SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

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

GasLightCTF 2026 - Down The Stream 1

AUTHORED BY:@bealthguy8/16/2026

down-the-stream-1 — gaslightCTF 2026 writeup

Category: crypto (500 points) Author: william_etotheipi Solves: 77 Series: down-the-stream (part 1 of 2) Flag: gaslightCTF{d0nt-r3veal-y0ur-co3ff-v3ct0r}

"A synchronous stream cipher sounds like a very secure cipher!"


Table of contents

  1. Challenge overview
  2. The encryption script
  3. How the LFSR works
  4. Why the cipher is broken
  5. Attack — recover the IV from the known prefix
  6. Attack — replay the keystream
  7. Solver
  8. Flag
  9. Takeaways

1. Challenge overview

We are given:

  • chall.py — an 8-bit LFSR-based stream cipher (full source).
  • output.txt — the ciphertext, as hex:
e944b3a55e47c5d8f3af3c93e2f7f2b1892094001e95a16b779b907bd374e2327a2dace45d222f69138b

The goal is to recover the plaintext flag.


2. The encryption script

def next_register(register: int) -> int:
    for _ in range(8):
        feedback = (((register >> 7) & 1) ^
                    ((register >> 5) & 1) ^
                    ((register >> 4) & 1) ^
                    ((register >> 3) & 1))
        register = ((register << 1) | feedback) & 0xff
    return register

def LFSR(pt_bytes: bytes, IV: int) -> bytes:
    register = IV
    ct = bytearray()
    ct.append(pt_bytes[0] ^ register)          # ← first byte XORed with raw IV

    for i in range(1, len(pt_bytes)):
        register = next_register(register)     # ← advance 8 clocks per byte
        ct.append(pt_bytes[i] ^ register)

    return bytes(ct)

if __name__ == class="hljs-string">&#039;__main__&#039;:
    with open(class="hljs-string">&#039;gaslight-down-the-stream-1/flag_and_IV.txt&#039;, class="hljs-string">&#039;r&#039;) as file:
        flag = file.readline().strip()
        IV = file.readline().strip()

    flag = flag.encode()
    IV = int(IV, 16)
    print(LFSR(flag, IV).hex())

Key structure:

  • The keystream generator is an 8-bit Linear Feedback Shift Register (LFSR) seeded with an unknown IV.
  • The first plaintext byte is XORed with the raw IV itself (ct[0] = p[0] ^ IV).
  • Each subsequent byte is XORed with the register state after advancing it 8 clock steps (next_register).

3. How the LFSR works

An LFSR is a shift register: on each clock, the register is shifted left by one, and the new bit pushed into the low bit is a linear (XOR) function of selected bits — the taps.

Here, the feedback bit is:

feedback = bit7 ⊕ bit5 ⊕ bit4 ⊕ bit3

i.e. the XOR of the register's bits 7, 5, 4 and 3 (0-indexed from the LSB).

clock step:
  bit7  →   bit6  →  …  →  bit0  →  discards
                         ↑
   feedback = b7^b5^b4^b3  (becomes new bit0)

next_register just applies this single step 8 times, so each byte of the message consumes exactly 8 clock cycles. Because everything is linear, the whole system — including the initial state IV — behaves like a deterministic generator producing a periodic keystream:

k_0 = IV
k_i = next_register(k_{i-1})        (i ≥ 1)
c_i = p_i ⊕ k_i

4. Why the cipher is broken

A synchronous stream cipher is only secure if the keystream is never reused and the initial state stays secret. This construction leaks the initial state immediately:

  1. c_0 = p_0 ⊕ IV.
  2. p_0 is the first byte of the flag, which begins with the known prefix gaslightCTF{p_0 = ord('g') = 0x67.
  3. Therefore:
IV = c_0 ⊕ 0x67

Once IV is known, the entire keystream can be regenerated deterministically (the taps are in the provided source), and the whole flag decrypts.

(It doesn't even matter whether we "should" know IV — the known plaintext prefix recovery gives it to us. This is exactly the point of the flag text: "d0nt r3veal y0ur co3ff v3ct0r".)


5. Attack — recover the IV from the known prefix

ct = bytes.fromhex(class="hljs-string">"e944b3a55e47c5d8f3af3c93e2f7f2b1892094001e95a16b779b907bd374e2327a2dace45d222f69138b")

IV = ct[0] ^ ord(class="hljs-string">&#039;g&#039;)
print(hex(IV))          # 0x8e

The ciphertext byte e9 XOR 0x67 gives 0x8e.


6. Attack — replay the keystream

def next_register(register):
    for _ in range(8):
        feedback = (((register >> 7) & 1) ^
                    ((register >> 5) & 1) ^
                    ((register >> 4) & 1) ^
                    ((register >> 3) & 1))
        register = ((register << 1) | feedback) & 0xff
    return register

reg = IV
pt = [ct[0] ^ reg]
for i in range(1, len(ct)):
    reg = next_register(reg)
    pt.append(ct[i] ^ reg)
print(bytes(pt))

Output:

gaslightCTF{d0nt-r3veal-y0ur-co3ff-v3ct0r}

7. Solver

python3 solver.py (in this directory) — recovers IV = 0x8e, regenerates the keystream, and prints the plaintext.


8. Flag

gaslightCTF{d0nt-r3veal-y0ur-co3ff-v3ct0r}

9. Takeaways

  • Known-plaintext prefix is catastrophic for stream ciphers: the first keystream byte is IV, and one known plaintext byte reveals it.
  • With the keystream state and the (public) LFSR taps, the entire keystream replays.
  • The flag text narrates the flaw: "don't reveal your coefficient vector" — here the IV (initial vector) is the leaked secret.
  • This challenge seeds part 2: the IV = 0x8e becomes the palindrome IV of down-the-stream-2.