Recon and Introduction

We are given the website http://privilege.hkn and the app.py that it is running:

#!/usr/bin/env python3
from flask import Flask,request,Response,render_template,abort,make_response
import json,random,os,base64
from Crypto.Cipher import AES
### Global variables
app = Flask(__name__)

secret_key = os.urandom(16)
ctr_nonce = os.urandom(8)

def gen_user_cookie():
    cookie_dict = {}
    cookie_dict["access_level"] = "User"
    pt = json.dumps(cookie_dict).encode()
    cipher = AES.new(secret_key, AES.MODE_CTR, nonce = ctr_nonce)
    ct = cipher.encrypt(pt).hex()
    return ct

def check_admin_cookie(cookie):
    ct = bytes.fromhex(cookie)
    cipher = AES.new(secret_key, AES.MODE_CTR, nonce = ctr_nonce)
    pt = cipher.decrypt(ct).decode()
    cookie_dict = json.loads(pt)
    if cookie_dict["access_level"] == "Admin":
        return True
    else:
        return False


@app.route('/flag')
def retPsyduck():
    user_cookie = request.cookies.get('permissions')
    if user_cookie == None:
        return Response("No cookie set")
    try:
        allowed = check_admin_cookie(user_cookie)
    except:
        return Response("Something went wrong with your cookie")

    if allowed: 
        with open('images/flag.png','rb') as f:
                return Response(f.read(), mimetype='image/png')
    else:
        return Response("This page is for admins only!")


@app.route("/")
def index():
    res = make_response()
    cookie = gen_user_cookie()
    res.set_cookie('permissions', cookie)
    with open('./templates/index.html') as f:
        res.set_data(f.read())
    return res



if(__name__ == '__main__'):
    app.run(host='0.0.0.0')

Clicking the button redirects us to /flag (but we dont have the right permissions):

If we look at the cookies that are set we find:

Looking at the app.py we can find the gen_user_cookie function that generates this cookie:

def gen_user_cookie():
    cookie_dict = {}
    cookie_dict["access_level"] = "User"
    pt = json.dumps(cookie_dict).encode()
    cipher = AES.new(secret_key, AES.MODE_CTR, nonce = ctr_nonce)
    ct = cipher.encrypt(pt).hex()
    return ct

Lets see what it does by printing all the variables:

In [1]: import json,os

In [2]: from Crypto.Cipher import AES

In [3]: secret_key = os.urandom(16)

In [4]: ctr_nonce = os.urandom(8)

In [5]: def gen_user_cookie():
   ...:     cookie_dict = {}
   ...:     cookie_dict["access_level"] = "User"
   ...:     pt = json.dumps(cookie_dict).encode()
   ...:     cipher = AES.new(secret_key, AES.MODE_CTR, nonce = ctr_nonce)
   ...:     ct = cipher.encrypt(pt).hex()
   ...:     print(f"""
   ...:     cookie_dict ({type(cookie_dict)}): {cookie_dict}
   ...:     pt ({type(pt)}): {pt}
   ...:     cipher ({type(cipher)}): {cipher}
   ...:     ct ({type(ct)}): {ct}
   ...:     """)
   ...:     #return ct
   ...: 

In [6]: gen_user_cookie()

    cookie_dict (<class 'dict'>): {'access_level': 'User'}
    pt (<class 'bytes'>): b'{"access_level": "User"}'
    cipher (<class 'Crypto.Cipher._mode_ctr.CtrMode'>): <Crypto.Cipher._mode_ctr.CtrMode object at 0x7fbf7829c610>
    ct (<class 'str'>): 32929cf15c3aa2de78a32a30dc835b3f6711c356470d0e49 

We can see that pt seems to be PlainText representing a json object denoting the access level of our user. That is then encrypted with AES in CTR (counter) mode and saved in ct as CipherText that is returned from the funtion.

If we take a look at the check_admin_cookie function, we can see how the cookie is validated for access to the flag:

