Hash the Filesystem
5 minutos de lectura
Se nos proporciona un código fuente que nos pide iniciar sesión y nos ofrece algunas funcionalidades. Esta es la función principal:
def challenge(req):
fnames = initializeDatabase()
file_record['admin'] = [fname for fname in fnames]
req.sendall(b'Super secret file server for malicious operations.\n' +
b'Who are you:\n' + b'> ')
user = req.recv(4096).decode().strip()
if user == 'admin':
req.sendall(
b'Administrator can access the server only via ssh.\nGoodbye!\n')
return
token = json.dumps({'username': user, 'timestamp': str(time.time())})
file_record[user] = []
key = os.urandom(16)
iv, token_ct = encrypt(key, token.encode())
req.sendall(b'Your token is: ' + token_ct.encode() + b'\n')
while True:
req.sendall(
b'1. Upload a file.\n2. Available files.\n3. Download a file.\n')
req.sendall(b'> ')
option = req.recv(4096).decode().strip()
try:
if option == '1':
req.sendall(b'Submit your token, passphrase, and file.\n')
res = json.loads(req.recv(4096).decode().strip())
token_ct = bytes.fromhex(res['token'])
token = json.loads(decrypt(key, iv, token_ct))
if token['username'] not in file_record.keys():
file_record[token['username']] = []
dt = bytes.fromhex(res['data'])
passphrase = res['passphrase']
fname = uploadFile(dt, passphrase)
file_record[token['username']].append(fname)
payload = json.dumps({'success': True})
req.sendall(payload.encode() + b'\n')
elif option == '2':
req.sendall(b'Submit your token.\n')
res = json.loads(req.recv(4096).decode().strip())
token_ct = bytes.fromhex(res['token'])
token = json.loads(decrypt(key, iv, token_ct))
if token['username'] not in file_record.keys():
payload = json.dumps({'files': []})
else:
files = file_record[token['username']]
payload = json.dumps({'files': files})
req.sendall(payload.encode() + b'\n')
elif option == '3':
req.sendall(b'Submit your token and passphrase.\n')
res = json.loads(req.recv(4096).decode().strip())
token_ct = bytes.fromhex(res['token'])
token = json.loads(decrypt(key, iv, token_ct))
passphrase = res['passphrase']
fname = getFname(passphrase)
files = file_record[token['username']]
if fname not in files:
payload = json.dumps({'filename': fname, 'success': False})
else:
content = readFile(fname).hex()
payload = json.dumps({
'filename': fname,
'success': True,
'content': content
})
req.sendall(payload.encode() + b'\n')
else:
req.sendall(b'Wrong option.')
except Exception as e:
req.sendall(b'An error has occured. Please try again.\n' + str(e).encode())
Lo primero de todo, hay un usuario llamado admin
que tiene algunos archivos:
def initializeDatabase():
fnames = []
directory = "./uploads/"
for file in os.listdir(directory):
file = directory + file
with open(file, "rb") as f:
data = f.read()
fname = uploadFile(data, os.urandom(100))
os.rename(file, directory + fname)
fnames.append(fname)
return fnames
Y no podemos iniciar sesión como admin
directamente. Después de autenticarnos, se nos dará un token JSON cifrado (campos username
y timestamp
):
$ nc 178.128.169.13 31214
Super secret file server for malicious operations.
Who are you:
> rocky
Your token is: 5aaf6404b1d989c8c4cbb7fe535b6f3de7c6233d5b9560b7ff81ef42806a5c7ed46b12c8bd76b65961a35e75731aef95f0418b1c969e6deaa9145722c9e5fde1
1. Upload a file.
2. Available files.
3. Download a file.
>
El cifrado para este token se hace en AES CTR. Aquí tenemos la primera vulnerabilidad. Así es como funciona AES CTR:
Vemos que el texto claro usa XOR contra el chorro de bits generado por los bloques AES con una clave dada, un IV y un contador. Entonces, el bit-stream es solo aplicar XOR entre el texto cifrado y el texto claro:
$$ \mathrm{ct} = \mathrm{bs} \oplus \mathrm{pt} \iff \mathrm{bs} = \mathrm{ct} \oplus \mathrm{pt} $$
Por tanto, podemos modificar fácilmente el texto ciffrado de manera que se descifre como admin
en el usuario:
$$ \mathrm{ct}' = \mathrm{bs} \oplus \mathrm{pt}' $$
Podemos verificar que no tenemos ningún archivo aún:
$ nc 178.128.169.13 31214
...
1. Upload a file.
2. Available files.
3. Download a file.
> 2
Submit your token.
{"token":"5aaf6404b1d989c8c4cbb7fe535b6f3de7c6233d5b9560b7ff81ef42806a5c7ed46b12c8bd76b65961a35e75731aef95f0418b1c969e6deaa9145722c9e5fde1"}
{"files": []}
Ahora, vamos a comenzar el script usando pwntools
para realizar las operaciones con XOR. Nótese que sabemos el texto claro original (token JSON) a excepción de timestamp
. Podemos calcularlo justo antes de recibir el token (en verdad no es relevante ya que no se verifica).
def main():
host, port = sys.argv[1].split(':')
io = remote(host, int(port))
user = 'rocky'
io.sendlineafter(b'> ', user.encode())
now = str(time.time())
io.recvuntil(b'Your token is: ')
token_pt = json.dumps({'username': user, 'timestamp': now})
token_ct = bytes.fromhex(io.recvline().strip().decode())
stream = xor(pad(token_pt.encode(), 16), token_ct)
admin_token_pt = json.dumps({'username': 'admin', 'timestamp': now})
admin_token_ct = xor(stream, pad(admin_token_pt.encode(), 16))
io.sendlineafter(b'> ', b'2')
io.sendlineafter(b'Submit your token.\n', json.dumps({'token': admin_token_ct.hex()}).encode())
files = set(json.loads(io.recv().decode())['files'])
log.info(f'Files: {files}')
io.interactive()
Y así nos conseguimos autenticar como admin
. Tenemos estos archivos:
$ python3 solve.py 178.128.169.13:31214
[+] Opening connection to 178.128.169.13 on port 31214: Done
[*] Files: {'960330fe5ba25b', '50c6e8cef3a6f91e', '631bf546a8812a78', 'ff299d2e8e3a7783a7', 'ff6338870dcfeca5de'}
[*] Switching to interactive mode
1. Upload a file.
2. Available files.
3. Download a file.
> $
Estas son algunas funciones relacionadas con la gestión de archivos:
def getFname(passphrase):
tphrase = tuple(passphrase)
return hex(hash(tphrase)).replace('0x', '').replace('-', 'ff')
def uploadFile(dt, passphrase):
fname = getFname(passphrase)
open('./uploads/' + fname, 'wb').write(dt)
return fname
def readFile(fname):
return open('./uploads/' + fname, 'rb').read()
Nos gustaría usar la opción 3
para descargar todos los archivos y encontrar la flag en alguno de ellos. El problema aquí es que necesitamos introducir una passphrase
que se transformará a tuple
y luego se le aplicará la función de hash de Python (en getFname
).
Esta función hash
devuelve un número de 8 bytes, pero depende mucho de los tipos de entrada:
$ python3 -q
>>> hex(hash('asdf'))
'0x62c09052785dadc'
>>> hex(hash(b'asdf'))
'0x62c09052785dadc'
>>> hex(hash(tuple('asdf')))
'0x49f23da86a2b619e'
>>> hex(hash(tuple(b'asdf')))
'-0x49b8b3f6e75ed0ae'
>>> hex(hash(1))
'0x1'
>>> hex(hash(2))
'0x2'
>>> hex(hash(tuple({'asdf': 1})))
'-0x13159d01c2f43a76'
Para números enteros, es trivial de invertir. De hecho, existe una manera de invertir la función hash
para objetos de tipo tuple
(que es nuestro caso). Encontré la solución en FCSC 2022 - Hash-ish. Este artículo analiza el código de Python en el que se implementa la función hash
para tipo tuple
.
Finalmente, adapté las funciones escritas por el autor del artículo a nuestro problema. Como estamos tratando con documentos en JSON, podemos introducir una lista y se formateará como list
en Python, y luego se transformará a tuple
. Entonces todo bien:
for file in files:
fname = find_collision(file)
io.sendlineafter(b'> ', b'3')
io.sendlineafter(b'Submit your token and passphrase.\n', json.dumps({'token': admin_token_ct.hex(), 'passphrase': fname}).encode())
content = bytes.fromhex(json.loads(io.recvline().decode())['content'])
if b'HTB{' in content:
log.success(f'Flag: {content.decode().strip()}')
break
io.close()
Usando este script: solve.py
podemos obtener la flag:
$ python3 solve.py 178.128.169.13:31214
[+] Opening connection to 178.128.169.13 on port 31214: Done
[*] Files: {'ff703746f45528d417', 'ff623150c55cdd9c09', '285f19e8770e0b14', 'ff637b5a6962fc4c3e', '57c39a9fbebb176a'}
[+] Flag: HTB{f1nd1n9_2320_d4y5_1n_py7h0n_15_fun}
[*] Closed connection to 178.128.169.13 port 31214