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
CheckTwoFactorCodeListener::isValidCode at src/bundle/Security/Http/EventListener/CheckTwoFactorCodeListener.php:32-53 dispatches TwoFactorAuthenticationEvents::CHECK before calling validateAuthenticationCode.
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).
- The listener inspects
$cacheItem->isHit() after save(); on a hit it dispatches TwoFactorCodeReusedEvent.
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
Thank you to the Ecosystem Security Team at The PHP Foundation for sharing!
Affected versions
scheb/2fa-bundle(and thescheb/2fameta-package):>= 8.0Trace
CheckTwoFactorCodeListener::isValidCodeat src/bundle/Security/Http/EventListener/CheckTwoFactorCodeListener.php:32-53 dispatchesTwoFactorAuthenticationEvents::CHECKbefore callingvalidateAuthenticationCode.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 bysha1(userIdentifier . '.' . code).$cacheItem->isHit()aftersave(); on a hit it dispatchesTwoFactorCodeReusedEvent.ThrowExceptionOnTwoFactorCodeReuseListener(registered by default in src/bundle/Resources/config/security.php:83) converts that event intoReusedTwoFactorCodeException, aborting the login.Net effect: every code value the client submits — including wrong guesses — gets cached as 'already used' for
code_reuse_cache_durationseconds (default 60).Trust boundary
Crosses Authenticated-but-pending-2FA attacker boundary (boundary #2). Reaching this listener requires the attacker to first hold a
TwoFactorTokenInterfacetoken, 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:
The repro instantiates the listener's exact key construction (
sha1($identifier . '.' . $code)with prefixscheb_two_factor_code_reuse.) and PSR-6getItem/save/isHitflow. The bundle's own unit test at tests/Security/Http/EventListener/CheckTwoFactorCodeReuseListenerTest.php (checkForCodeReuse_validCacheProviderNoCacheHit_cacheItemIsSaved) confirms that the cache item issave()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.
CheckTwoFactorCodeReuseListenerwrites every submitted_auth_codevalue 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,ThrowExceptionOnTwoFactorCodeReuseListenerrejects 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 asReusedTwoFactorCodeException, producing a temporary, low-probability denial-of-service against the victim's 2FA login flow. It is not an authentication bypass.Preconditions:
code_reuse_cache(defaults tonull; applications without a configured cache backend are unaffected).code_reuse_default_handler(ThrowExceptionOnTwoFactorCodeReuseListener) is wired.TwoFactorTokenInterfacetoken, i.e. first-factor authentication has succeeded.References