11 minutes
DDC 2023 Qualification - Flipping Privilege
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:
Analysis of cookie generation
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.