Generate the token with the secrets module: secrets.token_urlsafe(32) is 256 bits from the OS CSPRNG. Never use the random module (a Mersenne Twister) or seed it with time.time(), and never 'fix' a weak token by hashing it. Compare the stored token in constant time with hmac.compare_digest, and keep it single-use with a short expiry.
- Find every security token built from the random module or from a timestamp: reset tokens, session IDs, API keys, 'unguessable' links. random.random, random.randint, random.choice and random.getrandbits all share one predictable Mersenne Twister.
- Replace the generator with secrets.token_urlsafe(32) (256 bits) or secrets.token_bytes/token_hex; the secrets module draws from os.urandom, the OS CSPRNG.
- Delete any random.seed(...) call and any step that hashes a timestamp; SHA-256(str(time.time())) has exactly the entropy of the timestamp.
- Store only a hash of the token, give it a short expiry, and mark it used on first success.
- Compare the presented token with hmac.compare_digest (equivalently secrets.compare_digest) over fixed-length digests, never ==.
- Add regression tests that replay random.Random over the issue-time window and brute-force SHA-256 of the clock, and assert neither reproduces the issued token.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def generate_token() -> str:
# Seeding random.Random with the clock gives only as much entropy as the uncertainty in
# the issue time, not the 128 bits the 16-byte length suggests. Even the module-level
# random.random(), auto-seeded, is recoverable: 624 outputs reconstruct its full state.
rng = random.Random(int(time.time() * 1000))
return rng.randbytes(16).hex()With IMPL=vulnerable, 3 of 10 tests fail (3 exploit tests).
The fix: generate from a CSPRNG
def generate_token() -> str:
# secrets.token_urlsafe(n) draws n bytes from os.urandom (the OS CSPRNG) and encodes them
# URL-safely. 32 bytes is 256 bits, well above the 128-bit floor for a security token.
# Never use the random module for tokens; it is a Mersenne Twister, not a CSPRNG.
return secrets.token_urlsafe(32)With IMPL=fixed, 10 of 10 tests pass.
The fix: constant-time compare
def compare_token(presented: str, stored: str) -> bool:
# Constant-time comparison (CWE-208). Hashing both sides to a fixed 32 bytes first means a
# token of the wrong length still takes the same time and never short-circuits.
a = hashlib.sha256(presented.encode("utf-8")).digest()
b = hashlib.sha256(stored.encode("utf-8")).digest()
return hmac.compare_digest(a, b)With IMPL=fixed, 10 of 10 tests pass.
A fix that looks safe but is not
def generate_token() -> str:
material = str(int(time.time() * 1000))
return hashlib.sha256(material.encode("utf-8")).hexdigest()With IMPL=wrong, 2 of 10 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
def test_exploit_the_victim_token_cannot_be_reproduced_by_replaying_a_prng(client):
start = int(time.time() * 1000)
token = request_reset(client, VICTIM).get_json()["token"]
end = int(time.time() * 1000)
assert token not in set(prng_tokens_for_window(start, end)), "the token was reproduced from the clock alone"
def test_exploit_the_victim_token_cannot_be_recovered_by_hashing_the_issue_time(client):
start = int(time.time() * 1000)
token = request_reset(client, VICTIM).get_json()["token"]
end = int(time.time() * 1000)
assert token not in set(hashed_clock_tokens_for_window(start, end)), "the token was SHA-256 of the clock"
def test_exploit_the_victim_account_cannot_be_taken_over_with_a_forged_token(client):
start = int(time.time() * 1000)
request_reset(client, VICTIM) # the attacker triggers the victim's reset and notes the time
end = int(time.time() * 1000)
forged = prng_tokens_for_window(start, end) + hashed_clock_tokens_for_window(start, end)
took_over = any(confirm_reset(client, VICTIM, token).status_code == 200 for token in forged)
assert not took_over, "a forged token reset the victim account"
def test_exploit_the_token_comparison_runs_in_constant_time(client, monkeypatch):
calls = []
real_compare_digest = hmac.compare_digest
def spy(a, b):
calls.append(1)
return real_compare_digest(a, b)
# secrets.compare_digest is the same function under another name.
monkeypatch.setattr(hmac, "compare_digest", spy)
monkeypatch.setattr(secrets, "compare_digest", spy)
token = request_reset(client, VICTIM).get_json()["token"]
confirm_reset(client, VICTIM, token)
assert calls, "compare_token did not use hmac.compare_digest"- The module-level functions (random.random, random.randint) and a seeded random.Random share the same Mersenne Twister. It is auto-seeded from os.urandom at import, but its state is still recoverable from 624 consecutive 32-bit outputs, so it is never safe for secrets even unseeded.
- secrets.token_urlsafe(n) counts n in bytes, not characters: token_urlsafe(32) is 32 bytes (256 bits) rendered as about 43 characters. Passing a character count by mistake can shrink the entropy.
- hmac.compare_digest needs equal-length inputs of the same type; hash both sides to a fixed 32 bytes (as impl/fixed.py does) so a wrong-length token neither throws nor leaks its length.
- A uuid4() is fine as an identifier but carries only 122 bits and, in CPython, comes from os.urandom; ASVS 11.5.1 still asks for 128, so use secrets for tokens.
- Do not log or echo the token or place it in a URL that reaches Referer headers or access logs. This card returns it only to model the reset link the test reads.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs insecure-randomness/python-flask does this for you).
- Against your own app, request a reset for an account you control and confirm the token is ~43 URL-safe characters (32 bytes) with no visible structure across requests, and that the lookup uses hmac.compare_digest.
- Bandit's B311 flags the vulnerable random.Random call but says nothing about wrong.py's hashed timestamp; Semgrep's default pack found nothing. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/default | No findings on any implementation. Semgrep CE's default pack has no rule for use of the Python random module as a security RNG; Bandit's B311 is the tool that covers this, and it is recorded under 'other' below.Checked: Semgrep 1.180.0 (CE) |
| Other | Bandit B311 | Flags impl/vulnerable.py line 15, random.Random(int(time.time()*1000)), as 'Standard pseudo-random generators are not suitable for security/cryptographic purposes' (LOW severity, HIGH confidence). No finding on impl/fixed.py (secrets) or impl/wrong.py (hashlib). B311 flags the random module by name but cannot tell that wrong.py's SHA-256-of-a-timestamp is just as guessable.Checked: Bandit 1.9.4 (bandit -t B311) |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| werkzeug | 3.1.9 |
| secrets, hmac, hashlib (standard library) | Python 3.14.8 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the insecure-randomness/python-flaskfolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs insecure-randomness/python-flask runs all three modes and checks the expected results.
Verified
- Machine
- Apple M5 Max, macOS 27.0
- Toolchain
- python 3.14.8
- pip 26.2.1
- Matrix
- fixed: 10 of 10 tests pass; vulnerable: 3 of 10 tests fail (3 exploit tests); wrong: 2 of 10 tests fail (2 exploit tests)
- Test results
- Offline run of run-all.mjs (outbound network blocked): fixed 10/10 pass; vulnerable 3/10 fail (3 exploit); wrong 2/10 fail (2 exploit)
- Dependency freshness
- flask 3.1.3 (2026-02-19), werkzeug 3.1.9 (2026-09-27, fixes GHSA-g6x2-hccm-hh4m) and pytest 9.1.1 (2026-06-19) are the current releases and all more than 14 days old. requirements.txt pins all 12 packages with ==; no hashes.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- Python secrets module (token_urlsafe, compare_digest)
- Python random module: 'not suitable for security purposes'
- Python hmac.compare_digest
- Bandit B311: standard pseudo-random generators
- OWASP ASVS 5.0.0 V11.5 Random Values (11.5.1)
- CWE-338: Use of Cryptographically Weak PRNG
- CAPEC-59: Session Credential Falsification through Prediction
Attack patterns: