Post

Odyssey Finals 2025 - True Story (Crypto)

Writeup for True Story crypto challenge from Odyssey Finals 2025

Odyssey Finals 2025 - True Story (Crypto)

True Story - Crypto Challenge Writeup

We’re given a server that encrypts data using two different encryption schemes and provides three outputs:

  • c1 = plaintext ⊕ feedfront_keystream
  • c2 = plaintext ⊕ AES-CTR_keystream
  • c3 = c1 ⊕ c2 = feedfront_keystream ⊕ AES-CTR_keystream

We can either encrypt arbitrary data (option 1) or encrypt the flag (option 2).

The feedfront Class (Custom LFSR-like PRNG)

1
2
3
4
5
6
7
8
def get_byte(self):
    b_data = 0
    while b_data == 0:  # <-- KEY VULNERABILITY!
        for _ in range(8):
            lsb = self.state & 1
            b_data = (b_data << 1) | lsb
            self.next_state()
    return b_data

The while b_data == 0 loop means the feedfront keystream NEVER contains a 0x00 byte. If the 8 bits would produce 0x00, it regenerates until it gets a non-zero value.

So for any plaintext P:

  • c1 = P ⊕ ff_ks where ff_ks[i] ≠ 0 for all positions i
  • c2 = P ⊕ aes_ks
  • c3 = ff_ks ⊕ aes_ks

The Vulnerability

Since c1 = flag ⊕ ff_ks and ff_ks[i] ≠ 0:

1
ff_ks[i] = c1[i] ⊕ flag[i] ≠ 0

This means: flag[i] ≠ c1[i] for every position!

In other words, each c1 value tells us one byte value that the flag CANNOT be at that position.

The Attack

  1. Request many flag encryptions (option 2)
  2. Collect all the c1 values - each one eliminates one candidate per byte position
  3. For each position, start with all 256 possible byte values and remove the ones we see in c1
  4. After enough samples, only ONE candidate remains per position - that’s the flag byte!

This works because:

  1. Each encryption uses a different feedfront state (it advances)
  2. The c1 values are essentially random bytes, but they can NEVER equal the flag byte (because that would make ff_ks = 0)
  3. With ~2000-5000 encryptions, we statistically see almost all 255 “wrong” values for each position
  4. The one value we NEVER see is the actual flag byte!

Solution Script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import socket, time

s = socket.socket()
s.settimeout(10)
s.connect(('10.25.1.156', 8888))
time.sleep(0.5)
s.recv(4096)

c1_list = []
target = 5000  # Collect many encryptions

for attempt in range(target):
    try:
        s.send(b'2\n')  # Request flag encryption
        data = b''
        while b'>> ' not in data:
            data += s.recv(4096)
        data = data.decode()
        c1 = bytes.fromhex(data.split('c1 = ')[1].split('\r\n')[0])
        c1_list.append(c1)
        if len(c1_list) % 1000 == 0:
            print(f"Collected {len(c1_list)}...")
    except Exception as e:
        print(f"Error at {len(c1_list)}: {e}")
        break

flag_len = len(c1_list[0])
flag = bytearray(flag_len)

for i in range(flag_len):
    # Start with all 256 possible values
    candidates = set(range(256))
    
    # Each c1 eliminates one candidate: flag[i] ≠ c1[i]
    for c1 in c1_list:
        candidates.discard(c1[i])
    
    if len(candidates) == 1:
        flag[i] = list(candidates)[0]
    else:
        print(f"Position {i}: {len(candidates)} candidates remaining")
        flag[i] = list(candidates)[0]

print(f"Flag: {flag.decode()}")
s.close()

Flag

AKASEC{__0VNI__["easy_otp_"]}

This post is licensed under CC BY 4.0 by the author.