Imaginary CTF 2021: Cookie Stream Extended Writeup
Cookie Stream - Web - 150pts
Cookie streaming service? Naaaaaaah. Password protected Rickroll as a Service? YAAAAAAAAAAAAAAAAAAAAAAAAAAAASSSSSSSSSSS!
For this challenge, we are given another combo of python code and a website. The difference here is that this challenge has a crypto challenge vibe.
I began by looking at the website.
We are greeted by a login page and not much else. Alright, next, the python code.
key = urandom(16)
cnonce = urandom(8)
users = {
'admin' : '240964a7a2f1b057b898ef33c187f2c2412aa4d849ac1a920774fd317000d33ebb8b0064834ed1f8a74763df4e95cd8c8be3a154b46929c3969ce323db69b81f',
'ImaginaryCTFUser' : '87197acc4657e9adcc2e4e24c77268fa5b95dea2867eacd493a0478a0c493420bfb2280c7e4e579a604e0a243f74a36a8931edf71b088add09537e54b11ce326',
'Eth007' : '444c67bb7d9d56580e0a2fd1ad00c535e465fc3ca9558e8333512fe65ff971a3dfb6b08f48ea4f91f8e8b55887ec3f0d7634a8df98e636a4134628c95a8f0ebf',
'just_a_normal_user' : 'b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86',
'firepwny' : '6adee5baa5ad468ac371d40771cf2e83e3033f91076f158d2c8d5d7be299adfce15247067740edd428ef596006d6eaa843b36cc109618e0a1cae843b6eed5c29',
':roocursion:' : '7f5310d2675c09c1b274f7642bf4979b2ce642515551a7617d155033e77ecfd53dede33ee541adde2f1072739696d0138d1b2f90c9ecc596095fa43b759e9baa',
}
def check(username, password):
if username not in users.keys():
return False
if sha512(password.encode()).hexdigest() == users[username]:
return True
@app.route('/')
def index():
return render_template('login.html')
@app.route('/backend', methods=['GET', 'POST'])
def backend():
if request.method == 'POST':
if not check(request.form['username'], request.form['password']):
return 'Wrong username/password.'
resp = make_response(redirect('/home'))
nonce = urandom(8)
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce) # my friend told me that cbc had some weird bit flipping attack? ctr sounds way cooler anyways
cookie = hexlify(nonce + cipher.encrypt(pad(request.form['username'].encode(), 16)))
resp.set_cookie('auth', cookie)
return resp
else:
return make_response(redirect('/home'))
@app.route('/home', methods=['GET'])
def home():
nonce = unhexlify(request.cookies.get('auth')[:16])
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce)
username = unpad(cipher.decrypt(unhexlify(request.cookies.get('auth')[16:])), 16).decode()
if username == 'admin':
flag = open('flag.txt').read()
return render_template('fun.html', username=username, message=f'Your flag: {flag}')
else:
return render_template('fun.html', username=username, message='Only the admin user can view the flag.')
@app.errorhandler(Exception)
def handle_error(e):
return redirect('/')
There is a bunch to break down in this script. But first, let’s look at this line.
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce) # my friend told me that cbc had some weird bit flipping attack? ctr sounds way cooler anyways
The code and comment both tell me that maybe it’s about time I learn a bit about AES CTR mode.
AES CTR Mode Summary
Here’s the basic run-down on how AES CTR mode works.
Encryption with CTR mode follows this general set of steps.
- A nonce is concatenated to a counter
- The key and nonce+counter are used to perform some black magic
- The block of text we want to encrypt is XOR’d with the result of step 2
- Ciphertext pops out
- The counter is incremented and the process repeats until all the text is encrypted
Decryption is very similar to encryption in CTR mode, here are the steps.
- The same nonce used in encryption is concatenated with the counter
- The key and nonce+counter are combined using the same black magic as before
- This time, the ciphertext we want to decrypt is XOR’d with the result of step 2
- Plaintext pops out
- The counter is incremented and the process repeats until all the text is decrypted
An important note is that if the key and nonce pair are never reused, this process is very secure.
Analyzing the Code
Going back to the code, it’s time for a more complete analysis.
key = urandom(16)
cnonce = urandom(8)
The first few lines create a random 16-byte key and an 8-byte cnonce. The key remains this value for the rest of the program. Cnonce, weirdly enough, is never mentioned again.
users = {
'admin' : '240964a7a2f1b057b898ef33c187f2c2412aa4d849ac1a920774fd317000d33ebb8b0064834ed1f8a74763df4e95cd8c8be3a154b46929c3969ce323db69b81f',
'ImaginaryCTFUser' : '87197acc4657e9adcc2e4e24c77268fa5b95dea2867eacd493a0478a0c493420bfb2280c7e4e579a604e0a243f74a36a8931edf71b088add09537e54b11ce326',
[...]
def check(username, password):
if username not in users.keys():
return False
if sha512(password.encode()).hexdigest() == users[username]:
return True
This next block starts with a dictionary of users and what appears to be hashes of their passwords. The second half of this excerpt confirms that they are SHA512 format hashes. Also, this turns out to be the function that authenticates logins.
I did a quick check with John the Ripper and revealed that 3 passwords were weak enough to be cracked: ImaginaryCTFUser:idk, just_a_normal_user:password, and firepwny:pwned
def backend():
if request.method == 'POST':
if not check(request.form['username'], request.form['password']):
return 'Wrong username/password.'
resp = make_response(redirect('/home'))
nonce = urandom(8)
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce) # my friend told me that cbc had some weird bit flipping attack? ctr sounds way cooler anyways
cookie = hexlify(nonce + cipher.encrypt(pad(request.form['username'].encode(), 16)))
resp.set_cookie('auth', cookie)
return resp
else:
return make_response(redirect('/home'))
This function performs two main tasks: call check() to confirm the supplied username and password, and make a session cookie for the user with AES CTR mode.
nonce = urandom(8)
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce) # my friend told me that cbc had some weird bit flipping attack? ctr sounds way cooler anyways
cookie = hexlify(nonce + cipher.encrypt(pad(request.form['username'].encode(), 16)))
Looking a bit deeper into this function provides some important details on how the cookies are formed:
- A random 8-byte nonce is made for each cookie
- The username is encrypted using AES CTR mode
- The username is padded with extra bytes using the pad() function
- The nonce is affixed to the front of the encrypted username
The last most important detail is that the cookies aren’t timestamped and cookie expiry isn’t set. This means I could potentially use a single cookie forever.
@app.route('/home', methods=['GET'])
def home():
nonce = unhexlify(request.cookies.get('auth')[:16])
cipher = AES.new(key, AES.MODE_CTR, nonce=nonce)
username = unpad(cipher.decrypt(unhexlify(request.cookies.get('auth')[16:])), 16).decode()
if username == 'admin':
flag = open('flag.txt').read()
return render_template('fun.html', username=username, message=f'Your flag: {flag}')
else:
return render_template('fun.html', username=username, message='Only the admin user can view the flag.')
The final section of code has all the session management details in it:
- The user supplies their cookie
- The nonce is removed from the front of the cookie
- The username is decrypted and unpadded
- The script checks if the username is admin and prints the flag if it is
Also, cookies are never completely validated, meaning, someone could possibly create their own cookies.
Logging In
With the code out of the way, the next step for me was to somehow get my hands on a cookie. Using the credentials I got earlier, I logged into the site as firepwny and got to see my idol, Rick Astley.
Unfortunately, only the admin can see the flag and his password wasn’t as weak. However, I did get a cookie to dissect as a runner-up prize.
Mathing it Out
Since I learned so much about how this cookie is made, I was able to break it into pieces.
original cookie: a23d98c011b41eab3b0e8375b7b6ecfc52af4f92105485a2
nonce: a23d98c011b41eab
username + padding: 3b0e8375b7b6ecfc52af4f92105485a2
Normally, this part should be a massive roadblock. However, since the cookies never expire or get their validity checked, I could potentially use this cookie to pretend to be the admin. How? Unfortunately, I needed a few equations to figure it out.
This is the normal equation to represent what AES CTR mode does. Here C is the ciphertext, P is the plaintext and F(k,n) represents the black magic that AES does to combine the nonce n, the key k, and the counter.
In this situation, the only thing we don’t know is the key. Meaning we shouldn’t be able to reverse this, especially if the nonce is random every time. However, because the script always uses the same key and relies on the user to supply the nonce, the value of F(k, n) can be simplified to a constant value - Y.
Our equation becomes way easier to reverse with Y being some constant value. Then the equation can be manipulated a bit using XOR rules.
By using XOR rules we can figure out a value for Y and use it to put any value we want into the cookie.
Baking the Cookie
To put it all into practice, I needed to first find out the value of Y. To do that I take the original username, “firepwny”, convert it to hexadecimal and add some padding to it.
'firepwny' = 6669726570776e79
'firepwny'+ padding = 6669726570776e790808080808080808
How did I choose what to pad the username with?
Well, the way the pad(input, number_of_bytes) function works is that it will add bytes to the end of the input until its length is divisible evenly by “number_of_bytes”. For example, pad(b’firepwny’, 16) pads firepwny until it is 16 bytes long.
The byte value used to pad the input is chosen based on how many bytes it needs to pad. So if I were to pad “admin” to 16 bytes…
pad('admin', 16) = admin\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b\x0b
hex('admin')+padding = 61646d696e0b0b0b0b0b0b0b0b0b0b0b
\x0b is chosen because admin needs to be padded with 11 bytes to be 16 bytes in length.
With that out of the way, I continued by XORing the padded version of firepwny with its encrypted counterpart to find Y.
Y = 6669726570776e790808080808080808 XOR 3b0e8375b7b6ecfc52af4f92105485a2
Y = 5d67f110c7c182855aa7479a185c8daa
Next, to get the desired plaintext baked into the cookie I XOR’d Y with the padded version of “admin”.
New Ciphertext = 5d67f110c7c182855aa7479a185c8daa XOR 61646d696e0b0b0b0b0b0b0b0b0b0b0b
New Ciphertext = 3c039c79a9ca898e51ac4c91135786a1
Finally, I re-added the nonce to the front to force the website to use that value when decrypting the cookie.
New Cookie = a23d98c011b41eaba23d98c011b41eab3c039c79a9ca898e51ac4c91135786a1
Saying Goodbye to Rick
The final step of this process was to switch back to the browser and replace the cookie with the brand new one.
After copy-pasting the new cookie into the browser and refreshing the page, we are greeted by the flag and the challenge is complete.