agentggagentgg
Back to all findings
CRITICALconfirmedtwo-factor-bypasstwo-factor-bypass22e1a9eb9059

Brute-force-friendly rate limit on 2FA TOTP verification endpoint

`POST /rest/2fa/verify` is rate-limited at 100 attempts per 5 minutes per IP with no per-account lockout, and the verifier uses `epochTolerance: 30` (≈30 minutes of valid codes); combined this is well within practical brute-force range of a 6-digit TOTP.

Fileserver.ts
Lines451454
Confidence
70%
File statusvalidated
Details

server.ts L451-454:

app.post('/rest/2fa/verify',
  rateLimit({ windowMs: 5 * 60 * 1000, max: 100, validate: false }),
  utils.asyncHandler(twoFactorAuth.verify)
)

The 2FA flow itself is otherwise correctly structured — routes/login.ts returns a partial tmpToken (typed password_valid_needs_second_factor_token) instead of a full session when totpSecret is set, and routes/2fa.ts L20-24 derives the user id from the signed token rather than req.body. The weakness is purely brute-force resistance:

  • The bucket is 100 requests per 5 minutes (20/min). The brief considers anything above ~5/min on a 6-digit code brute-forceable.
  • app.enable('trust proxy') is set globally (L339) and the verify-2fa rate limiter does not pin its keyGenerator to anything stable, so X-Forwarded-For controls the bucket key — an attacker rotates that header and the limit effectively disappears.
  • The limit is per-IP, not per tmpToken.sub / userId, so an attacker can rotate IPs against a single victim.
  • routes/2fa.ts L31 verifies the supplied code with verifySync({ secret: user.totpSecret, token: totpToken, epochTolerance: 30 }).valid. In otplib epochTolerance is measured in 30-second windows, so 30 epochs widens the accepted-code set to ~61 codes per moment, shrinking the effective search space from 10^6 to ~1.6×10^4. At 20 attempts/minute that exhausts in ~13 hours from a single IP, and minutes with IP rotation.

The net effect: holder of a victim's password (e.g., obtained via the weak password policy or the SQL-injection login route at routes/login.ts L33) can brute-force the second factor by sending guesses to /rest/2fa/verify along with the tmpToken returned by /rest/user/login.

Proof of concept
  1. Authenticate password-step against a 2FA-enabled account and capture the tmpToken:

`` POST /rest/user/login {"email":"wurstbrot@juice-sh.op","password":"EinBelegtesBrotMitSchinkenSCHINKEN"} → 401 { status: "totp_token_required", data: { tmpToken: "..." } } ``

  1. Brute-force /rest/2fa/verify rotating X-Forwarded-For:

`` for code in 000000..999999: POST /rest/2fa/verify X-Forwarded-For: <random IP> { "tmpToken": "...", "totpToken": "<code>" } ` Because the limiter is keyed off req.ip (derived from XFF under trust proxy`), each random XFF resets the bucket, and the 30-epoch tolerance means ≥61 of every 10⁶ codes succeed.

Impact

An attacker who knows the victim's password can defeat the second factor at scale, fully bypassing 2FA for any account. No additional authentication is required. Affects every endpoint protected by the 2FA-issued bearer token.

Validation
confirmed

Lines 451-454 set rateLimit({ windowMs: 5*60*1000, max: 100, validate: false }) with no custom keyGenerator, and app.enable('trust proxy') is set globally above, so the limiter keys on req.ip derived from attacker-controlled X-Forwarded-For. The limit is per-IP rather than per tmpToken.sub, with no account lockout middleware, so password-holding attackers can rotate XFF to brute-force the 6-digit TOTP — especially given the wide epochTolerance in routes/2fa.ts cited by the detector. The exploit path (login → tmpToken → brute-force /rest/2fa/verify) is reachable from an unauthenticated network attacker once a password is known, defeating the second factor.

CVSS 3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
Base score: 9.1 · CRITICAL

/rest/2fa/verify (L451-454) is reachable over the network with no authentication middleware — only a tmpToken issued by /rest/user/login is needed, so PR:N and UI:N. With app.enable('trust proxy') (L339) the limiter keys off req.ip, which is attacker-controlled via X-Forwarded-For, so the 100/5min cap is trivially bypassed; combined with epochTolerance: 30 (~61 accepted codes out of 10^6) the brute-force is mechanically straightforward (AC:L). The password prerequisite is a chained precondition (and the finding notes a SQLi login path) but the 2FA layer itself collapses without special conditions. Successful exploitation yields full account takeover (C:H/I:H) against any 2FA-enabled user, including high-value accounts; impact is confined to the app (S:U) and the bug isn't itself a DoS (A:N).

References