PRNG Stream Reuse Explained: When Randomness Repeats Itself

πŸ‡ΊπŸ‡ΈENπŸ‡ΈπŸ‡¦AR

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 empty zip() 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:

SituationWhat can go wrong
Reset tokensA leaked random decision reveals later token material
Session IDsOne endpoint leaks generator output used elsewhere
Challenge-response logicA public value advances the same generator used for secrets
NoncesReused generator state produces related values
Temporary codes"Random" code generation becomes replayable
Multi-stage workflowsOne 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:

  1. One PRNG object used across unrelated features.
    Search for a shared random.Random , global random calls, or shared helper objects.
  2. Same randrange() parameters in different branches.
    Matching bounds are a strong hint that streams can be aligned.
  3. User-visible random choices.
    Error messages, debug output, selected array entries, timing behavior, or branch-specific responses can all become oracles.
  4. Invalid input that gets skipped instead of rejected.
    Look for continue , conditional appends, or ignored conversion failures.
  5. Validation built around zip(), all(), or any().
    Check what happens when one iterable is empty or shorter than expected.
  6. A strict outer length with a weaker inner length.
    An application may require "100 inputs" but accidentally build only 64 meaningful values.
  7. 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

CategoryAction
RandomnessUse secrets for security-sensitive values
PRNG stateKeep unrelated random operations on independent generators
Input countEnforce the exact required number of fields
Input typeReject anything outside the intended type
RangeReject out-of-range values, never silently drop them
Output lengthValidate the produced value before comparing it
Collection checksHandle empty and unequal-length cases explicitly
Error handlingAvoid leaking selected values or internal random decisions
Session behaviorDo not reseed security-sensitive generators into predictable states
ReviewTest 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

  1. Python Documentation, random and general-purpose pseudorandom number generation.
    https://docs.python.org/3/library/random.html
  2. OWASP, Input Validation Cheat Sheet.
    https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
  3. Python Documentation, Built-in Functions.
    https://docs.python.org/3/library/functions.html