def check_admin_cookie(cookie):
    ct = bytes.fromhex(cookie)
    cipher = AES.new(secret_key, AES.MODE_CTR, nonce = ctr_nonce)
    pt = cipher.decrypt(ct).decode()
    cookie_dict = json.loads(pt)
    if cookie_dict["access_level"] == "Admin":
        return True
    else:
        return False

This function decrypts the cookie and checks if access_level is set to Admin. So the goal is to somehow change our access_level in the cookie from User to Admin

The secret_key and ctr_nonce used on the server is randomly generated at startup through so we cant decrypt and encrypt our own cookie, since we cant get these.

A look at the properties of AES in CTR mode

If we take a look at how AES CTR works though we can find some interesting properties though:

We can see that each block is encrypted independently from the each other, and that each blocks ciphertext is made from the xor of plaintext and a keystream generated from the key, nonce and a counter.

With known plaintext and ciphertext we are able to calculate the generated keystream used in the xor:

pt ^ s = ct
pt ^ ct = s

And since we know the keystream we can use it to encrypt any other plaintext in the same position.

Lets try with an example:

pt = 0x41
ct = 0x69

s = pt ^ ct = 0x28

pt2 = 0x50
ct2 = pt2 ^ s = 0x78

Local exploit

Lets try and script this approach with modified functions from app.py:

#!/usr/bin/env python3
# local_exploit.py
import json,os
from Crypto.Cipher import AES

### Global variables
secret_key = os.urandom(16)
ctr_nonce = os.urandom(8)

def gen_user_cookie():
    cookie_dict = {}
    cookie_dict["access_level"] = "User"
    pt = json.dumps(cookie_dict).encode()
    cipher = AES.new(secret_key, AES.MODE_CTR, nonce = ctr_nonce)
    ct = cipher.encrypt(pt) # Made it return bytes instead of hex encoded
    return pt, ct # Made it return the plaintext too

def decrypt_cookie(cookie):
    ct = bytes.fromhex(cookie)
    cipher = AES.new(secret_key, AES.MODE_CTR, nonce = ctr_nonce)
    pt = cipher.decrypt(ct)
    return pt

# Make cookie and plaintext
plaintext, cookie = gen_user_cookie()
print(f"Plaintext (bytes): {plaintext}")
print(f"Plaintext (hex): {plaintext.hex()}")
print(f"Encrypted Cookie (hex): {cookie.hex()}")

# Calculate the keystream used in xor
keystream = []

print("\nCalculating keystream...")
for pt, ct in zip(plaintext, cookie):
    s = pt ^ ct
    keystream.append(s)
    print(f"{pt:02x} ^ {ct:02x} = {s:02x}")

# Encrypt new plaintext
new_dict = {}
new_dict["access_level"] = "Admin"
new_plaintext = json.dumps(new_dict).encode()

print(f"\nNew plaintext (bytes): {new_plaintext}")
print(f"\nNew plaintext (hex): {new_plaintext.hex()}")

new_ciphertext = ""

print("\nEncrypting new plaintext...")
for s, pt in zip(keystream, new_plaintext):
    ct = s ^ pt
    new_ciphertext += f"{ct:02x}"
    print(f"{s:02x} ^ {pt:02x} = {ct:02x}")

print(f"New Ciphertext (hex): {new_ciphertext}")

print(f"\nDecrypted Ciphertext with known secret_key and nonce: {decrypt_cookie(new_ciphertext)}")
$ python local_exploit.py
Plaintext (bytes): b'{"access_level": "User"}'
Plaintext (hex): 7b226163636573735f6c6576656c223a202255736572227d
Encrypted Cookie (hex): 36363f2ec9306e38d87f9ad8c6fabd13a2431b2c79ff34a3

Calculating keystream...
7b ^ 36 = 4d
22 ^ 36 = 14
61 ^ 3f = 5e
63 ^ 2e = 4d
63 ^ c9 = aa
65 ^ 30 = 55
73 ^ 6e = 1d
73 ^ 38 = 4b
5f ^ d8 = 87
6c ^ 7f = 13
65 ^ 9a = ff
76 ^ d8 = ae
65 ^ c6 = a3
6c ^ fa = 96
22 ^ bd = 9f
3a ^ 13 = 29
20 ^ a2 = 82
22 ^ 43 = 61
55 ^ 1b = 4e
73 ^ 2c = 5f
65 ^ 79 = 1c
72 ^ ff = 8d
22 ^ 34 = 16
7d ^ a3 = de

New plaintext (bytes): b'{"access_level": "Admin"}'

New plaintext (hex): 7b226163636573735f6c6576656c223a202241646d696e227d

Encrypting new plaintext...
4d ^ 7b = 36
14 ^ 22 = 36
5e ^ 61 = 3f
4d ^ 63 = 2e
aa ^ 63 = c9
55 ^ 65 = 30
1d ^ 73 = 6e
4b ^ 73 = 38
87 ^ 5f = d8
13 ^ 6c = 7f
ff ^ 65 = 9a
ae ^ 76 = d8
a3 ^ 65 = c6
96 ^ 6c = fa
9f ^ 22 = bd
29 ^ 3a = 13
82 ^ 20 = a2
61 ^ 22 = 43
4e ^ 41 = 0f
5f ^ 64 = 3b
1c ^ 6d = 71
8d ^ 69 = e4
16 ^ 6e = 78
de ^ 22 = fc
New Ciphertext (hex): 36363f2ec9306e38d87f9ad8c6fabd13a2430f3b71e478fc

Decrypted Ciphertext with known secret_key and nonce: b'{"access_level": "Admin"'

Bruteforce missing byte

It seems to work pretty well, we only have a little problem, the new ciphertext is missing the closing brace (}) to end the dictionary.

This happens because the user cookie is 24 bytes long but the new plaintext is 25 bytes long:

In [1]: len(b'{"access_level": "User"}')
Out[1]: 24

In [2]: len(b'{"access_level": "Admin"}')
Out[2]: 25

Since we can only calculate keystream from the plaintext we are missing the 25th keystream to encrypt the last character with.

We will need to bruteforce it instead, by anding the following to the bottom of our script:

# Bruteforce the last (extra) byte
print("\nBruteforcing the last (extra) byte")
for i in range(2**8):
    ct = new_ciphertext + f"{i:02x}"
    decrypted = decrypt_cookie(ct)
    print(f"{ct} decrypted to {decrypted}", end="...")
    if decrypted == new_plaintext:
        print("Correct")
        new_ciphertext = ct
        break
    print("Wrong")

print(f"\nNew Ciphertext with correct extra byte (hex): {new_ciphertext}")

If we then run it again we can see that we also get the last byte:

$ python local_exploit.py
Plaintext (bytes): b'{"access_level": "User"}'
Plaintext (hex): 7b226163636573735f6c6576656c223a202255736572227d
Encrypted Cookie (hex): 9aaa57450f9da26cc6fa38204d4c5d4ee5b3c935b1f543ce

Calculating keystream...
7b ^ 9a = e1
22 ^ aa = 88
61 ^ 57 = 36
63 ^ 45 = 26
63 ^ 0f = 6c
65 ^ 9d = f8
73 ^ a2 = d1
73 ^ 6c = 1f
5f ^ c6 = 99
6c ^ fa = 96
65 ^ 38 = 5d
76 ^ 20 = 56
65 ^ 4d = 28
6c ^ 4c = 20
22 ^ 5d = 7f
3a ^ 4e = 74
20 ^ e5 = c5
22 ^ b3 = 91
55 ^ c9 = 9c
73 ^ 35 = 46
65 ^ b1 = d4
72 ^ f5 = 87
22 ^ 43 = 61
7d ^ ce = b3

