Skip to main content

Command Palette

Search for a command to run...

Anatomy of a GHSA Collaboration: Fixing Filament's MFA Race Together

Updated
11 min read
Anatomy of a GHSA Collaboration: Fixing Filament's MFA Race Together

I. Introduction

Filament is an open-source full-stack UI framework for Laravel built on top of Livewire. It lets developers compose admin panels, forms, tables, infolists, actions, and notifications as type-checked PHP, and ships first-party multi-factor authentication out of the box (TOTP apps, email codes, recovery codes).

It is one of the most widely adopted admin-panel frameworks in the PHP ecosystem (filament/filament on Packagist, ~31k stars on GitHub), used as the back-office layer of a large number of production Laravel apps including SaaS dashboards, internal tools, and customer portals.

Vulnerable Version: filament/filament >= 4.3.1, < 4.11.5 and >= 5.0.0, < 5.6.5 (every release that contains the previous patch for GHSA-pvcv-q3q7-266g / CVE-2025-67507).

Advisory: https://github.com/filamentphp/filament/security/advisories/GHSA-mc5j-f6wx-h9qh

CVE: CVE-2025-67507

Merged fix: filamentphp/filament#19891 security: Prevent 2FA concurrent recovery code reuse

II. Target selection

I was working through Anthropic's plan to use AI for finding bugs in open-source projects. After scanning GitHub and Packagist for high-traffic Laravel libraries, I landed on Filament.

From a few basic pieces of information it looked like a promising target: it is an authentication-aware framework that ships its own multi-factor authentication implementation (TOTP, email codes, recovery codes), and the published security advisory page had two fixes within the previous six months a recovery-code reuse bug and an XSS in summarizer columns. Both were recently patched, which is exactly when incomplete-fix bugs are richest: the maintainer wrote the patch under pressure, the test added with the patch only covers the path the maintainer thought to check, and any sibling assumption the patch silently relies on is fair game.

It was also more interesting than a typical web app because a bug in framework MFA code is a library bug it affects every Filament-powered admin panel that has app-based MFA with recovery codes enabled.

III. Finding

After cloning the repository I immediately proceeded to prompt for bug hunting. Here I used:

  • Claude Code (Claude CLI)

  • Model: Claude Opus 4.7

I pointed the model at the previous advisory (GHSA-pvcv-q3q7-266g) and its patch commit (87ff60ad9b). The patch turned verifyRecoveryCode() from "return true on first match" into "filter the matching hash out of an array and re-save it." That is a classic read-modify-write cycle with no atomic guarantee in sight, which is the standard incomplete-fix shape for replay-style bugs, so I decided to focus the audit there.

I used the following prompt (I also used AI to generate this prompt based on the application's source code and the previous advisory's wording):

You are a senior offensive security researcher specialized in PHP / Laravel,Livewire applications, multi-factor authentication flows, race conditions and TOCTOU bugs, and incomplete-fix discovery.

Your task is to audit this Filament codebase.

IMPORTANT:
- Do not assume the patch fully closed the surface. Incomplete fixes are a vulnerability class.
- Do not assume framework code is safe because it has tests. A test that does not exercise concurrency cannot detect a race.
- Per-request authentication flows in Laravel run in separate processes (PHP-FPM workers). Treat anything that does "read -> mutate in PHP -> save" as racy by default unless it has explicit locking.
- bcrypt is slow on purpose; that is your race window.

Your workflow:

1. Locate the patch commit (87ff60ad9b) and read every line it changed.
2. Read the storage layer for the attribute the patch touches  (InteractsWithAppAuthenticationRecovery::saveAppAuthenticationRecoveryCodes).
3. Map the read-modify-write sequence and identify:
   - Where the read happens (which model instance? cached attribute?)
   - How long PHP holds memory between read and save
   - Whether anything atomic protects the write
     (transaction? lockForUpdate? Cache::lock? optimistic version?)
4. Build a Docker PoC that:
   - Pins the AFFECTED version
   - Stands up a real database
   - Uses pcntl_fork() to call verifyRecoveryCode() in parallel
   - Asserts that the post-call DB state matches the patch's intent
5. Re-run on the patched version. Tally success/failure across N runs.
6. If the race reproduces, build a payload narrative:
   - Who exploits, what pre-conditions, what they gain.
   - Show the specific row in the original advisory's threat model that this bypass extends.

Output format:

## Candidate N
- Vulnerability type
- Reachable surface
- Source
- Sink
- Vulnerable code (file:line)
- Why the existing patch fails
- Concurrency analysis
- Payload / exploitation primitive
- Reproducible PoC (script + N runs + tally)
- Impact
- Confidence level

At the end:
- Rank findings by likelihood of being exploitable.
- Clearly separate the confirmed finding from suspicious code.

After that, the AI produced a result showing it had found the issue. The data flow it reconstructed was:

