//nefariousplan

CVE-2026-41660: Admidio's 2FA Reset Check Was Inverted For Fifteen Months

pattern

cve

proof of concept

The patch is one character. !== becomes ===. For fifteen months, Admidio's 2FA reset endpoint enforced exactly one rule: non-admin users could not disable their own 2FA, but they could disable anyone else's, including administrators. The rule the developer believed they were enforcing was the opposite of what they wrote. Group leaders stripping admin TOTP was not the bug. It was what the check was for.

The check is not missing. It is inverted.

The vulnerable code is one if statement in modules/profile/two_factor_authentication.php, in the handler for mode=reset:

} elseif ($getMode === 'reset') {
    // check form field input and sanitized it from malicious content
    $profilePasswordEditForm = $gCurrentSession->getFormObject($_POST['adm_csrf_token']);
    $formValues = $profilePasswordEditForm->validate($_POST);

    if (!($gCurrentUser->isAdministrator() || $gCurrentUserId !== $userId)) {
        throw new Exception('SYS_NO_RIGHTS');
    }

    // Reset two factor authentication settings
    $user->setSecondFactorSecret(null);
    $user->save();

Read the predicate. The if throws when:

!(isAdmin || currentId !== targetId)

By De Morgan's law, that reduces to:

(!isAdmin) && (currentId === targetId)

In English: throw SYS_NO_RIGHTS when the caller is not an administrator AND the caller IS the target user. The check blocks non-admin users from resetting their own 2FA. Any non-admin user who can construct a request against another user's profile, a group leader with profile-edit rights is the canonical case the advisory cites, falls through the predicate silently. setSecondFactorSecret(null) runs against the other user's record. The other user can be an administrator.

The fix on April 12 flips one operator:

-        if (!($gCurrentUser->isAdministrator() || $gCurrentUserId !== $userId)) {
+        // only administrators could reset 2FA of other users
+        if (!($gCurrentUser->isAdministrator() || $gCurrentUserId === $userId)) {
             throw new Exception('SYS_NO_RIGHTS');
         }

!== becomes ===. The same reduction now reads: throw when the caller is not an administrator AND the caller is not the target user. Which is the policy the new comment above the line states out loud.

That comment is the second change in this commit. The original code shipped without it. For fifteen months, the policy the check was supposed to enforce was not written down anywhere in the file; it lived only in the operator. The operator was wrong.

The file was added to the repository on December 29, 2024, in commit 83adc3ecb ("Add second factor authentication to user profile"). The inverted check is in that initial commit. The fix is in commit adf5c5c0e on April 12, 2026. Five hundred and four days separate them. CWE-863 ("Incorrect Authorization") names this exactly, the check exists, parses, executes, and answers the question. The question is the inverse of the one the policy required.

The shape of the predicate is itself a quiet tell. !(A || B) with one negated comparison inside the OR is a doubly-negated form a reviewer normalizing for readability would rewrite as !A && B (where B remains currentId !== targetId), and would have caught immediately: "we throw when you're not an admin and you're not the target, that means we permit when you're not an admin and you ARE the target, why does this code go out of its way to let non-admins reset their own 2FA?" Nobody rewrote it. It survived review in its original form and stayed in the production file unmodified through every subsequent edit to that file.

The exploit is one POST

A group leader on an Admidio installation has profile-edit rights against the members of the groups they lead. If an administrator happens to be a member of one of those groups, that group leader's role grants edit rights on the admin's profile. Edit rights on a profile are the precondition the advisory names; the rest of the chain is one HTTP request:

POST /adm_program/modules/profile/two_factor_authentication.php
Cookie: PHPSESSID=<group-leader-session>
Content-Type: application/x-www-form-urlencoded

adm_csrf_token=<harvested>&mode=reset&user_uuid=<admin-uuid>

The handler enters the reset branch. The CSRF token passes (the group leader has a valid session and harvested the token from any form they're allowed to render). The inverted predicate evaluates: caller is not an admin, target is not the caller, the check passes. setSecondFactorSecret(null) runs against the admin's user record. $user->save() commits.

The administrator's TOTP enrollment is gone. The next time that administrator logs in, password is sufficient. There is no audit log row that distinguishes "the admin reset their own TOTP" from "a group leader reset it for them"; the codepath is the same. The admin's next login looks identical to every previous login except that the TOTP prompt never appears.

This is the precondition the CVSS vector encodes as PR:L/UI:N/S:U/I:H. Not an unauthenticated bypass, an authenticated, in-product privilege escalation that converts "the group leader of a group the admin happens to belong to" into "the person who decides whether the admin's account has 2FA." Combined with any path that gives an attacker the admin's password, password reuse, phishing, a separate password reset bug, the 2FA reset endpoint is the gate that decides whether the password is sufficient.

This is the smallest fix in a release that shipped eight of them

git log v5.0.8..v5.0.9 --author=Markus returns thirteen commits in five weeks. At least eight of them are access-control or CSRF fixes:

Commit Date Title
adf5c5c0e 2026-04-12 2FA could be resetted by other users #2023
9c1489706 2026-05-02 check valid login before loading user data
973fb7f20 2026-05-02 Folder rights not checked when working with files #2035
2d4a253fd 2026-05-02 Password reset mails by default sent to all email address fields #2026
2acba0371 2026-05-03 Missing CSRF token check when sending new login data #2038
067b77f13 2026-05-04 Fix rights check for category editing #2039
d26ddd91f 2026-05-04 CSRF token check in enablen SSO missing #2041
84cf61dc6 2026-05-07 CSRF protection on SSO key export missing #2049

One maintainer. Five weeks. Eight bugs of the same family: rights checks missing, rights checks wrong, CSRF tokens missing, sensitive flows aimed at the wrong destination. One of these eight, the inverted operator on 2FA reset, got a CVE number. The other seven were filed as issues on the regular tracker, fixed by the same author, and shipped in the same release.

The thing that made this one a CVE is not its severity. It is the disclosure channel. Issue #2023 was opened against Admidio/admidio via the GitHub Security Advisory workflow, which generated GHSA-rh3w-4ccx-prf9, which got a CVE from GitHub's CNA. The other seven were opened by users who noticed during normal use. Same class of bug. Same maintainer. Same commit cadence. Different paperwork.

The substrate is visible if you read the other commits

Commit 067b77f13, two weeks after the 2FA fix, is titled "Fix rights check for category editing." The pre-patch rights check in modules/categories.php reads the category type from a URL parameter the caller controls:

$getType = admFuncVariableIsValid($_GET, 'type', 'string', array('validValues' => array('ANN', 'AWA', 'EVT', 'FOT', 'LNK', 'ROL', 'USF', 'IVT')));

// check rights of the type
if (($getType === 'ANN' && !$gCurrentUser->isAdministratorAnnouncements())
    || ($getType === 'AWA' && !$gCurrentUser->isAdministratorUsers())
    || ($getType === 'EVT' && !$gCurrentUser->isAdministratorEvents())
    ...

A caller who is an announcements administrator (isAdministratorAnnouncements) sends type=ANN. The rights check passes. The action then operates on a category UUID, which can belong to a different cat_type, the Category record's actual type, loaded from the database, is never compared against the URL-supplied type. An announcements admin edits a role category.

The fix moves the rights check to run after $category->readDataByUuid($getCategoryUUID) and re-evaluates it against $category->getValue('cat_type') rather than $getType. Same eight-line OR chain, evaluated against record state instead of caller state.

Commit 973fb7f20 ("Folder rights not checked when working with files") is a third instance of the same class. The file rename and move forms passed folder_uuid as a URL parameter the caller controlled, and the rights check evaluated against that parameter. The fix removes folder_uuid from the URL entirely and looks up the actual parent folder from the file record after loading it.

Three modules in five weeks. Three different mechanism shapes:

  • two_factor_authentication.php: the predicate's logic is inverted.
  • categories.php: the predicate evaluates against the wrong input source.
  • documents-files.php: the predicate evaluates against an attacker-supplied identifier instead of looking up the real one.

The common substrate is structural. There is no central authorization helper in this codebase. Every module re-implements its rights check inline, in PHP, in whatever shape the author of that module had in their head at the time. The reviewer for each PR has to derive the intent from the local predicate and judge whether the predicate matches the intent. The reviewer for two_factor_authentication.php had to derive an unwritten policy from one line of double-negated boolean. The reviewer for categories.php had to notice that the input feeding the rights check came from a different request parameter than the input feeding the action. Neither reviewer caught the gap, because the gap is built into the substrate, not into any individual line.

This is the design-debt-driver shape with the substrate named. The patches close instances. The substrate produces them.

What the changelog doesn't say

The NVD entry for CVE-2026-41660 will describe a 2FA bypass with CVSS 7.1. The advisory will say to upgrade to 5.0.9. Both are correct. Neither says what the release notes don't: the same release closes seven more access-control holes that the maintainer filed and fixed inside a month, none of which has a CVE number, none of which an Admidio user reading the changelog will know to look for.

Patch reference: Admidio/admidio@adf5c5c0e Advisory: GHSA-rh3w-4ccx-prf9

The patch closes the typo. The substrate that lets a typo become an authorization model still ships.