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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
flag = "DalCTF{test}"

seed = "" # Turn the bytes into an int for the seed

x = 0

for i in range(0, len(seed)):
    x += ord(seed[i])

def rng(y):
    x = pow(int((175*y + 17)/14 + 45), 15, 4294967295)
    return x

for i in range(0, len(flag)):
    x = rng(x)
    t = ord(flag[i]) * x
    print(t)

output.txt (44 lines)

1
2
3
4
5
6
7
71303168
8253210177
28894521096
34931108342
323052614304
...
272413723250

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:

1
2
3
4
for i in range(0, len(flag)):
    x = rng(x)            # (1) advance the generator
    t = ord(flag[i]) * x  # (2) multiply the flag byte by the new state
    print(t)              # (3) print it

Two observations break the whole challenge wide open:

  1. The keystream is independent of the flag. The sequence of states x₁, x₂, x₃, … is produced purely by chaining rng(). The flag bytes are never fed back into the generator. Each state only depends on the previous state (and, originally, the seed).

  2. Each output is a plain product. Line i of the output is simply:

    1
    
    tᵢ = 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:

1
flag[i] = tᵢ / xᵢ

The only question is: how do we get the states without the seed?


The Seed Is a Red Herring

The generator is fully deterministic:

1
2
def rng(y):
    return pow(int((175*y + 17)/14 + 45), 15, 4294967295)

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:

1
t₀ = ord('D') × x₁  =  68 × x₁  =  71303168

Solving:

1
x₁ = 71303168 / 68 = 1048576

That divides exactly1048576 = 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:

  1. Recover the first state: x₁ = output[0] / 68.
  2. For each line i: recover flag[i] = round(output[i] / xᵢ), then advance xᵢ ← rng(xᵢ).
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
out = [int(l) for l in open('output.txt')]

def rng(y):
    return pow(int((175*y + 17) / 14 + 45), 15, 4294967295)

xi = out[0] // 68          # x1, from known first char 'D' (ord 68)
flag = ''
for t in out:
    flag += chr(round(t / xi))   # recover the character
    xi = rng(xi)                 # advance the deterministic generator

print(flag)

Running it:

1
DalCTF{533m1ng1y_r4nd0m1y_g3n3r473d_num63rs}

A small but important detail: round() (rather than integer division) guards against floating-point dust. t / xi should be a whole number, but for large states Python’s float division can land at something like 100.99999999. Rounding snaps it back to the true ASCII value. For a fully exact solve you could instead verify t % xi == 0 and use t // xi.


The Flag

1
DalCTF{533m1ng1y_r4nd0m1y_g3n3r473d_num63rs}

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

StepAction
1Notice output[i] = ord(flag[i]) × xᵢ — a plain product.
2The state sequence is deterministic and independent of the flag.
3Known prefix D (68) → x₁ = output[0] / 68 = 1048576.
4Replay rng() to regenerate all states; divide each output to recover each char.
5DalCTF{533m1ng1y_r4nd0m1y_g3n3r473d_num63rs}