verifyRecoveryCode(recoveryCode, user)
  -> getRecoveryCodes(user)
        -> $user->app_authentication_recovery_codes  // PHP in-memory cached attribute
  -> foreach Hash::check(recoveryCode, $hashedCode)  // bcrypt, ~50-200ms × 8 codes ≈ 1.5s race window
  -> $remainingCodes built from non-matching hashes
  -> \(user->saveAppAuthenticationRecoveryCodes(\)remainingCodes)
        -> \(this->app_authentication_recovery_codes = \)codes
        -> $this->save()                              // plain Eloquent UPDATE — no WHERE, no lock

The sink it identified, in packages/panels/src/Auth/MultiFactor/App/AppAuthentication.php:195-216 (v5.6.4):

public function verifyRecoveryCode(string \(recoveryCode, ?HasAppAuthenticationRecovery \)user = null): bool
{
    $user ??= Filament::auth()->user();

    $remainingCodes = [];
    $isValid = false;

    foreach (\(this->getRecoveryCodes(\)user) as $hashedRecoveryCode) {
        if (Hash::check(\(recoveryCode, \)hashedRecoveryCode)) {
            $isValid = true;
            continue;
        }
        \(remainingCodes[] = \)hashedRecoveryCode;
    }

    if ($isValid) {
        \(user->saveAppAuthenticationRecoveryCodes(\)remainingCodes);
    }

    return $isValid;
}

The model's conclusion: the prior patch (87ff60ad9b) only restructured the function to filter the matched hash out of an in-memory array and write it back. It is SELECT → mutate in PHP → UPDATE with no row lock, no transaction, no compare-and-swap. The bcrypt loop holds the window open for ~1–2 seconds. Two concurrent requests both read the full code set, both validate, both save() their own stale "remaining" array, and the later write overwrites the earlier — restoring a just-consumed code.

The most important part of the analysis was the concurrency reasoning. The maintainer's own regression test (it will not allow a recovery code to be used more than once) called verifyRecoveryCode() twice sequentially with auth()->logout() between calls. That tests sequential reuse and proves nothing about concurrent reuse, even though the advisory's underlying threat is "the same code authenticates more than one session." This made the finding strictly broader than the patched precedent: the per-code single-use guarantee the advisory asserted does not hold under load.

I then set up a Docker environment and continued to have the AI verify the bug end-to-end; it confirmed the bug was real on every release containing the previous fix.

Here is the vulnerable request (the normal app-MFA recovery code login Filament ships with):

POST /admin/login                        -> reaches MFA challenge
POST /livewire/update                    -> submits recoveryCode field

backed by:

TextInput::make('recoveryCode')
    ->rule(function () use ($user): Closure {
        return function (string \(attribute, mixed \)value, Closure \(fail) use (\)user): void {
            if (is_string(\(value) && \)this->verifyRecoveryCode(\(value, \)user)) {
                return;
            }
            $fail(__('...recovery_code.messages.invalid'));
        };
    })

After that, I continued to use AI to build the race PoC:

// poc.php — pcntl_fork two parallel verifyRecoveryCode() calls
$pidA = pcntl_fork();
if ($pidA === 0) {
    \(ok = \)provider->verifyRecoveryCode(\(plainCodes[0], TestUser::find(\)user->id));
    shmop_write(\(shm, sprintf("A:%s\n", \)ok ? 'OK' : 'FAIL'), 0);
    exit(0);
}
$pidB = pcntl_fork();
if ($pidB === 0) {
    \(ok = \)provider->verifyRecoveryCode(\(plainCodes[1], TestUser::find(\)user->id));
    shmop_write(\(shm, sprintf("B:%s\n", \)ok ? 'OK' : 'FAIL'), 2048);
    exit(0);
}

Five back-to-back runs against filament/filament v5.6.4 on SQLite:

=== Run 1 ===
Fork results: A:OK B:OK
DB now holds 7 hashed codes (started with 8).
Plain codes still valid after race: [0, 2, 3, 4, 5, 6, 7]
[!] BYPASS CONFIRMED — code [0] authenticated but was NOT removed from storage.

=== Run 2, 3, 4, 5 — identical bypass result ===

5/5 races bypassed. Both forks authenticate successfully (A:OK B:OK) but only one code is deleted from storage instead of two — the "loser" of the save race overwrites the winner's deletion with its stale snapshot, restoring an already-consumed recovery code. Replayed in pairs the attacker keeps a stolen 8-code recovery list valid indefinitely.

I submitted the bug shortly afterwards. Filament accepted it the same day, the maintainer opened a fix branch on the temporary private fork, and a CVE will be requested when v4.11.5 / v5.6.5 ship.

IV. Fix

What I did differently here from a normal advisory submission was use Claude Code to also collaborate on the fix in the temporary private fork. GitHub exposes a POST /repos/{owner}/{repo}/security-advisories/{ghsa_id}/forks endpoint that spins up a private repo under the maintainer's org once a report is accepted; Claude drove it end to end.

When I cloned the fork, the maintainer had already pushed a fix to a security/2fa-concurrent-recovery-code-reuse branch:

return DB::transaction(function () use (\(user, \)recoveryCode): bool {
    \(lockedUser = \)user
        ->newQuery()
        ->whereKey($user->getKey())
        ->lockForUpdate()
        ->first();
    // ... validate against \(lockedUser, save against \)lockedUser
});

