Protein Cookies 2
5 minutes to read
This challenge uses the same code base as Protein Cookies, so we will assume a lot of infrastructure things.
Target
The aim of the challenge is to forge a session cookie to be authenticated in order to download the secret PDF file containing the flag:
@web.route('/program')
@verify_login
def program():
return send_file('flag.pdf')
The cookie (named login_info
) is created and verified in util.py
:
def create_cookie(username, is_logged_in=False):
data = f'user_id={username}&isLoggedIn={is_logged_in}'
signature = lj12_hash(SECRET + data.encode())
return data + '.' + signature
def verify_cookie(cookie_data):
data, signature = cookie_data.split(".")
if lj12_hash(SECRET + data.encode()) == signature:
return {
k: v[-1] for k, v in parse_qs(data).items()
}.get('isLoggedIn', '') == 'True'
return False
Cookie crafting
This cookie is crafted with the the following code (cryptoutil.py
):
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
BLOCK_LEN = 32
SECRET = get_random_bytes(50)
iv = b"@\xab\x97\xca\x18\x1d\xac<\x1e\xc3xC\x9b\x1c\xc5\x1f\x8aD=\xec*\x16G\xe7\x89'\x80\xe4\xe6\xfc5l"
def pad(data):
if len(data) % BLOCK_LEN == 0:
return data
pad_byte = bytes([len(data) % 256])
pad_len = BLOCK_LEN - (len(data) % BLOCK_LEN)
data += pad_byte * pad_len
return data
def compression_function(data, key):
if len(data) != BLOCK_LEN or len(key) != BLOCK_LEN:
raise ValueError(f"Input for compression function is not {BLOCK_LEN} bytes long!")
# AES is a safe compression function, right? Why not just use that?
cipher = AES.new(key, AES.MODE_ECB)
enc = cipher.encrypt(data)
# let's confuse it up a bit more, don't want to make it too easy!
enc = enc[::-1]
enc = enc[::2] + enc[1::2]
enc = enc[::3] + enc[2::3] + enc[1::3]
return enc
def lj12_hash(data):
data = pad(data)
blocks = [data[x:x + BLOCK_LEN] for x in range(0, len(data), BLOCK_LEN)]
enc_block = iv
for i in range(len(blocks)):
enc_block = compression_function(blocks[i], enc_block)
return enc_block.hex()
It uses AES as a hash function, which is a bit weird.
In brief, the cookie contains data and a signature (separated by a dot), so that the data is not easily modifiable:
Understanding the signature
Let’s debug these functions in the Python REPL:
$ python3 -q
>>> from Crypto.Cipher import AES
>>> from Crypto.Random import get_random_bytes
>>>
>>> BLOCK_LEN = 32
>>> SECRET = get_random_bytes(50)
>>>
>>> iv = b"@\xab\x97\xca\x18\x1d\xac<\x1e\xc3xC\x9b\x1c\xc5\x1f\x8aD=\xec*\x16G\xe7\x89'\x80\xe4\xe6\xfc5l"
>>>
>>>
>>> def pad(data):
... if len(data) % BLOCK_LEN == 0:
... return data
... pad_byte = bytes([len(data) % 256])
... pad_len = BLOCK_LEN - (len(data) % BLOCK_LEN)
... data += pad_byte * pad_len
... return data
...
>>>
>>> def compression_function(data, key):
... print(f'compression_function({data}, {key.hex()})')
... if len(data) != BLOCK_LEN or len(key) != BLOCK_LEN:
... raise ValueError(f"Input for compression function is not {BLOCK_LEN} bytes long!")
... # AES is a safe compression function, right? Why not just use that?
... cipher = AES.new(key, AES.MODE_ECB)
... enc = cipher.encrypt(data)
... print(f'AES({key.hex()}) ({data}) = {enc.hex()}')
... # let's confuse it up a bit more, don't want to make it too easy!
... enc = enc[::-1]
... enc = enc[::2] + enc[1::2]
... enc = enc[::3] + enc[2::3] + enc[1::3]
... print(f'{enc.hex() = }\n')
... return enc
...
>>>
>>> def lj12_hash(data):
... data = pad(data)
... blocks = [data[x:x + BLOCK_LEN] for x in range(0, len(data), BLOCK_LEN)]
... enc_block = iv
... for i in range(len(blocks)):
... enc_block = compression_function(blocks[i], enc_block)
... return enc_block.hex()
...
>>>
Specifically, we are interested in compression_function
, so notice how I added some print
statements to watch intermediate results. Now, let’s build a cookie:
>>> def create_cookie(username, is_logged_in=False):
... data = f'user_id={username}&isLoggedIn={is_logged_in}'
... signature = lj12_hash(SECRET + data.encode())
... return data + '.' + signature
...
>>> create_cookie('guest')
compression_function(b'\xe6oe\x8b\x1b\x01\x9d\xa2\xfd\x82n\x19#\x1e=\xbdT\xcdfV/\xfc\xbeh0\xe2\xeb\x00\xc6.A6', 40ab97ca181dac3c1ec378439b1cc51f8a443dec2a1647e7892780e4e6fc356c)
AES(40ab97ca181dac3c1ec378439b1cc51f8a443dec2a1647e7892780e4e6fc356c) (b'\xe6oe\x8b\x1b\x01\x9d\xa2\xfd\x82n\x19#\x1e=\xbdT\xcdfV/\xfc\xbeh0\xe2\xeb\x00\xc6.A6') = 0775dee00d03383ffe727ac4f525fd87c6218042edfe65003887974b669251c8
enc.hex() = 'c88742253f7597edfdfede4bfe8772e06665c67a0d920021c403513880f53807'
compression_function(b'l\x8bk\xf4\xaa|\xbdL\xef\x87\x0e\x1d[\xe8\xb7\xa3gVuser_id=guest&', c88742253f7597edfdfede4bfe8772e06665c67a0d920021c403513880f53807)
AES(c88742253f7597edfdfede4bfe8772e06665c67a0d920021c403513880f53807) (b'l\x8bk\xf4\xaa|\xbdL\xef\x87\x0e\x1d[\xe8\xb7\xa3gVuser_id=guest&') = 5e5244d093d9bbe4da813e1c769a99c92825e7b9498672f940dbd73bab6d97f3
enc.hex() = 'f3dbb99ae452d74999da443b86c981d0ab72283e936df9251cd99740e776bb5e'
compression_function(b'isLoggedIn=FalsePPPPPPPPPPPPPPPP', f3dbb99ae452d74999da443b86c981d0ab72283e936df9251cd99740e776bb5e)
AES(f3dbb99ae452d74999da443b86c981d0ab72283e936df9251cd99740e776bb5e) (b'isLoggedIn=FalsePPPPPPPPPPPPPPPP') = c2da45ccd5382bac94a79eed57a6151d571efe71fe0669e4c53ba8228865ed82
enc.hex() = '823b71a6acdaa8fe15944522061da7cc8869579ed565e41eed38edc5fe572bc2'
'user_id=guest&isLoggedIn=False.823b71a6acdaa8fe15944522061da7cc8869579ed565e41eed38edc5fe572bc2'
It seems clear, right? compression_function
takes data
and key
, sets up an AES cipher with key
and encrypts data
with AES ECB. Then, the ciphertext is shuffled and then it returns the result. The next call to compression_function
will take the next data
block and the previous result as key
. Notice how the input data is padded with P
characters.
Hash length extension attack
As well as in Protein Cookies, this challenge is related to hash length extension attack. While reading this article, I immediately understood what to do here.
The objective is to append more information to the alreaady signed data and craft a new signature that holds the new information plus the old one. The key is to understand that hash functions divide information in fix-length blocks (and use padding if necessary). They compute the hash for a block and take that result into account for the next block.
So, to add information, we need to fill the already signed data with padding and append new data. Then, we must take the current signature of the signed data and use it to generate the new signature using the next block (the appended information). The result will be a valid signature.
Implementation
For this challenge, we would like to add isLoggedIn=True
. Because of how query strings and Python work, we can simply add &isLoggedIn=True
to the current information:
>>> from urllib.parse import parse_qs
>>>
>>> {k: v[-1] for k, v in parse_qs('user_id=guest&isLoggedIn=False').items()}
{'user_id': 'guest', 'isLoggedIn': 'False'}
>>> {k: v[-1] for k, v in parse_qs('user_id=guest&isLoggedIn=FalsePPPPPPPPPPPPPPPP&isLoggedIn=True').items()}
{'user_id': 'guest', 'isLoggedIn': 'True'}
So, the implementation is simple:
>>> lj12_hash(SECRET + b'user_id=guest&isLoggedIn=FalsePPPPPPPPPPPPPPPP&isLoggedIn=True')
compression_function(b'\xe6oe\x8b\x1b\x01\x9d\xa2\xfd\x82n\x19#\x1e=\xbdT\xcdfV/\xfc\xbeh0\xe2\xeb\x00\xc6.A6', 40ab97ca181dac3c1ec378439b1cc51f8a443dec2a1647e7892780e4e6fc356c)
AES(40ab97ca181dac3c1ec378439b1cc51f8a443dec2a1647e7892780e4e6fc356c) (b'\xe6oe\x8b\x1b\x01\x9d\xa2\xfd\x82n\x19#\x1e=\xbdT\xcdfV/\xfc\xbeh0\xe2\xeb\x00\xc6.A6') = 0775dee00d03383ffe727ac4f525fd87c6218042edfe65003887974b669251c8
enc.hex() = 'c88742253f7597edfdfede4bfe8772e06665c67a0d920021c403513880f53807'
compression_function(b'l\x8bk\xf4\xaa|\xbdL\xef\x87\x0e\x1d[\xe8\xb7\xa3gVuser_id=guest&', c88742253f7597edfdfede4bfe8772e06665c67a0d920021c403513880f53807)
AES(c88742253f7597edfdfede4bfe8772e06665c67a0d920021c403513880f53807) (b'l\x8bk\xf4\xaa|\xbdL\xef\x87\x0e\x1d[\xe8\xb7\xa3gVuser_id=guest&') = 5e5244d093d9bbe4da813e1c769a99c92825e7b9498672f940dbd73bab6d97f3
enc.hex() = 'f3dbb99ae452d74999da443b86c981d0ab72283e936df9251cd99740e776bb5e'
compression_function(b'isLoggedIn=FalsePPPPPPPPPPPPPPPP', f3dbb99ae452d74999da443b86c981d0ab72283e936df9251cd99740e776bb5e)
AES(f3dbb99ae452d74999da443b86c981d0ab72283e936df9251cd99740e776bb5e) (b'isLoggedIn=FalsePPPPPPPPPPPPPPPP') = c2da45ccd5382bac94a79eed57a6151d571efe71fe0669e4c53ba8228865ed82
enc.hex() = '823b71a6acdaa8fe15944522061da7cc8869579ed565e41eed38edc5fe572bc2'
compression_function(b'&isLoggedIn=Truepppppppppppppppp', 823b71a6acdaa8fe15944522061da7cc8869579ed565e41eed38edc5fe572bc2)
AES(823b71a6acdaa8fe15944522061da7cc8869579ed565e41eed38edc5fe572bc2) (b'&isLoggedIn=Truepppppppppppppppp') = df5243aa39123c49d7bc5e60f5c56871e6db406f1cef75b306cbdfb4ca295384
enc.hex() = '84cb6fc54952df1c68d743b4ef71bcaaca75e65e3929b3db6012530640f53cdf'
'84cb6fc54952df1c68d743b4ef71bcaaca75e65e3929b3db6012530640f53cdf'
As can be seen, we only need to take the current signature (823b71...572bc2
) and enter it in compression_function
using &isLoggedIn=True
padded with p
as data
. The output hash will be a valid signature.
Let’s do this for the remote instance of the challenge. The current signature is 2ab7bd...4372de
. Let’s run compression_function
:
>>> compression_function(b'&isLoggedIn=Truepppppppppppppppp', bytes.fromhex('2ab7bd190e7d915b8b284fd41d9bf8a9363d81c0da1bccae8159a7a09d4372de'))
compression_function(b'&isLoggedIn=Truepppppppppppppppp', 2ab7bd190e7d915b8b284fd41d9bf8a9363d81c0da1bccae8159a7a09d4372de)
AES(2ab7bd190e7d915b8b284fd41d9bf8a9363d81c0da1bccae8159a7a09d4372de) (b'&isLoggedIn=Truepppppppppppppppp') = 9ce82a3638c784bb73234dd5cd05bc749e20f2eace2c6d3956b712a65ab1c268
enc.hex() = '68b7ea05bbe812cebc732aa62c7423365a6d9e4d38b13920d5c7c256f2cd849c'
b'h\xb7\xea\x05\xbb\xe8\x12\xce\xbcs*\xa6,t#6Zm\x9eM8\xb19 \xd5\xc7\xc2V\xf2\xcd\x84\x9c'
Now let’s add the following cookie to the browser:
user_id=guest&isLoggedIn=FalsePPPPPPPPPPPPPPPP&isLoggedIn=True.68b7ea05bbe812cebc732aa62c7423365a6d9e4d38b13920d5c7c256f2cd849c
Flag
If we go to /program
, we will see the flag:
HTB{b3aT1nG_tH3_cUsT0m_h4sH_??!}