Fibopadcci
13 minutos de lectura
Se nos proporciona el código fuente en Python del servidor:
import socketserver
from Crypto.Cipher import AES
import os
from secret import flag, key
fib = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 121, 98, 219, 61]
wlc_msg = """
-------------------------------------------------------------------------
| Welcome to my Super Secure Encryption service! |
| We use AES along with custom padding for authentication |
| for extra security, so only admins should be able to |
| decrypt the flag with the key I have provided them! |
| Admins: Feel free to send me messages that you have |
| encrypted with my key, but make sure they are padded |
| correctly with my custom padding I showed you (fibopadcci) |
| Also, please use the a value I gave you last time, |
| if you need it, ask a fellow admin, I don't want some random |
| outsiders decrypting our secret flag. |
-------------------------------------------------------------------------
"""[1:]
menu_msg = """\n
-------------------------
| Menu |
-------------------------
|[0] Encrypt flag. |
|[1] Send me a message! |
-------------------------
"""[1:]
def xor(a, b):
return bytes([_a ^ _b for _a, _b in zip(a, b)])
def pad(data): #Custom padding, should be fine!
c = 0
while len(data) % 16:
pad = str(hex(fib[c] % 255))[2:]
data += unhex("0" * (2-len(pad)) + pad)
c += 1
return data
def checkpad(data):
if len(data) % 16 != 0:
return 0
char = data[-1]
try:
start = fib.index(char)
except ValueError:
return 0
newfib = fib[:start][::-1]
for i in range(len(newfib)):
char = data[-(i+2)]
if char != newfib[i]:
return 0
return 1
def unhex(data):
return bytes.fromhex(data)
class SuperSecureEncryption: # This should be unbreakable!
def __init__(self, key):
self.cipher = AES.new(key, AES.MODE_ECB)
def encrypt(self, data):
data = pad(data)
a = os.urandom(16).replace(b'\x00', b'\xff')
b = os.urandom(16).replace(b'\x00', b'\xff')
lb_plain = a
lb_cipher = b
output = b''
data = [data[i:i+16] for i in range(0, len(data), 16)]
for block in data:
enc = self.cipher.encrypt(xor(lb_cipher, block))
enc = xor(enc, lb_plain)
output += enc
lb_plain = block
lb_cipher = enc
return output, a.hex(), b.hex()
def decrypt(self, data, a, b):
lb_plain = a
lb_cipher = b
output = b''
data = [data[i:i+16] for i in range(0, len(data), 16)]
for block in data:
dec = self.cipher.decrypt(xor(block, lb_plain))
dec = xor(dec, lb_cipher)
output += dec
lb_plain = dec
lb_cipher = block
if checkpad(output):
return output
else:
return None
def encryptFlag():
encrypted, a, b = SuperSecureEncryption(key).encrypt(flag)
return f'encrypted_flag: {encrypted.hex()}\na: {a}\nb: {b}'
def sendMessage(ct, a, b):
if len(ct) % 16:
return "Error: Ciphertext length must be a multiple of the block length (16)!"
if len(a) != 16 or len(b) != 16:
return "Error: a and b must have lengths of 16 bytes!"
decrypted = SuperSecureEncryption(key).decrypt(ct, a, b)
if decrypted != None:
return "Message successfully sent!"
else:
return "Error: Message padding incorrect, not sent."
def handle(self):
self.write(wlc_msg)
while True:
self.write(menu_msg)
option = self.query("Your option: ")
if option == "0":
self.write(encryptFlag())
elif option == "1":
try:
ct = unhex(self.query("Enter your ciphertext in hex: "))
b = unhex(self.query("Enter the B used during encryption in hex: "))
a = b'HTB{th3_s3crt_A}' # My secret A! Only admins know it, and plus, other people won't be able to work out my key anyway!
self.write(sendMessage(ct,a,b))
except ValueError as e:
self.write("Provided input is not hex!")
else:
self.write("Invalid input, please try again.")
class RequestHandler(socketserver.BaseRequestHandler):
handle = handle
def read(self, until='\n'):
out = ''
while not out.endswith(until):
out += self.request.recv(1).decode()
return out[:-len(until)]
def query(self, string=''):
self.write(string, newline=False)
return self.read()
def write(self, string, newline=True):
self.request.sendall(str.encode(string))
if newline:
self.request.sendall(b'\n')
def close(self):
self.request.close()
class Server(socketserver.ForkingTCPServer):
allow_reuse_address = True
def handle_error(self, request, client_address):
self.request.close()
port = 1337
server = Server(('0.0.0.0', port), RequestHandler)
server.serve_forever()
Análisis del cifrado
Se nos proporcionan estas dos opciones:
$ nc 206.189.114.209 31967
-------------------------------------------------------------------------
| Welcome to my Super Secure Encryption service! |
| We use AES along with custom padding for authentication |
| for extra security, so only admins should be able to |
| decrypt the flag with the key I have provided them! |
| Admins: Feel free to send me messages that you have |
| encrypted with my key, but make sure they are padded |
| correctly with my custom padding I showed you (fibopadcci) |
| Also, please use the a value I gave you last time, |
| if you need it, ask a fellow admin, I don't want some random |
| outsiders decrypting our secret flag. |
-------------------------------------------------------------------------
-------------------------
| Menu |
-------------------------
|[0] Encrypt flag. |
|[1] Send me a message! |
-------------------------
Your option:
La primera solo envía la flag cifrada y dos valores a
y b
.
Your option: 0
encrypted_flag: 18a6cae6493d67b7d35180499b9db145e33abdfb5a265dc6f5cf257ac6dfde78211ff6bc21399f605790cc6a0b67c9bc
a: ae9b722fd54f1a40526252a1d8f2da17
b: c304b05b0725ce2e93f6ef2b4bd0a73f
La segunda opción intenta descifrar el mensaje cifrado, pero muestra un error:
-------------------------
| Menu |
-------------------------
|[0] Encrypt flag. |
|[1] Send me a message! |
-------------------------
Your option: 1
Enter your ciphertext in hex: 18a6cae6493d67b7d35180499b9db145e33abdfb5a265dc6f5cf257ac6dfde78211ff6bc21399f605790cc6a0b67c9bc
Enter the B used during encryption in hex: c304b05b0725ce2e93f6ef2b4bd0a73f
Error: Message padding incorrect, not sent.
El error dice “padding incorrect”, por lo que probablemente realizaremos un Padding Oracle Attack. Esta es la clase que implementa el algoritmo de cifrado:
class SuperSecureEncryption: # This should be unbreakable!
def __init__(self, key):
self.cipher = AES.new(key, AES.MODE_ECB)
def encrypt(self, data):
data = pad(data)
a = os.urandom(16).replace(b'\x00', b'\xff')
b = os.urandom(16).replace(b'\x00', b'\xff')
lb_plain = a
lb_cipher = b
output = b''
data = [data[i:i+16] for i in range(0, len(data), 16)]
for block in data:
enc = self.cipher.encrypt(xor(lb_cipher, block))
enc = xor(enc, lb_plain)
output += enc
lb_plain = block
lb_cipher = enc
return output, a.hex(), b.hex()
def decrypt(self, data, a, b):
lb_plain = a
lb_cipher = b
output = b''
data = [data[i:i+16] for i in range(0, len(data), 16)]
for block in data:
dec = self.cipher.decrypt(xor(block, lb_plain))
dec = xor(dec, lb_cipher)
output += dec
lb_plain = dec
lb_cipher = block
if checkpad(output):
return output
else:
return None
Vale la pena dibujar los procesos de cifrado y descifrado en diagramas de bloques:
- Cifrado:
- Descifrado:
En realidad, este esquema de cifrado se conoce como Infinite Garble Extension (entonces, en realidad no es un cifrado en bloque personalizado).
En primer lugar, recuperaremos la flag cifrada, que envía el texto cifrado y los dos valores a
y b
usados para eso (son aleatorios).
No podemos descifrar el mensaje nosotros mismos porque no tenemos la clave AES para los bloques de descifrado… y no podemos decirle al servidor que lo descifre porque el servidor usa un valor a
fijo:
elif option == "1":
try:
ct = unhex(self.query("Enter your ciphertext in hex: "))
b = unhex(self.query("Enter the B used during encryption in hex: "))
a = b'HTB{th3_s3crt_A}' # My secret A! Only admins know it, and plus, other people won't be able to work out my key anyway!
self.write(sendMessage(ct,a,b))
except ValueError as e:
self.write("Provided input is not hex!")
Además, el servidor no envía el texto descifrado:
def sendMessage(ct, a, b):
if len(ct) % 16:
return "Error: Ciphertext length must be a multiple of the block length (16)!"
if len(a) != 16 or len(b) != 16:
return "Error: a and b must have lengths of 16 bytes!"
decrypted = SuperSecureEncryption(key).decrypt(ct, a, b)
if decrypted != None:
return "Message successfully sent!"
else:
return "Error: Message padding incorrect, not sent."
Padding Oracle Attack
Si el relleno del mensaje descifrado es correcto, se muestra "Message successfully sent!"
. De lo contrario, el servidor devuelve "Error: Message padding incorrect, not sent."
. Ese es el oráculo (de relleno) que tenemos.
La implementación del relleno está personalizada (relacionada con la secuencia de Fibonacci), pero eso no es un problema:
fib = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 121, 98, 219, 61]
# ...
def pad(data): #Custom padding, should be fine!
c = 0
while len(data) % 16:
pad = str(hex(fib[c] % 255))[2:]
data += unhex("0" * (2-len(pad)) + pad)
c += 1
return data
Probémoslo por si acaso:
$ python3 -q
>>> fib = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 121, 98, 219, 61]
>>>
>>> def unhex(data):
... return bytes.fromhex(data)
...
>>> def pad(data): #Custom padding, should be fine!
... c = 0
... while len(data) % 16:
... pad = str(hex(fib[c] % 255))[2:]
... data += unhex("0" * (2-len(pad)) + pad)
... c += 1
... return data
...
>>> pad(b'A')
b'A\x01\x02\x03\x05\x08\r\x15"7Y\x90\xe9yb\xdb'
>>> pad(b'ABCD')
b'ABCD\x01\x02\x03\x05\x08\r\x15"7Y\x90\xe9'
>>> pad(b'ABCDEFGHI')
b'ABCDEFGHI\x01\x02\x03\x05\x08\r\x15'
>>> pad(b'ABCDEFGHIJKLM')
b'ABCDEFGHIJKLM\x01\x02\x03'
>>> pad(b'ABCDEFGHIJKLMNO')
b'ABCDEFGHIJKLMNO\x01'
>>> pad(b'ABCDEFGHIJKLMNOP')
b'ABCDEFGHIJKLMNOP'
El relleno se comprueba en checkpad
:
def checkpad(data):
if len(data) % 16 != 0:
return 0
char = data[-1]
try:
start = fib.index(char)
except ValueError:
return 0
newfib = fib[:start][::-1]
for i in range(len(newfib)):
char = data[-(i+2)]
if char != newfib[i]:
return 0
return 1
Básicamente, toma el último byte y verifica que la secuencia de relleno es correcta. Hay un error menor, porque los mensajes con longitud un múltiplo de 16 no están rellenados y devolverán un error de relleno incorrecto:
>>> def checkpad(data):
... if len(data) % 16 != 0:
... return 0
... char = data[-1]
... try:
... start = fib.index(char)
... except ValueError:
... return 0
... newfib = fib[:start][::-1]
... for i in range(len(newfib)):
... char = data[-(i+2)]
... if char != newfib[i]:
... return 0
... return 1
...
>>> pad(b'A' * 16)
b'AAAAAAAAAAAAAAAA'
>>> pad(b'A' * 15)
b'AAAAAAAAAAAAAAA\x01'
>>> checkpad(pad(b'A' * 16))
0
>>> checkpad(pad(b'A' * 15))
1
>>> checkpad(pad(b'A' * 32))
0
Un Padding Oracle Attack funciona de la siguiente manera:
Podemos ajustar el segundo bloque de texto cifrado, que afecta directamente al último bloque de texto claro. La idea es iterar el último byte desde 0x00
hasta 0xff
Hasta que encontremos uno que resulte en 0x01
(el primer byte de la secuencia de relleno). En este punto, el servidor mostrará un mensaje exitoso.
Sin embargo, este no es un Padding Oracle Attack típico contra AES CBC. En tal caso, encontraríamos un byte “mágico” ($B_i$) que no da error de relleno y se cumplen estas condiciones:
$$ B_i \oplus \mathrm{pt}_i = \mathrm{pad}_i \iff B_i \oplus \mathrm{AES.dec}(\mathrm{ct}_i) = \mathrm{pad}_i $$
Entonces, el byte de texto claro $\mathrm{pt}_i$ se calcula como:
$$ \mathrm{pt}_i = \mathrm{AES.dec}(\mathrm{ct}_i) = \mathrm{pad}_i \oplus B_i $$
Esta vez, el texto cifrado no se envía directamente al descifrador, hay una operación XOR con el bloque anterior de texto claro. Sin embargo, es necesario comprender los conceptos detrás del Padding Oracle Attack clásico para comprender el proceso para resolver este reto. Haremos lo siguiente:
Con esta configuración, si usamos el valor proporcionado de b
, el mensaje se descifraría correctamente. Entonces, si obligamos al último byte a ser 0x01
(en este caso concreto), el servidor no dará error y podremos hacer los cálculos anteriores para encontrar el byte de texto sin formato.
Pruebas
Vamos a mostrar cómo funciona localmente:
$ cat > secret.py
from os import urandom
flag = b'HTB{ABCDEFGHIJKLM}'
key = urandom(16)
^C
$ python3 server.py
Ahora, usaremos el siguiente script de Python:
#!/usr/bin env python3
from pwn import remote, sys, xor
fib = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 121, 98, 219, 61]
def main():
host, port = sys.argv[1].split(':')
io = remote(host, port)
io.sendlineafter(b'Your option: ', b'0')
io.recvuntil(b'encrypted_flag: ')
encrypted_flag = bytes.fromhex(io.recvline().strip().decode())
io.recvuntil(b'a: ')
flag_a = bytes.fromhex(io.recvline().strip().decode())
io.recvuntil(b'b: ')
flag_b = bytes.fromhex(io.recvline().strip().decode())
print(encrypted_flag.hex(), flag_a.hex(), flag_b.hex())
secret_a = b'HTB{th3_s3crt_A}'
ct_block = xor(encrypted_flag[:16], secret_a, flag_a)
for b_byte in range(256):
io.sendlineafter(b'Your option: ', b'1')
io.sendlineafter(b'Enter your ciphertext in hex: ', ct_block.hex().encode())
b = bytes([0] * 15 + [b_byte])
io.sendlineafter(b'Enter the B used during encryption in hex: ', b.hex().encode())
if io.recvline() == b'Message successfully sent!\n':
dec = [b_byte ^ fib[0] ^ flag_b[15]]
print(bytes(dec))
break
if __name__ == '__main__':
main()
$ python3 solve.py 127.0.0.1:1337
[+] Opening connection to 127.0.0.1 on port 1337: Done
3aafe4279ec56dee1235644f384574877fb67096164ee5e5b65b44c937dff0ba 50d773e13822ea33ae90049f27c5836f f973da5793d1143b3c59c54fa14bf882
b'L'
[*] Closed connection to 127.0.0.1 port 1337
$ python3 solve.py 127.0.0.1:1337
[+] Opening connection to 127.0.0.1 on port 1337: Done
ca4789bffdb992e84441b81a99b072488d55466a133f5ed28b762cab27b9e85f 5d4ba3db381b3b4818251fa763302bbf 6d53ac073b59a7402f9f6e74beb1ff20
b'L'
[*] Closed connection to 127.0.0.1 port 1337
$ python3 solve.py 127.0.0.1:1337
[+] Opening connection to 127.0.0.1 on port 1337: Done
5a683e63568c6cdcc37604da5c5f2415f42129d6fc7c730986121f6dcf16a7f1 6e86d0b25520672f6db8bf8dc133aba9 30635516c74992dd35b08bb363727da3
b'L'
[*] Closed connection to 127.0.0.1 port 1337
Como se puede ver, estamos obteniendo L
, que es el carácter en la posición 16
de la flag en texto claro (HTB{ABCDEFGHIJKLM}
). Actualicemos el script para extraer el byte en la posición 15
:
def main():
# ...
ct_block = xor(encrypted_flag[:16], secret_a, flag_a)
pt = b''
dec = []
for b_byte in range(256):
io.sendlineafter(b'Your option: ', b'1')
io.sendlineafter(b'Enter your ciphertext in hex: ', ct_block.hex().encode())
b = bytes([0] * 15 + [b_byte])
io.sendlineafter(b'Enter the B used during encryption in hex: ', b.hex().encode())
if io.recvline() == b'Message successfully sent!\n':
dec = [b_byte ^ fib[0]] + dec
pt = bytes([dec[0] ^ flag_b[15]]) + pt
break
for b_byte in range(256):
io.sendlineafter(b'Your option: ', b'1')
io.sendlineafter(b'Enter your ciphertext in hex: ', ct_block.hex().encode())
b = bytes([0] * 14 + [b_byte]) + xor(bytes(dec), bytes(fib[1: len(dec) + 1]))
io.sendlineafter(b'Enter the B used during encryption in hex: ', b.hex().encode())
if io.recvline() == b'Message successfully sent!\n':
dec = [b_byte ^ fib[0]] + dec
pt = bytes([dec[0] ^ flag_b[14]]) + pt
break
print(pt)
$ python3 solve.py 127.0.0.1:1337
[+] Opening connection to 127.0.0.1 on port 1337: Done
92fedbc7fb035693f5e8a9e5214f2829a645ce32cb462be33717082f961fa15d e1962d917c48723e6a3acc448bef370f 3ced7fab3844130b748f6445b4e6990b
b'KL'
[*] Closed connection to 127.0.0.1 port 1337
Descifrado
Ahora, Lo automatizamos para obtener todos los 16 bytes de texto claro para un bloque usando un bucle for
:
def main():
# ...
ct_block = xor(encrypted_flag[:16], secret_a, flag_a)
pt = b''
dec = []
for i in range(16):
for b_byte in range(256):
io.sendlineafter(b'Your option: ', b'1')
io.sendlineafter(b'Enter your ciphertext in hex: ', ct_block.hex().encode())
b = bytes([0] * (15 - i) + [b_byte]) + xor(bytes(dec), bytes(fib[1: len(dec) + 1]))
io.sendlineafter(b'Enter the B used during encryption in hex: ', b.hex().encode())
if io.recvline() == b'Message successfully sent!\n':
dec = [b_byte ^ fib[0]] + dec
pt = bytes([dec[0] ^ flag_b[15 - i]]) + pt
break
print(pt)
$ python3 solve.py 127.0.0.1:1337
[+] Opening connection to 127.0.0.1 on port 1337: Done
31b0b9118dd27c5e97631bcf2fe708d270744b65306d721740e447fe9212278c cae439e59417b4c0576e5a45a2c3883a df41ffc451fd4b257f3615168ac540c3
b'HTB{ABCDEFGHIJKL'
[*] Closed connection to 127.0.0.1 port 1337
Lo siguiente que hacemos es poner el bucle for
en una función para descifrar todos los bloques. Para esto, debemos tener en cuenta que los valores correspondientes para a
y b
son los bloques anteriores de texto claro y texto cifrado, respectivamente (si es necesario, véase el esquema aislando los bloques centralres). También modifiqué un poco el script para mostrar información sobre el progreso del proceso de descifrado:
flag_prog = log.progress('Flag')
poa = log.progress('Bytes')
def decrypt_block(ct_block):
dec = []
poa.status('0 / 16')
for i in range(16):
for b_byte in range(256):
io.sendlineafter(b'Your option: ', b'1')
io.sendlineafter(b'Enter your ciphertext in hex: ', ct_block.hex().encode())
b = bytes([0] * (15 - i) + [b_byte]) + xor(bytes(dec), bytes(fib[1: len(dec) + 1]))
io.sendlineafter(b'Enter the B used during encryption in hex: ', b.hex().encode())
if io.recvline() == b'Message successfully sent!\n':
poa.status(f'{i + 1} / 16')
dec = [b_byte ^ fib[0]] + dec
break
return dec
encrypted_flag_blocks = [encrypted_flag[i:i+16] for i in range(0, len(encrypted_flag), 16)]
flag = b''
a, b = flag_a, flag_b
for block in encrypted_flag_blocks:
ct_block = xor(block, secret_a, a)
dec = decrypt_block(ct_block)
flag += xor(dec, b)
if b'\x01' not in flag:
flag_prog.status(flag.decode())
a, b = flag[-16:], block
flag = flag[:flag.index(b'\x01')]
flag_prog.success(flag.decode())
poa.success()
$ python3 solve.py 127.0.0.1:1337
[+] Opening connection to 127.0.0.1 on port 1337: Done
[+] Flag: HTB{ABCDEFGHIJKLM}
[+] Bytes: Done
[*] Closed connection to 127.0.0.1 port 1337
Flag
Si lo ejecutamos en la instancia remota, encontraremos la flag después de unos 15 minutos:
$ python3 solve.py 167.71.137.186:32286
[+] Opening connection to 167.71.137.186 on port 32286: Done
[+] Flag: HTB{cU5t0m_p4dd1Ng_w0nT_s4v3_y0u_tH1s_T1m3_:(!@}
[+] Bytes: Done
[*] Closed connection to 167.71.137.186 port 32286
El script completo se puede encontrar aquí: solve.py
.