New plaintext (bytes): b'{"access_level": "Admin"}'

New plaintext (hex): 7b226163636573735f6c6576656c223a202241646d696e227d

Encrypting new plaintext...
e1 ^ 7b = 9a
88 ^ 22 = aa
36 ^ 61 = 57
26 ^ 63 = 45
6c ^ 63 = 0f
f8 ^ 65 = 9d
d1 ^ 73 = a2
1f ^ 73 = 6c
99 ^ 5f = c6
96 ^ 6c = fa
5d ^ 65 = 38
56 ^ 76 = 20
28 ^ 65 = 4d
20 ^ 6c = 4c
7f ^ 22 = 5d
74 ^ 3a = 4e
c5 ^ 20 = e5
91 ^ 22 = b3
9c ^ 41 = dd
46 ^ 64 = 22
d4 ^ 6d = b9
87 ^ 69 = ee
61 ^ 6e = 0f
b3 ^ 22 = 91
New Ciphertext (hex): 9aaa57450f9da26cc6fa38204d4c5d4ee5b3dd22b9ee0f91

Decrypted Ciphertext with known secret_key and nonce: b'{"access_level": "Admin"'

Bruteforcing the last (extra) byte
9aaa57450f9da26cc6fa38204d4c5d4ee5b3dd22b9ee0f9100 decrypted to b'{"access_level": "Admin"\xb6'...Wrong
9aaa57450f9da26cc6fa38204d4c5d4ee5b3dd22b9ee0f9101 decrypted to b'{"access_level": "Admin"\xb7'...Wrong
9aaa57450f9da26cc6fa38204d4c5d4ee5b3dd22b9ee0f9102 decrypted to b'{"access_level": "Admin"\xb4'...Wrong
...
9aaa57450f9da26cc6fa38204d4c5d4ee5b3dd22b9ee0f91c9 decrypted to b'{"access_level": "Admin"\x7f'...Wrong
9aaa57450f9da26cc6fa38204d4c5d4ee5b3dd22b9ee0f91ca decrypted to b'{"access_level": "Admin"|'...Wrong
9aaa57450f9da26cc6fa38204d4c5d4ee5b3dd22b9ee0f91cb decrypted to b'{"access_level": "Admin"}'...Correct

New Ciphertext with correct extra byte (hex): 9aaa57450f9da26cc6fa38204d4c5d4ee5b3dd22b9ee0f91cb

Remote exploit

Now we just need to change the code to be able to do it with the cookie from the challenge server. I modified the script to the following:

#!/usr/bin/env python3
# remote_exploit.py
import json,requests

# Make cookie and plaintext
plaintext_dict = {}
plaintext_dict["access_level"] = "User"
plaintext = json.dumps(plaintext_dict).encode()

cookie = "" # Change me to the cookie from the server

# Convert cookie to bytes (hex -> bytes)
cookie = bytes.fromhex(cookie)

print(f"Plaintext (bytes): {plaintext}")
print(f"Plaintext (hex): {plaintext.hex()}")
print(f"Encrypted Cookie (hex): {cookie.hex()}")

# Calculate the keystream used in xor
keystream = []

print("\nCalculating keystream...")
for pt, ct in zip(plaintext, cookie):
    s = pt ^ ct
    keystream.append(s)
    print(f"{pt:02x} ^ {ct:02x} = {v:02x}")

# Encrypt new plaintext
new_dict = {}
new_dict["access_level"] = "Admin"
new_plaintext = json.dumps(new_dict).encode()

print(f"\nNew plaintext (bytes): {new_plaintext}")
print(f"\nNew plaintext (hex): {new_plaintext.hex()}")

new_ciphertext = ""

print("\nEncrypting new plaintext...")
for s, pt in zip(keystream, new_plaintext):
    ct = s ^ pt
    new_ciphertext += f"{ct:02x}"
    print(f"{s:02x} ^ {pt:02x} = {ct:02x}")

