Skip to content

Code-reuse cache is updated before code validation, enabling pre-poisoning #317

@scheb

Description

@scheb

Thank you to the Ecosystem Security Team at The PHP Foundation for sharing!

Affected versions

  • scheb/2fa-bundle (and the scheb/2fa meta-package): >= 8.0

Trace

  1. CheckTwoFactorCodeListener::isValidCode at src/bundle/Security/Http/EventListener/CheckTwoFactorCodeListener.php:32-53 dispatches TwoFactorAuthenticationEvents::CHECK before calling validateAuthenticationCode.
  2. CheckTwoFactorCodeReuseListener::checkForCodeReuse (subscribed to CHECK at priority 0) takes $event->getCode() — the raw value the client posted as _auth_code — and stores it in the PSR-6 cache keyed by sha1(userIdentifier . '.' . code).
  3. The listener inspects $cacheItem->isHit() after save(); on a hit it dispatches TwoFactorCodeReusedEvent.
  4. ThrowExceptionOnTwoFactorCodeReuseListener (registered by default in src/bundle/Resources/config/security.php:83) converts that event into ReusedTwoFactorCodeException, aborting the login.
    Net effect: every code value the client submits — including wrong guesses — gets cached as 'already used' for code_reuse_cache_duration seconds (default 60).

Trust boundary

Crosses Authenticated-but-pending-2FA attacker boundary (boundary #2). Reaching this listener requires the attacker to first hold a TwoFactorTokenInterface token, which Symfony's firewall only assigns after first-factor (password) authentication succeeds. So this is a post-password-compromise scenario.

Validation

Reproduction with the bundle's own cache key format and PSR-6 semantics:

$ php /tmp/test_repro.php
Attacker submits guess 999999 -> reuse?: no
Cache entry created: YES
(Note: CHECK event fires BEFORE code validation in CheckTwoFactorCodeListener::isValidCode,
 so the cache is poisoned regardless of whether 999999 is the right code.)

Legitimate user submits 999999 -> reuse?: YES
  -> ThrowExceptionOnTwoFactorCodeReuseListener will throw ReusedTwoFactorCodeException
  -> The user's legitimate authentication is blocked.

The repro instantiates the listener's exact key construction (sha1($identifier . '.' . $code) with prefix scheb_two_factor_code_reuse.) and PSR-6 getItem/save/isHit flow. The bundle's own unit test at tests/Security/Http/EventListener/CheckTwoFactorCodeReuseListenerTest.php (checkForCodeReuse_validCacheProviderNoCacheHit_cacheItemIsSaved) confirms that the cache item is save()d during the first call, with no validity check on the code.

Summary

Reuse-cache pre-poisoning denies legitimate 2FA login in scheb/2fa-bundle. CheckTwoFactorCodeReuseListener writes every submitted _auth_code value into the PSR-6 reuse cache before the code has been validated, so any guess an attacker submits is recorded as "already used". If a poisoned value later collides with the victim's next real TOTP/HOTP code, ThrowExceptionOnTwoFactorCodeReuseListener rejects the victim's legitimate login as a reuse.

Impact

An authenticated-but-pending-2FA attacker (one who has already compromised the victim's first-factor credentials) can pre-poison the reuse cache for the victim's account. When a poisoned guess collides with a future legitimate code (collision probability ~1/10^4 to 1/10^6 per attempt depending on digits), the victim's next 2FA submission is rejected as ReusedTwoFactorCodeException, producing a temporary, low-probability denial-of-service against the victim's 2FA login flow. It is not an authentication bypass.

Preconditions:

  • The application has opted in by configuring code_reuse_cache (defaults to null; applications without a configured cache backend are unaffected).
  • The default code_reuse_default_handler (ThrowExceptionOnTwoFactorCodeReuseListener) is wired.
  • The attacker already holds a TwoFactorTokenInterface token, i.e. first-factor authentication has succeeded.

References

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions