down-the-stream-2 — gaslightCTF 2026 writeup
Category: crypto (456 points)
Author: william_etotheipi
Solves: 67
Series: down-the-stream (part 2 of 2)
Flag: gaslightCTF{st0p-rev3al1ng-4ll-the-pl4int3xts}
"The author tried to be a bit more clever and this time, parts of the cipher are redacted. However, you were able to intercept a plaintext-ciphertext pair generated from the algorithm. Does this compensate for the redacted algorithm?"
"The initialization vector this time (IV) is the palindrome generated from the previous IV. e.g. if the previous IV was 2a, then the palindrome generated is 2aa2"
Table of contents
- Challenge overview
- The (partial) encryption script
- What we know vs. what is redacted
- The palindrome IV
- Recover the keystream from the known pair
- Recover the redacted feedback taps
- Decrypt the flag
- Solver
- Flag
- Takeaways
1. Challenge overview
We are given four things:
chall (1).py— a 16-bit LFSR stream cipher whose feedback function is redacted (from unknown_funcs import generate_feedback).intercepted.txt— a known plaintext/ciphertext pair:Hello, world c68d2a7ec13c03eb380395cfoutput.txt— the flag's ciphertext (hex).The
IVrule: the IV is the palindrome generated from part 1's IV.
Goal: decrypt the flag despite not knowing the LFSR feedback polynomial.
2. The (partial) encryption script
from unknown_funcs import generate_feedback # ← REDACTED
def next_register(register: int) -> int:
for _ in range(16):
feedback = generate_feedback(register)
register = ((register << 1) | feedback) & 0xffff
return register
def LFSR(pt_bytes: bytes, IV: int) -> bytes:
assert len(pt_bytes) % 2 == 0
register = IV
ct = bytearray()
ct.append(pt_bytes[0] ^ (register >> 8)) # high byte
ct.append(pt_bytes[1] ^ (register & 0x00ff)) # low byte
for i in range(2, len(pt_bytes), 2):
register = next_register(register) # 16 clocks per 2-byte word
ct.append(pt_bytes[i] ^ (register >> 8))
ct.append(pt_bytes[i+1] ^ (register & 0x00ff))
return bytes(ct)Compared to part 1:
- The register is 16 bits instead of 8.
- Each 16-bit word of keystream (
register >> 8,register & 0xff) XORs two plaintext bytes at a time. next_registeradvances the register 16 clock steps between words.- The only thing we don't know is
generate_feedback— the LFSR's linear feedback function (the tap positions).
3. What we know vs. what is redacted
| Known | Unknown |
|---|---|
| The shifting scheme (16-bit, 16 steps per word) | generate_feedback (the taps) |
| The IV (via the palindrome rule) | — |
| A full plaintext/ciphertext pair (12 bytes) | — |
| The flag ciphertext (52 bytes) | — |
The central question the description asks: does a known plaintext/ciphertext pair make up for the redacted algorithm? Yes — it gives us the keystream, and from successive keystream words we can identify the feedback taps.
4. The palindrome IV
From down-the-stream-1 we recovered IV = 0x8e (the flag said "d0nt r3veal y0ur co3ff
v3ct0r").
The description's rule: "if the previous IV was 2a, then the palindrome generated is
2aa2". So the hex string is mirrored:
0x8e → "8ee8" → IV = 0x8ee8We'll verify this against the known pair below (it should match the first keystream word).
5. Recover the keystream from the known pair
For a stream cipher, keystream = plaintext ⊕ ciphertext. XORing the intercepted pair
word-by-word (2 bytes each):
pt = bclass="hljs-string">"Hello, world"
ct = bytes.fromhex(class="hljs-string">"c68d2a7ec13c03eb380395cf")
words = []
for i in range(0, len(pt), 2):
w = ((pt[i] ^ ct[i]) << 8) | (pt[i+1] ^ ct[i+1])
words.append(w)Result:
k_0 = 0x8ee8
k_1 = 0x4612
k_2 = 0xae10
k_3 = 0x239c
k_4 = 0x5771
k_5 = 0xf9abk_0 = 0x8ee8 — exactly the palindrome IV predicted from part 1. This confirms both the
IV rule and the keystream recovery.
By construction of the cipher, these words are consecutive LFSR states:
r_0 = IV = 0x8ee8
r_1 = next(r_0) = 0x4612
r_2 = next(r_1) = 0xae10
r_3 = next(r_2) = 0x239c
r_4 = next(r_3) = 0x5771
r_5 = next(r_4) = 0xf9ab6. Recover the redacted feedback taps
generate_feedback(r) is a single-bit linear function of the register's 16 bits — an LFSR
feedback polynomial:
feedback = ⊕ of bits b_j of r where tap j is setThat's just a 16-bit mask (one bit per possible tap, 2^16 = 65536 candidates). We brute
force the mask:
- Start from
r_0 = 0x8ee8. - Simulate the same
next_registerwith the candidate mask: 16 single-clock steps, each shifting left and inserting the XOR of the tapped bits as the new LSB. - Check the resulting state against
r_1, then continue throughr_5. - The mask that reproduces all six known states is the redacted feedback.
def next_register(register, mask):
for _ in range(16):
feedback = 0
for b in range(16):
if (mask >> b) & 1:
feedback ^= (register >> b) & 1
register = ((register << 1) | feedback) & 0xffff
return register
for mask in range(65536):
r = 0x8ee8
if all(next_register(r, mask) == w for ... in ...):
print(hex(mask)) # 0xba40The uniquely matching mask is:
mask = 0xba40 = 1011 1010 0100 0000i.e. taps on bits {6, 9, 11, 12, 13, 15} (0-indexed from the LSB). The six consecutive known states are more than enough to pin down all 16 taps (only one mask survives).
7. Decrypt the flag
Now we can replay the keystream for the flag ciphertext:
flag_ct = bytes.fromhex(
class="hljs-string">"e989357ec7774be81425bfd007aaf6f30b5342ce52a66776f8b326449b85ed186f1641603546b068dd89ba6b3145")
r = 0x8ee8
out = bytearray()
for i in range(0, len(flag_ct), 2):
out.append(flag_ct[i] ^ (r >> 8))
out.append(flag_ct[i+1] ^ (r & 0xff))
r = next_register(r, mask)
print(out.decode())Output:
gaslightCTF{st0p-rev3al1ng-4ll-the-pl4int3xts}(The flag itself lectures again: "stop revealing all the plaintexts" — the leaked plaintext/ciphertext pair is exactly what let us recover the redacted taps.)
8. Solver
python3 solver.py (in this directory):
- Recovers keystream words from
intercepted.txt(k_0 = 0x8ee8). - Brute-forces the 16-bit feedback mask →
0xba40. - Replays the keystream against
output.txt→ flag.
9. Flag
gaslightCTF{st0p-rev3al1ng-4ll-the-pl4int3xts}10. Takeaways
- Redacting part of the algorithm is not security if an attacker can observe input and output: a known plaintext/ciphertext pair yields the keystream directly.
- Consecutive LFSR states from a known pair let you solve for the (linear) feedback taps —
here by a trivial
2^16brute force since the register is only 16 bits. - The series' plaintexts narrate the mistakes: part 1 "don't reveal your IV" → part 2 "stop revealing all the plaintexts". The leaked pair from part 2 and the IV from part 1 chain together to break the whole thing.
- General rule: never encrypt a known-format prefix (like
gaslightCTF{) with a stream cipher whose initial state can be deduced from it.