Randomness is useful right up until the same random number generator starts answering two different questions.
One part of the application uses it to make a decision. Another part uses it to build a secret. Both calls happen to use the same range, from the same generator state. Suddenly, the "secret" is no longer secret. It is just the next few values in a stream you already managed to observe.
That is the core idea here.
This article looks at a generic Python validation service that combines three small mistakes:
- the same PRNG stream is reused across two logically unrelated code paths,
- invalid numeric values are silently discarded,
-
and a final
all()check is performed over an emptyzip()result.
None of those mistakes looks catastrophic on its own. Together, they turn a four-stage validation process into something an attacker can walk through almost mechanically.
What Is PRNG Stream Reuse, Anyway?
A pseudorandom number generator is not magic. It is a deterministic state machine.
Given the same internal state, the next call returns the same value.
That distinction matters. "Random" in application code usually means "unpredictable enough for this use case", not "physically unpredictable". Python's random module is designed for general-purpose pseudorandomness, and functions such as randrange() produce values from a deterministic generator state. It is not intended for security-sensitive secrets. [1]
The dangerous pattern is not simply "we used a predictable PRNG". It is more subtle:
rng = random.Random(seed)
# Code path A
decision = rng.randrange(0, 100)
# Code path B
secret_length = rng.randrange(0, 100)
If the application somehow exposes decision, then secret_length is no longer independent. It is the next item in the same stream.
Now make both code paths consume exactly the same kind of call:
rng.randrange(0, 100)
and the problem gets much worse. You are not trying to reverse the generator at all. You are simply aligning two views of the same sequence.
Think of it as two people reading a numbered list from the same page. If one person tells you item #1 and item #2, you do not need to hack the notebook to know what item #3 looks like.
Why Should You Care?
PRNG stream reuse shows up in places that are surprisingly easy to get wrong:
| Situation | What can go wrong |
|---|---|
| Reset tokens | A leaked random decision reveals later token material |
| Session IDs | One endpoint leaks generator output used elsewhere |
| Challenge-response logic | A public value advances the same generator used for secrets |
| Nonces | Reused generator state produces related values |
| Temporary codes | "Random" code generation becomes replayable |
| Multi-stage workflows | One stage accidentally leaks another stage's state |
The problem becomes especially nasty when the application exposes a random value indirectly, for example through a selected array element, an error message, a debug line, or a branch that only triggers for certain random values.
That is an oracle.
A Worked Example
Imagine a TCP service that accepts exactly 100 comma-separated fields.
It processes them in four stages.
Stage 1: Count the fields
if len(parts) != 100:
return False
So far, perfectly normal.
Stage 2: Convert the fields into indices
Digits are treated as indices. Non-digit characters are treated as characters and used to select entries from a list.
The application also contains this logic:
if non_numeric:
r = rng.randrange(0, len(non_numeric))
if r % 2:
for _ in range(len(non_numeric)):
selected = non_numeric[rng.randrange(0, len(non_numeric))]
print("selected:", selected)
Assume non_numeric contains exactly 100 entries.
That gives us:
r = rng.randrange(0, 100) # PRNG call #1
# If r is odd:
for _ in range(100):
rng.randrange(0, 100) # PRNG calls #2..101
This is already interesting.
The application is exposing a large portion of its random stream whenever the first result happens to be odd.
Stage 3: Build a secret
Later, another branch runs:
length = rng.randrange(0, 100)
secret = "".join(
chr(rng.randrange(0, 100))
for _ in range(length)
)
That looks unrelated.
It is not.
The two branches both start from the same PRNG state when used from fresh sessions, and both use randrange(0, 100).
So the sequence is effectively:
call #1 -> length
call #2 -> secret[0]
call #3 -> secret[1]
...
The observable branch has already shown us the values from calls #2 onward.
Therefore:
length = leaked_call_1
secret[0] = leaked_call_2
secret[1] = leaked_call_3
...
There is no seed cracking here. No MT19937 inversion. No giant brute-force search.
It is just stream alignment.
The First Trap: The Printed Number Is Not the Random Index
There is a subtle detail that matters when reproducing this kind of bug.
Suppose the service does:
selected = choices[rng.randrange(0, 100)]
print(ord(selected))
If the output is:
114
then 114 is not necessarily the random value.
It is ord(selected).
If the attacker already knows the exact choices array, they can reverse the mapping:
index = choices.index(selected)
or, more efficiently:
ord_to_index = {
ord(ch): i
for i, ch in enumerate(choices)
}
Then:
actual_random_value = ord_to_index[114]
That distinction is easy to miss. Treating the printed character code as the PRNG output produces a perfectly believable sequence of nonsense, which is exactly the kind of bug that can make a correct attack look broken.
The Second Trap: all([]) Is True
Now for the validation bug.
Suppose the final check looks like this:
checks = [
expected == actual
for expected, actual in zip(expected_text, user_text[64:])
]
if not all(checks):
return False
The developer probably intended to compare the entire suffix.
But what happens when user_text[64:] is empty?
list(zip("anything", ""))
becomes:
[]
and therefore:
all([]) == True
That is not a cryptographic trick. It is just Python doing exactly what it was asked to do.
An empty iterable contains no false value, so all() returns True.
The real problem is the missing requirement that the suffix must exist.
A safe version would first enforce the expected length:
if len(user_text) != 128:
return False
if user_text[:64] != expected_prefix:
return False
if user_text[64:] != expected_suffix:
return False
Or, if the comparison really needs zip():
if len(user_text[64:]) != len(expected_suffix):
return False
if not all(
a == b
for a, b in zip(expected_suffix, user_text[64:])
):
return False
The lesson is simple: never let the loop define the validity condition when the expected length matters.
The Third Trap: Silently Ignored Values
There is another small bug that makes the empty-iterator bypass practical.
Imagine this parser:
for value in parts:
if value.isdigit():
index = int(value)
if 0 <= index < len(data):
indices.append(index)
Notice what happens to:
9999
The value is numeric.
Parsing succeeds.
But it is out of range, so nothing gets appended.
There is no rejection.
That means an attacker can submit:
64 valid indices
36 out-of-range values
and satisfy an outer condition that requires exactly 100 input fields while only producing 64 actual output characters.
That is a classic distinction between:
- "the input was accepted", and
- "the input was semantically valid".
OWASP recommends validating both syntax and semantic meaning, including range constraints, and rejecting unexpected values rather than quietly carrying on. [2]
Putting the Three Bugs Together
Now the attack chain becomes clear.
Step 1: Leak the random stream
Send 100 known non-numeric entries.
The application consumes:
randrange(0, 100)
once, then consumes another 100 values from the same stream.
If the first value is odd, the service reveals those selections.
Because the attacker knows the array, each revealed selection can be converted back into its random index.
Step 2: Reconstruct candidate secrets
The first random value is only known to be odd.
So instead of guessing a 0 to 99 length, there are only 50 candidates:
for length in range(1, 100, 2):
secret = "".join(
chr(x) for x in leaked_values[:length]
)
digest = hashlib.sha256(
secret.encode()
).hexdigest()
The attacker now has 50 candidate SHA256 strings.
Step 3: Turn the hash into valid indices
Assume the application later converts indices back into characters from a static text buffer.
A SHA256 digest contains only:
0123456789abcdef
So the attacker only needs the position of those 16 characters in that buffer.
Once the mapping is known:
char_to_index = {
"0": 311,
"1": 314,
"2": 317,
# ...
}
a 64-character SHA256 string becomes 64 integer indices.
Step 4: Pad with rejected values
Append 36 out-of-range numbers:
payload = valid_indices + [9999] * 36
The parser sees 100 fields, but only 64 survive.
Step 5: Empty suffix
Now:
user_text[64:]
is empty.
So the suffix comparison becomes:
all([])
which passes.
The first 64 characters only need to be the correct SHA256 candidate.
A Minimal Exploit Skeleton
Here is the attack shape in generic form:
import hashlib
def build_candidates(leaked_values):
candidates = []
for length in range(1, 100, 2):
secret = "".join(
chr(x)
for x in leaked_values[:length]
)
digest = hashlib.sha256(
secret.encode()
).hexdigest()
candidates.append(digest)
return candidates
def build_payload(digest, char_to_index):
indices = [
char_to_index[ch]
for ch in digest
]
# Force the generated string to stop at 64 chars.
indices += [9999] * 36
return ",".join(
str(x)
for x in indices
)
The interesting part is not the Python. It is the fact that three independent assumptions line up:
same PRNG state
+
same randrange(0, 100)
+
observable output
=
reconstructable secret
and:
exact field count
+
silent out-of-range discard
+
all(zip(..., empty))
=
empty suffix bypass
That is the entire attack.
What a Secure Implementation Looks Like
The easiest defense is to stop sharing state between unrelated jobs.
Vulnerable version
import random
rng = random.Random(seed)
def build_public_result(items):
choice = rng.randrange(0, 100)
return items[choice]
def build_secret():
length = rng.randrange(0, 100)
return bytes(
rng.randrange(0, 100)
for _ in range(length)
)
The two operations are coupled to the same stream.
Patched version
import secrets
def build_public_result(items):
choice = secrets.randbelow(len(items))
return items[choice]
def build_secret():
length = secrets.randbelow(100)
return bytes(
secrets.randbelow(100)
for _ in range(length)
)
If the value is security-sensitive, use a cryptographically secure generator such as secrets instead of the general-purpose random module. Python explicitly documents random as a general-purpose PRNG and provides secrets for security-sensitive randomness. [1]
The deeper fix is not just swapping one module.
If two operations should be independent, their state should be independent too.
Fixing the Input Validation
The parser should reject malformed or out-of-range values immediately.
Vulnerable version
for value in parts:
if value.isdigit():
index = int(value)
if 0 <= index < len(data):
indices.append(index)
# execution continues even when some values were ignored
Patched version
if len(parts) != 100:
raise ValueError("Exactly 100 values are required")
indices = []
for value in parts:
if not value.isdigit():
raise ValueError("Only decimal integers are allowed")
index = int(value)
if not 0 <= index < len(data):
raise ValueError("Index out of range")
indices.append(index)
Do not silently discard invalid input when the field is required.
Reject it.
The same principle applies at every layer. Validate length, type, range, and semantic meaning before the value reaches later logic. [2]
Fixing the Empty-Iterator Bypass
Never write:
if all(
a == b
for a, b in zip(expected, actual)
):
accept()
without first proving that the compared collections have the expected length.
Use direct equality when that is all you need:
if actual != expected:
return False
Or enforce the shape before comparing:
if len(actual) != len(expected):
return False
if not all(
a == b
for a, b in zip(expected, actual)
):
return False
The first form is usually better. There is no prize for making a string comparison look like a miniature SAT solver.
Testing and Audit Points
When reviewing code that uses randomness and multi-stage validation, look for these patterns:
-
One PRNG object used across unrelated features.
Search for a sharedrandom.Random, globalrandomcalls, or shared helper objects. -
Same
randrange()parameters in different branches.
Matching bounds are a strong hint that streams can be aligned. -
User-visible random choices.
Error messages, debug output, selected array entries, timing behavior, or branch-specific responses can all become oracles. -
Invalid input that gets skipped instead of rejected.
Look forcontinue, conditional appends, or ignored conversion failures. -
Validation built around
zip(),all(), orany().
Check what happens when one iterable is empty or shorter than expected. -
A strict outer length with a weaker inner length.
An application may require "100 inputs" but accidentally build only 64 meaningful values. -
Fresh sessions that restore the same PRNG state.
A repeatable seed turns each new connection into a clean experiment.
These are good manual audit points even when there is no obvious "vulnerability function" to grep for.
Common Myths
"It is using MT19937, so I need to recover the whole state."
Not necessarily.
If the application leaks enough output and then reuses the same stream for the secret, stream alignment can be far easier than state recovery.
"all([]) is only a Python curiosity."
Not when it sits on an authorization or validation boundary.
A false assumption about collection length can turn a correct comparison into a check that never actually runs.
"Out-of-range values are harmless because they are ignored."
Ignored input is still attacker-controlled input.
If ignoring a value changes the size or structure of data consumed by a later security check, the ignore behavior is part of the attack surface.
"A 50% chance to trigger the leak makes the attack unreliable."
Not really.
A deterministic generator means the outcome is stable for a fixed state, and even when a condition is probabilistic, a small number of controlled requests can make an oracle practical. The real problem is that the leak reveals exactly the sequence needed by the later secret-generation path.
Defense Checklist
| Category | Action |
|---|---|
| Randomness | Use secrets for security-sensitive values |
| PRNG state | Keep unrelated random operations on independent generators |
| Input count | Enforce the exact required number of fields |
| Input type | Reject anything outside the intended type |
| Range | Reject out-of-range values, never silently drop them |
| Output length | Validate the produced value before comparing it |
| Collection checks | Handle empty and unequal-length cases explicitly |
| Error handling | Avoid leaking selected values or internal random decisions |
| Session behavior | Do not reseed security-sensitive generators into predictable states |
| Review | Test malformed, empty, short, and overlong inputs explicitly |
Final Thoughts
This class of bug is a good reminder that security failures rarely need one giant mistake.
A reusable PRNG stream gives away information.
A parser quietly discards invalid values.
A validation loop trusts zip() to have something to compare.
Individually, each choice can survive a code review.
Together, they make a predictable path through the application.
The safest approach is boring on purpose: use the right randomness source, reject bad input early, validate lengths before comparing collections, and never assume a loop checked something just because the loop existed.
Because if your security check can become all([]), the attacker did not break your lock.
You simply forgot to close the door.
References
-
Python Documentation,
randomand general-purpose pseudorandom number generation.
https://docs.python.org/3/library/random.html -
OWASP, Input Validation Cheat Sheet.
https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html -
Python Documentation, Built-in Functions.
https://docs.python.org/3/library/functions.html