b01lers CTF 2025
Pesky CBC
import secrets
from Crypto.Cipher import AES
try:
with open('./flag.txt', 'r') as f:
flag = f.read()
except:
flag = 'bctf{REDACTED}'
key1 = secrets.token_bytes(32)
key2 = secrets.token_bytes(32)
def pesky_decrypt(ciphertext):
assert len(ciphertext) % 16 == 0
iv1 = secrets.token_bytes(16)
iv2 = secrets.token_bytes(16)
c1 = AES.new(key1, AES.MODE_CBC, iv1)
c2 = AES.new(key2, AES.MODE_CBC, iv2)
return c1.decrypt(c2.decrypt(ciphertext))
def main():
cipher = AES.new(key2, AES.MODE_ECB)
secret = secrets.token_bytes(16)
ciphertext = cipher.encrypt(secret)
print('Here is the encrypted secret:')
print(ciphertext.hex())
print()
print('Here are some hints for you ^_^')
for _ in range(8):
random_value = secrets.token_bytes(16)
ciphertext = cipher.encrypt(random_value)
print(random_value.hex())
print(ciphertext.hex())
print()
while True:
print('Options:')
print('1: pesky decrypt')
print('2: guess secret')
choice = input('>> ').strip()
if choice == '1':
ciphertext = bytes.fromhex(input('>> '))
print(pesky_decrypt(ciphertext).hex())
elif choice == '2':
guess = bytes.fromhex(input('>> '))
if secret == guess:
print('Here is your flag :)')
print(flag)
return
else:
print('lmao skill issue')
return
else:
print('Invalid Choice')
return
if __name__ == '__main__':
main()
Solve:
Let’s analyse how pesky_decrypt works.
from os import urandom
from pwn import xor
from Crypto.Cipher import AES
def ECB_dec(x, key):
return AES.new(key, AES.MODE_ECB).decrypt(x)
key1 = urandom(32)
key2 = urandom(32)
def pesky_decrypt(ciphertext):
iv1 = urandom(16)
iv2 = urandom(16)
c1 = AES.new(key1, AES.MODE_CBC, iv1)
c2 = AES.new(key2, AES.MODE_CBC, iv2)
return c1.decrypt(c2.decrypt(ciphertext))
c1, c2, c3 = urandom(16), urandom(16), urandom(16)
payload = c1 + c2 + c3
recv = pesky_decrypt(payload)
o1, o2, o3 = recv[:16], recv[16:32], recv[32:48]
assert o3 == xor(ECB_dec(xor(ECB_dec(c3, key2), c2), key1), ECB_dec(c2, key2), c1)
If we create some payload 3 blocks long, then the third received block is useful (the first 2 have the unknown iv1, iv2 in them)
Sending any payload more than 3 blocks doesn’t really give anything else useful
assert o3 == xor(ECB_dec(xor(ECB_dec(c3, key2), c2), key1), ECB_dec(c2, key2), c1)
Let’s simplify this.
Firstly we can just set c1 to 0, then xoring with that doesn’t change.
let’s call c3 X, and call c2 Y.
And also call g1(x) = ECB_dec(x, key1), g2(x) = ECB_dec(x, key2)
Then it becomes this:
def g1(x):
return ECB_dec(x, key1)
def g2(x):
return ECB_dec(x, key2)
def query(X, Y):
payload = b'\x00'*16 + Y + X
recv = pesky_decrypt(payload)
return recv[32:48]
X, Y = urandom(16), urandom(16)
assert query(X, Y) == xor(g1(xor(g2(X), Y)), g2(Y)) # g1(g2(X) xor Y) xor g2(Y)
And we start with 8 pairs of random x and g2(x).
g1_, g2_ = {}, {}
for _ in range(8):
x = urandom(16)
g2_[x] = g2(x)
Now let’s see how we can use our query.
query(X, Y) = g1(g2(X) xor Y) xor g2(Y)
We can rearrange for g1(g2(X) xor Y) = query(X, Y) xor g2(Y) (where we know g2(X) and g2(Y))
query(X, Y) = g1(g2(X) xor Y) xor g2(Y)
We can rearrange for g2(Y) = query(X, Y) xor g1(g2(X) xor Y)
If we send Y = g2(X) xor Z, g1(g2(X) xor Y) becomes g1(g2(X) xor g2(X) xor Z) = g1(Z)
so choose some Z and X where we know g2(X) and g1(Z)
g2(g2(X) xor Z) = query(X, g2(X) xor Z) xor g1(Z)
g1_, g2_ = {}, {}
for _ in range(8):
x = urandom(16)
g2_[x] = g2(x)
def expand_g1(X, Y):
g1_[xor(g2_[X], Y)] = xor(query(X, Y), g2_[Y])
def expand_g2(X, Z):
g2_[xor(g2_[X], Z)] = xor(query(X, xor(g2_[X], Z)), g1_[Z])
for _ in range(200):
expand_g1(random.choice(list(g2_.keys())), random.choice(list(g2_.keys())))
expand_g2(random.choice(list(g2_.keys())), random.choice(list(g1_.keys())))
for x, g1x in g1_.items():
assert g1x == g1(x)
for x, g2x in g2_.items():
assert g2x == g2(x)
Local solver:
from os import urandom, environ
environ['TERM'] = 'xterm'
from pwn import xor
from Crypto.Cipher import AES
from Crypto.Util.number import *
import random
def ECB_dec(x, key):
return AES.new(key, AES.MODE_ECB).decrypt(x)
key1 = urandom(32)
key2 = urandom(32)
def pesky_decrypt(ciphertext):
iv1 = urandom(16)
iv2 = urandom(16)
c1 = AES.new(key1, AES.MODE_CBC, iv1)
c2 = AES.new(key2, AES.MODE_CBC, iv2)
return c1.decrypt(c2.decrypt(ciphertext))
def g1(x):
return ECB_dec(x, key1)
def g2(x):
return ECB_dec(x, key2)
def query(X, Y):
payload = b'\x00'*16 + Y + X
recv = pesky_decrypt(payload)
return recv[32:48]
X, Y = urandom(16), urandom(16)
assert query(X, Y) == xor(g1(xor(g2(X), Y)), g2(Y)) # g1(g2(X) xor y) xor g2(Y)
g1_, g2_ = {}, {}
for _ in range(8):
x = urandom(16)
g2_[x] = g2(x)
def expand_g1(X, Y):
T = xor(g2_[X], Y)
g1_[T] = xor(query(X, Y), g2_[Y])
return T
def expand_g2(X, Z):
T = xor(g2_[X], Z)
g2_[T] = xor(query(X, xor(g2_[X], Z)), g1_[Z])
return T
while True:
expand_g1(random.choice(list(g2_.keys())), random.choice(list(g2_.keys())))
expand_g2(random.choice(list(g2_.keys())), random.choice(list(g1_.keys())))
if len(g2_) > 128+20:
break
for x, g1x in g1_.items():
assert g1x == g1(x)
for x, g2x in g2_.items():
assert g2x == g2(x)
def xor_subset(random_bytes, target):
def bytes_to_binary(b):
return [int(i) for i in f"{bytes_to_long(b):0128b}"]
vecs = [bytes_to_binary(b) for b in random_bytes]
M = Matrix(GF(2), vecs).transpose()
if M.rank() != len(random_bytes):
#print("all vectors not linearly independent")
return []
solve = M.solve_right(vector(bytes_to_binary(target)))
return [v for s, v in zip(solve, random_bytes) if s]
cipher = AES.new(key2, AES.MODE_ECB)
secret = urandom(16)
print(f' {secret = }')
ciphertext = cipher.encrypt(secret)
end_x = random.choice(list(g2_.keys()))
start = random.choice(list(g1_.keys()))
end = xor(g2_[end_x], ciphertext)
g1_end_plus_secret = query(end_x, ciphertext)
while True:
g2_values = random.sample(list(g2_.values()), k=int(128))
subset = xor_subset(g2_values, target=xor(start, end))
if subset != [] and len(subset)%2 == 0:
break
reversed = {v: k for k, v in g2_.items()}
current = start
for i in range(0, len(subset), 2):
current = expand_g2(reversed[subset[i+0]], current)
current = expand_g1(reversed[subset[i+1]], current)
assert current == end
secret = xor(g1_end_plus_secret, g1_[end])
print(f'recovered {secret = }')
Remote solver:
from os import environ
environ['TERM'] = 'xterm'
from pwn import *
from Crypto.Util.number import *
from tqdm import trange
def expand_g1(X, Y):
T = xor(g2_[X], Y)
g1_[T] = xor(query(X, Y), g2_[Y])
return T
def expand_g2(X, Z):
T = xor(g2_[X], Z)
g2_[T] = xor(query(X, xor(g2_[X], Z)), g1_[Z])
return T
def query(X, Y):
payload = b'\x00'*16 + Y + X
io.recvuntil(b'2: guess secret\n>> ')
io.sendline(b'1')
io.recv()
io.sendline(payload.hex().encode())
return bytes.fromhex(io.recvline().decode())[32:48]
def xor_subset(random_bytes, target):
def bytes_to_binary(b):
return [int(i) for i in f"{bytes_to_long(b):0128b}"]
vecs = [bytes_to_binary(b) for b in random_bytes]
M = Matrix(GF(2), vecs).transpose()
if M.rank() != len(random_bytes):
#print("all vectors not linearly independent")
return []
solve = M.solve_right(vector(bytes_to_binary(target)))
return [v for s, v in zip(solve, random_bytes) if s]
#io = process(["python", "chall.py"])
io = remote("peskycbc.atreides.b01lersc.tf", 8443, ssl=True)
io.recvline()
ciphertext = bytes.fromhex(io.recvline().decode())
io.recvline()
io.recvline()
g1_, g2_ = {}, {}
for _ in range(8):
g2x = bytes.fromhex(io.recvline().decode())
x = bytes.fromhex(io.recvline().decode())
g2_[x] = g2x
for _ in trange(128+20):
expand_g1(random.choice(list(g2_.keys())), random.choice(list(g2_.keys())))
expand_g2(random.choice(list(g2_.keys())), random.choice(list(g1_.keys())))
end_x = random.choice(list(g2_.keys()))
start = random.choice(list(g1_.keys()))
end = xor(g2_[end_x], ciphertext)
g1_end_plus_secret = query(end_x, ciphertext)
while True:
g2_values = random.sample(list(g2_.values()), k=int(128))
subset = xor_subset(g2_values, target=xor(start, end))
if subset != [] and len(subset)%2 == 0:
break
reversed = {v: k for k, v in g2_.items()}
current = start
for i in range(0, len(subset), 2):
current = expand_g2(reversed[subset[i+0]], current)
current = expand_g1(reversed[subset[i+1]], current)
assert current == end
secret = xor(g1_end_plus_secret, g1_[end])
print(f'recovered {secret = }')
io.recv()
io.sendline(b'2')
io.recv()
io.sendline(secret.hex().encode())
print(io.recv().decode())
bctf{neighbor_c5975dc61dbfd49a}