print(f"New Ciphertext (hex): {new_ciphertext}")

# Bruteforce the last (extra) byte
print("\nBruteforcing the last (extra) byte")
for i in range(2**8):
    ct = new_ciphertext + f"{i:02x}"

    # Bruteforce by using requests to the /flag path on the server
    print(f"Trying {ct}", end="...")
    r = requests.get("http://privilege.hkn/flag", cookies={"permissions": ct})
    # Look for "cookie" in the response, since the error message is:
    # "Something went wrong with your cookie" 
    if "cookie" not in r.text:
        print("Correct")
        new_ciphertext = ct
        break
    else:
        print("Wrong")

print(f"\nNew Ciphertext with correct extra byte (hex): {new_ciphertext}")
$ python remote_exploit.py
Plaintext (bytes): b'{"access_level": "User"}'
Plaintext (hex): 7b226163636573735f6c6576656c223a202255736572227d
Encrypted Cookie (hex): 0aeaf0b0fe3a8974e6244e6ea7265f7c2fe4efdf2619ed68

Calculating keystream...
7b ^ 0a = 71
22 ^ ea = c8
61 ^ f0 = 91
63 ^ b0 = d3
63 ^ fe = 9d
65 ^ 3a = 5f
73 ^ 89 = fa
73 ^ 74 = 07
5f ^ e6 = b9
6c ^ 24 = 48
65 ^ 4e = 2b
76 ^ 6e = 18
65 ^ a7 = c2
6c ^ 26 = 4a
22 ^ 5f = 7d
3a ^ 7c = 46
20 ^ 2f = 0f
22 ^ e4 = c6
55 ^ ef = ba
73 ^ df = ac
65 ^ 26 = 43
72 ^ 19 = 6b
22 ^ ed = cf
7d ^ 68 = 15

New plaintext (bytes): b'{"access_level": "Admin"}'

New plaintext (hex): 7b226163636573735f6c6576656c223a202241646d696e227d

Encrypting new plaintext...
71 ^ 7b = 0a
c8 ^ 22 = ea
91 ^ 61 = f0
d3 ^ 63 = b0
9d ^ 63 = fe
5f ^ 65 = 3a
fa ^ 73 = 89
07 ^ 73 = 74
b9 ^ 5f = e6
48 ^ 6c = 24
2b ^ 65 = 4e
18 ^ 76 = 6e
c2 ^ 65 = a7
4a ^ 6c = 26
7d ^ 22 = 5f
46 ^ 3a = 7c
0f ^ 20 = 2f
c6 ^ 22 = e4
ba ^ 41 = fb
ac ^ 64 = c8
43 ^ 6d = 2e
6b ^ 69 = 02
cf ^ 6e = a1
15 ^ 22 = 37
New Ciphertext (hex): 0aeaf0b0fe3a8974e6244e6ea7265f7c2fe4fbc82e02a137

Bruteforcing the last (extra) byte
Trying 0aeaf0b0fe3a8974e6244e6ea7265f7c2fe4fbc82e02a13700...Wrong
Trying 0aeaf0b0fe3a8974e6244e6ea7265f7c2fe4fbc82e02a13701...Wrong
Trying 0aeaf0b0fe3a8974e6244e6ea7265f7c2fe4fbc82e02a13702...Wrong
...
Trying 0aeaf0b0fe3a8974e6244e6ea7265f7c2fe4fbc82e02a13741...Wrong
Trying 0aeaf0b0fe3a8974e6244e6ea7265f7c2fe4fbc82e02a13742...Wrong
Trying 0aeaf0b0fe3a8974e6244e6ea7265f7c2fe4fbc82e02a13743...Correct

New Ciphertext with correct extra byte (hex): 0aeaf0b0fe3a8974e6244e6ea7265f7c2fe4fbc82e02a13743

Profit

We can then copy our calculated ciphertext and set it as our cookie in the browser:

and then reload the /flag site:

We have now stolen the flag from psyduck.