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_keystreamc2 = plaintext ⊕ AES-CTR_keystreamc3 = 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_kswhereff_ks[i] ≠ 0for all positions ic2 = P ⊕ aes_ksc3 = 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
- Request many flag encryptions (option 2)
- Collect all the c1 values - each one eliminates one candidate per byte position
- For each position, start with all 256 possible byte values and remove the ones we see in c1
- After enough samples, only ONE candidate remains per position - that’s the flag byte!
This works because:
- Each encryption uses a different feedfront state (it advances)
- The c1 values are essentially random bytes, but they can NEVER equal the flag byte (because that would make ff_ks = 0)
- With ~2000-5000 encryptions, we statistically see almost all 255 “wrong” values for each position
- 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.