A clean idea DB row lock plus a fresh re-read inside the transaction. I asked Claude to audit it the same way it audited the original patch, with one question: what assumption does this fix depend on? The answer it produced was "lockForUpdate() requires row-level locking in the database." That holds on MySQL and PostgreSQL. It does not hold on SQLite Laravel's compileLockForUpdate() for the SQLite grammar returns an empty string, and SQLite transactions are deferred by default, so two SELECTs coexist on a shared lock until a write commits. SQLite is a first-class Filament driver (composer test:sqlite runs the whole suite against it).

I re-ran the pcntl_fork race with the maintainer's fix overlaid, on SQLite:

### MAINTAINER's lockForUpdate() fix — true race on SQLite, 6 runs ###
Run 1: BYPASS CONFIRMED — code [0]
Run 2: BYPASS CONFIRMED — code [1]
Run 3: BYPASS CONFIRMED — code [1]
Run 4: BYPASS CONFIRMED — code [0]
Run 5: BYPASS CONFIRMED — code [1]
Run 6: BYPASS CONFIRMED — code [0]

Then spun up a MySQL 8.4 container with docker compose for a fair counter-test:

### MAINTAINER's lockForUpdate() fix — true race on MySQL 8.4, 5 runs ###
All 5: [ok] both raced codes consumed — fix held on mysql.

So the maintainer's fix worked on MySQL/PostgreSQL but did nothing on SQLite. The advisory was not fully closed.

The right addition was an application-level per-user lock that does not care about the database driver the same Cache::lock primitive Filament's own TOTP replay protection already uses in verifyCode() on the very same class. Claude wrote a 16-line additive change:

$lockKey = 'filament.app_authentication_recovery_codes.' . md5(
    \(user::class . ':' . ((\)user instanceof Authenticatable) ? \(user->getAuthIdentifier() : spl_object_id(\)user)),
);

return Cache::lock(\(lockKey, 10)->block(10, fn (): bool => DB::transaction(function () use (\)user, $recoveryCode): bool {
    // ... maintainer's transaction body unchanged
}));

The maintainer's transaction and row lock are kept untouched for MySQL/PostgreSQL; the cache lock adds mutual exclusion that does not depend on the database driver. Verified by re-running the matrix:

Build SQLite MySQL 8.4
Affected (v5.6.4) bypassed 5/5 (race fires)
Maintainer fix only bypassed 6/6 held 7/7
Combined fix held 6/6 held 5/5

All green. I pushed the branch to the private fork and opened a PR against the maintainer's security branch (not the public default branch):

gh pr create \
  --repo filamentphp/filament-ghsa-mc5j-f6wx-h9qh \
  --base security/2fa-concurrent-recovery-code-reuse \
  --head security/recovery-code-race-sqlite-fix \
  --title "Also serialise recovery code verification on SQLite (GHSA-mc5j-f6wx-h9qh)" \
  --body-file PR-BODY-sqlite.md

The PR body led with the empirical table the single piece of evidence that turned "feedback" into "actionable collaboration." Three hours later the maintainer merged the combined fix into 4.x via filamentphp/filament#19891, with my commit credited:

Merge commit from fork
Co-authored-by: StarPlatinu 
Co-authored-by: Dan Harrin 

The final code on 4.x is verbatim what Claude pushed; the maintainer's "cleanup" commit only shortened the cache key prefix.

V. Timeline

  • 2026-05-21: Submitted bug via the GitHub Security Advisory of the repository.

  • 2026-05-21: Filament maintainer Dan Harrin verified and accepted the advisory, opened a draft GHSA-mc5j-f6wx-h9qh, and pushed an initial fix (f28f92ce) to the temporary private fork (security/2fa-concurrent-recovery-code-reuse) using DB::transaction() + lockForUpdate().

  • 2026-05-21: I (via Claude Code) verified the initial fix against a pcntl_fork race on both SQLite and MySQL; SQLite was bypassed 6/6 because lockForUpdate() is a no-op there. Pushed a combined fix (Cache::lock wrapper + the maintainer's transaction) to the private fork and opened a PR against the maintainer's security branch.

  • 2026-05-21 17:47 UTC: Maintainer merged the combined fix into 4.x via PR #19891, with co-authorship credit. Email confirmation:

    Thanks, I am developing a fix, removing exploitation details from the report, and then after the fix is released I will request a CVE and publish this report when it is issued. merge request https://github.com/filamentphp/filament/pull/19891 Dan Harrin, 2026-05-21 20:56 ICT

  • 2026-05-23 10:54 UTC: GHSA-mc5j-f6wx-h9qh published, CVE-2026-48505 issued, releases 4.11.5 and 5.6.5 cut.

The whole pipeline finding an incomplete fix, building a real-concurrency PoC, racing the maintainer's first fix across two DB drivers, writing the combined patch, pushing to the private fork, opening the PR ran inside Claude Code on Opus 4.7 in a single session. The model carried the work from "this patch looks too small" to "merged co-authored commit on the main maintenance branch" without me leaving the terminal.