SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/GASLIGHTCTF-2026-DOWN-THE-STREAM-2
← BACK TO ARCHIVE
EVENT: gaslightCTF 2026CATEGORY: CryptographyPOINTS: 500 PTS

GasLightCTF 2026 - Down The Stream 2

AUTHORED BY:@bealthguy8/16/2026

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

  1. Challenge overview
  2. The (partial) encryption script
  3. What we know vs. what is redacted
  4. The palindrome IV
  5. Recover the keystream from the known pair
  6. Recover the redacted feedback taps
  7. Decrypt the flag
  8. Solver
  9. Flag
  10. 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
    c68d2a7ec13c03eb380395cf
  • output.txt — the flag's ciphertext (hex).

  • The IV rule: 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_register advances 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 = 0x8ee8

We'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 = 0xf9ab

k_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)   = 0xf9ab

6. 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 set

That'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_register with 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 through r_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))   # 0xba40

The uniquely matching mask is:

mask = 0xba40 = 1011 1010 0100 0000

i.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):

  1. Recovers keystream words from intercepted.txt (k_0 = 0x8ee8).
  2. Brute-forces the 16-bit feedback mask → 0xba40.
  3. 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^16 brute 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.