Hash the Filesystem
5 minutes to read
We are given a Python source code that asks to sign in and offers some functionalities. This is the main function:
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())
First of all, there’s a user called admin
that has some files:
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
And we cannot sign up directly as admin
. After authenticating, we will be given an encrypted JSON token (fields username
and 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.
>
The encryption for this token is AES CTR. Here we have the first vulnerability. This is how AES CTR works:
So, the plaintext uses XOR against the bit stream generated by the AES blocks with a given key, IV and counter. Therefore, the bit stream is just the XOR between the ciphertext and the plaintext:
$$ \mathrm{ct} = \mathrm{bs} \oplus \mathrm{pt} \iff \mathrm{bs} = \mathrm{ct} \oplus \mathrm{pt} $$
Hence, we can easily modify our ciphertext so that it decrypts to having admin
as username:
$$ \mathrm{ct}' = \mathrm{bs} \oplus \mathrm{pt}' $$
We can check that we don’t have any files yet:
$ nc 178.128.169.13 31214
...
1. Upload a file.
2. Available files.
3. Download a file.
> 2
Submit your token.
{"token":"5aaf6404b1d989c8c4cbb7fe535b6f3de7c6233d5b9560b7ff81ef42806a5c7ed46b12c8bd76b65961a35e75731aef95f0418b1c969e6deaa9145722c9e5fde1"}
{"files": []}
Now, let’s start a script using pwntools
in order to perform the XOR operations. Notice that we know the complete original plaintext (JSON token) but for the timestamp
key. We can compute it right before we receive the token (actually it does not matter since there is no checks on this).
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()
And we are logged in as admin
. We have these filenames:
$ 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.
> $
There are some function related to this file management:
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()
We would like to use option 3
in order to download all the files and find the flag in some of them. The problem here is that we need to enter a passphrase
that will be transformed to tuple
and then Python built-in hash function will be applied (in getFname
).
This hash
function returns an 8-byte integer, but it depends a lot on the input types:
$ 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'
For numbers, it is trivial to reverse. Actually, there is a way to reverse the hash
function for tuple
objects (which is our case). I found the solution in FCSC 2022 - Hash-ish. This article points to the Python codebase where the hash
implementation for tuple
can be found.
Eventually, I adapted the functions written by the author of the article to our problem. Since we are dealing with JSON documents, we can enter a list and it will be parsed to a Python list
, and then transformed to tuple
. So we are good to go:
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()
Using this solution script: solve.py
we can obtain the 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