-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The signal was on a return value the caller never read\n\nCVE-2026-41669 is the discard. `validateSignature()` is declared on line 354 of the pre-patch file with this signature:\n\n```php\npublic function validateSignature(SAMLClient $client, SamlMessage $message, bool $required = false): bool|string {\n```\n\n`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:\n\n```php\n// Client has no cert configured...\nif (!$certPem) {\n $SPcert = null;\n if ($required) {\n return $gL10n->get('SYS_SSO_SAML_SIGNATURE_KEY_MISSING');\n } else {\n return false;\n }\n}\n...\n$signatureReader = $message->getSignature();\nif (is_null($signatureReader)) {\n if ($required) {\n return $gL10n->get('SYS_SSO_SAML_SIGNATURE_MISSING');\n } else {\n return false;\n }\n}\ntry {\n $ok = $signatureReader->validate($SPcert);\n if ($ok) {\n return true;\n } else {\n return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED');\n }\n} catch (Exception $ex) {\n return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED');\n}\n```\n\nThree 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.\n\nThe 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:\n\n```php\n// Validate signatures. Will throw an exception\nif ($client->getValue('smc_require_auth_signed') || $client->getValue('smc_validate_signatures')) {\n $this->validateSignature($client, $request, $client->getValue('smc_require_auth_signed'));\n}\n```\n\nThe 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.\n\nIf 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.\n\n## Both comments are the developer's testimony\n\nThe 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`.\n\n`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.\n\nA 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.\n\nThis is the shape that the [fail open intercept](/patterns/fail-open-intercept) pattern names, transposed from try/catch into return values. In the [Tomcat EncryptInterceptor exhibit](/posts/tomcat-encryptinterceptor-fails-open), 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.\n\n## An unsigned AuthnRequest gets the same response as a signed one\n\nThe 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 `` 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.\n\nThe attacker constructs a request claiming to be issued by any SAML client registered on the target IdP:\n\n```xml\n\n\n https://victim.example/admidio/sp/registered-client\n\n```\n\nNo `` 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 `` on the request, and returns the translation key `SYS_SSO_SAML_SIGNATURE_MISSING`. The return value is discarded. Execution continues on line 421.\n\nWhat 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:\n\n```php\n} elseif (method_exists($request, 'getAssertionConsumerServiceURL')) {\n $response->setDestination($request->getAssertionConsumerServiceURL());\n}\n```\n\nPost-patch:\n\n```php\n// Always use the registered ACS URL, never the request's ACS URL\n$response->setDestination($client->getValue('smc_acs_url'));\n```\n\nThe 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.\n\nThe CVE record names the signature-discard half. The deliverable is the chain.\n\n## The same patch fixed the same shape in the OIDC service\n\nCommit `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`.\n\nThe pre-patch `handleIntrospectionRequest()`:\n\n```php\npublic function handleIntrospectionRequest() {\n // TODO_RK\n if (!$this->isServiceSetup) {\n $this->setupService();\n }\n return new JsonResponse([\"active\" => true]);\n}\n```\n\nOAuth 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`.\n\n`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).\n\nThe 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.\n\n## The patch is the function doing what the comment always said it did\n\nThe diff against `validateSignature()`:\n\n```diff\n- public function validateSignature(SAMLClient $client, SamlMessage $message, bool $required = false): bool|string {\n+ public function validateSignature(SAMLClient $client, SamlMessage $message, bool $required = false): bool\n+ {\n global $gL10n;\n $certPem = $client->getValue('smc_x509_certificate');\n if (!$certPem) {\n $SPcert = null;\n if ($required) {\n- return $gL10n->get('SYS_SSO_SAML_SIGNATURE_KEY_MISSING');\n+ throw new Exception($gL10n->get('SYS_SSO_SAML_SIGNATURE_KEY_MISSING'));\n } else {\n return false;\n }\n }\n ...\n $signatureReader = $message->getSignature();\n if (is_null($signatureReader)) {\n if ($required) {\n- return $gL10n->get('SYS_SSO_SAML_SIGNATURE_MISSING');\n+ throw new Exception($gL10n->get('SYS_SSO_SAML_SIGNATURE_MISSING'));\n } else {\n return false;\n }\n }\n try {\n $ok = $signatureReader->validate($SPcert);\n if ($ok) {\n return true;\n } else {\n- return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED');\n+ throw new Exception($gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED'));\n }\n- } catch (Exception $ex) {\n- return $gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED');\n+ } catch (Exception) {\n+ throw new Exception($gL10n->get('SYS_SSO_SAML_SIGNATURE_FAILED'));\n }\n }\n```\n\nEvery `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.\n\n## smc_require_auth_signed was a setting before it was a control\n\nThe 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.\n\nThe 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.\n\nEvery nefariousplan post about [fail open intercept](/patterns/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.\n\nThe 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.\n\nPoC: [GHSA-25cw-98hg-g3cg](https://github.com/advisories/GHSA-25cw-98hg-g3cg)","closing_line":"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.","hook_md":"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.\n\nThat 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.","post_id":269,"slug":"admidio-smc-require-auth-signed-was-a-setting","title":"CVE-2026-41669: Admidio's smc_require_auth_signed Was a Setting Before It Was a Control","type":"initial","unreadable_sentence":"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."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCarvwSgAKCRDeZjl4jgkQ JgqoAP4xPKgjfbHFBoLsxJxWImMclC9FpwKXDJRvpPlP6zozbAEAib+gjeNRM3Ye u6HdN2gd8UPAh3M8mPfqfkVyn6i1nQg= =4bL1 -----END PGP SIGNATURE-----