Challenge: LCG Seed Squared · CTF: DalCTF 2026 · Category: Crypto · Difficulty: Easy
“I’ve been messing around with LCGs recently. I’ve forgotten the seed but I can remember I took something squared…”
The flavor text wants you to chase the seed. It’s a red herring. The seed never matters — and that’s the whole lesson of this challenge.
The Files
We’re handed a Python script and a text file full of big numbers.
LCGSeedSquared.py
| |
output.txt (44 lines)
| |
The flag in the script is just a placeholder (DalCTF{test}); the real run produced output.txt. Since there are 44 lines, the real flag is 44 characters long.
Reading the Code Carefully
It’s tempting to start reverse-engineering rng() and trying to recover the lost seed. Don’t. Look at what the loop actually does:
| |
Two observations break the whole challenge wide open:
The keystream is independent of the flag. The sequence of states
x₁, x₂, x₃, …is produced purely by chainingrng(). The flag bytes are never fed back into the generator. Each state only depends on the previous state (and, originally, the seed).Each output is a plain product. Line
iof the output is simply:1tᵢ = ord(flag[i]) × xᵢThat’s it. No XOR, no modular wrapping of the product, no mixing. Just multiplication.
So if I can learn the state xᵢ for each position, recovering the character is one division away:
| |
The only question is: how do we get the states without the seed?
The Seed Is a Red Herring
The generator is fully deterministic:
| |
Given any state xᵢ, the next state is x_{i+1} = rng(xᵢ), computed entirely from xᵢ. The seed only determines where the chain starts. If I can recover a single state anywhere in the sequence, I can regenerate every state after it myself — the forgotten seed becomes irrelevant.
And we have a free state handed to us, because we know how the flag starts.
Every DalCTF flag begins with DalCTF{, so flag[0] = 'D', and ord('D') = 68. The first output line is:
| |
Solving:
| |
That divides exactly — 1048576 = 2²⁰, a perfectly clean integer. That clean division is the confirmation that our guess (D at position 0) is correct. If the first character had been anything else, we’d almost certainly get a non-integer.
Now we have x₁. From here, rng() gives us x₂ = rng(x₁), x₃ = rng(x₂), and so on — the entire keystream, no seed required.
The Solve
The algorithm:
- Recover the first state:
x₁ = output[0] / 68. - For each line
i: recoverflag[i] = round(output[i] / xᵢ), then advancexᵢ ← rng(xᵢ).
| |
Running it:
| |
A small but important detail:
round()(rather than integer division) guards against floating-point dust.t / xishould be a whole number, but for large states Python’s float division can land at something like100.99999999. Rounding snaps it back to the true ASCII value. For a fully exact solve you could instead verifyt % xi == 0and uset // xi.
The Flag
| |
Decoded from leetspeak: “seemingly randomly generated numbers.” The flag itself is the punchline — those big intimidating numbers in output.txt only look random.
Why This Works — The Real Lesson
This challenge is a compact lesson in how not to use a PRNG as a cipher. Three independent mistakes stack up:
The keystream never depends on the plaintext. Because the flag bytes aren’t fed back into the generator, the state sequence is fixed. Recovering one state recovers all of them.
The combining operation is reversible and leaks structure. Multiplication (
char × state) is trivially invertible by division. Worse, because we know one plaintext byte (D), one ciphertext value directly hands us the corresponding state — a textbook known-plaintext attack. CTF flags having a fixed, predictable prefix (DalCTF{) makes this almost automatic.The secret (seed) only sets the starting point. In a real stream cipher the secret must influence the entire keystream irreversibly. Here, learning any single state value makes the seed completely irrelevant — its secrecy buys nothing.
The “squared” hint in the description is pure misdirection — it points you toward analyzing the math of the generator and recovering the seed, which is a dead end. You never need to know how rng() works internally, only that it’s deterministic. Recover one state from the known prefix, replay the generator, and divide. Done.
TL;DR
| Step | Action |
|---|---|
| 1 | Notice output[i] = ord(flag[i]) × xᵢ — a plain product. |
| 2 | The state sequence is deterministic and independent of the flag. |
| 3 | Known prefix D (68) → x₁ = output[0] / 68 = 1048576. |
| 4 | Replay rng() to regenerate all states; divide each output to recover each char. |
| 5 | DalCTF{533m1ng1y_r4nd0m1y_g3n3r473d_num63rs} |