NiteCTF 2024 — Solving my first QEMU Pwn

Ditto
6 min readDec 19, 2024

--

Recently, I played NiteCTF 2024 in December. I solved 4 challenges:

  1. Chaterine
  2. MixedSignal
  3. Print_The_Gifts
  4. PCI_Mayhem

The challenges are pretty fun, and is a good introduction to pwn. This CTF is also my first experience with QEMU (it has been something I wanna learn for a long time).

In this write-up, I will only be talking about the QEMU pwn!

QEMU Patch

The qemu patch introduced a new PCI Device.

+static void nite_pci_class_init(ObjectClass *klass, void *data)
+{
+ DeviceClass *dc = DEVICE_CLASS(klass);
+ PCIDeviceClass *k = PCI_DEVICE_CLASS(klass);
+
+ k->config_write = nite_config_write;
+ k->config_read = nite_config_read;
+ k->realize = nite_realize;
+ k->vendor_id = 0x6969;
+ k->device_id = 0x6969;
+ k->revision = 0x00;
+ k->class_id = PCI_CLASS_OTHERS;
+ dc->desc = "NiteCTF 2024 - Just a PCI device :D";
+}
  • It creates a new device, and has two methods known as nite_config_write and nite_config_read
+static uint32_t nite_config_read(PCIDevice *dev,
+ uint32_t addr, int len)
+{
+ PCINiteDevState *nitedev = NITE_PCI_DEV(dev);
+
+ if (addr == 0xe4) {
+ if (nitedev->addr >= 32)
+ return 0xffffffff;
+ return nitedev->mem[nitedev->addr];
+ } else {
+ return pci_default_read_config(dev, addr, len);
+ }
+}
+
+static void nite_config_write(PCIDevice *dev,
+ uint32_t addr, uint32_t val, int len)
+{
+ PCINiteDevState *nitedev = NITE_PCI_DEV(dev);
+
+ if (addr == 0xe0) {
+ nitedev->addr = val;
+ } else if (addr == 0xe4) {
+ if (nitedev->addr >= 32)
+ return;
+ nitedev->mem[nitedev->addr] = val;
+ } else {
+ pci_default_write_config(dev, addr, val, len);
+ }
+}

First, reading other write-ups, I managed to quickly identify the target device and the resource that belongs to the NiteCTF PCI Device. This can be be done using lspci.

Once we identify the right device, we should see that the device_id 0x6969 matches whats written in the code below.

+static void nite_pci_class_init(ObjectClass *klass, void *data)
+{
+ DeviceClass *dc = DEVICE_CLASS(klass);
+ PCIDeviceClass *k = PCI_DEVICE_CLASS(klass);
+
+ k->config_write = nite_config_write;
+ k->config_read = nite_config_read;
+ k->realize = nite_realize;
+ k->vendor_id = 0x6969;
+ k->device_id = 0x6969;
+ k->revision = 0x00;
+ k->class_id = PCI_CLASS_OTHERS;
+ dc->desc = "NiteCTF 2024 - Just a PCI device :D";
+}

The next part to reach the nite_config_read and nite_config_write in the snippet above is somewhat different from most other CTFs.

This is because the function is related to the config file. Hence, to reach these functions, we have to write to the resource config file instead.

Subsequently, the bug in nite_config_write/read is actually fairly straightforward:

+struct PCINiteDevState {
+ /*< private >*/
+ PCIDevice parent_obj;
+
+ /*< public >*/
+ SomeObjectState obj1;
+ int32_t mem[32];
+ int32_t addr;
+};

+static void nite_config_write(PCIDevice *dev,
+ uint32_t addr, uint32_t val, int len)
+{
+ PCINiteDevState *nitedev = NITE_PCI_DEV(dev);
+
+ if (addr == 0xe0) {
+ nitedev->addr = val;
+ } else if (addr == 0xe4) {
+ if (nitedev->addr >= 32)
+ return;
+ nitedev->mem[nitedev->addr] = val;
+ } else {
+ pci_default_write_config(dev, addr, val, len);
+ }
+}
  • Notice that the PCINiteDevState struct uses int32_t for addr field
  • It has a mem array of size 32
  • When addr is 0xE0, we set the addr
  • When addr is 0xE4, we do nitedev->mem[nitedev->addr]
  • When addr is 0xE4, nite_config_write checks if addr is ≥ 32 and within bounds of the mem array. This is a signed check, hence addr can be set to negative!!

The bug is pretty straightward, and is both in the nite_config_read too. Now lets check out how to exploit it!

Overview:

Bypassing ASLR is pretty straightforward and can be done using nite_config_read .

We also have OOB write and can write backwards, however this is not enough to get Code Execution. So I first tried to get arbitrary write. Getting it is slightly more complicated, but not too difficult.

I first looked at the PCI Device fields I can hijack.

pwndbg> dt "struct PCIDevice" $rdi
struct PCIDevice @ 0x7ffff913bb10
0x00007ffff913bb10 +0x0000 qdev : DeviceState
0x00007ffff913bbb0 +0x00a0 partially_hotplugged : _Bool
0x00007ffff913bbb1 +0x00a1 has_power : _Bool
0x00007ffff913bbb8 +0x00a8 config : uint8_t *
0x00007ffff913bbc0 +0x00b0 cmask : uint8_t *
0x00007ffff913bbc8 +0x00b8 wmask : uint8_t *
0x00007ffff913bbd0 +0x00c0 w1cmask : uint8_t *

...

The config, cmask, wmask looks interesting, since they are pointers. Hijacking pointers will likely give us arbitrary write. Lets see how they are used:

void pci_default_write_config(PCIDevice *d, uint32_t addr, uint32_t val_in, int l)
{
int i, was_irq_disabled = pci_irq_disabled(d);
uint32_t val = val_in;

assert(addr + l <= pci_config_size(d));

// write one byte
for (i = 0; i < l; val >>= 8, ++i) {
uint8_t wmask = d->wmask[addr + i]; // controlled
uint8_t w1cmask = d->w1cmask[addr + i]; // controlled
assert(!(wmask & w1cmask));

// set wmask to 0xFF
d->config[addr + i] = (d->config[addr + i] & ~wmask) | (val & wmask);

// set w1cmask to 0
d->config[addr + i] &= ~(val & w1cmask); /* W1C: Write 1 to Clear */
}


...

}

The pci_default_write_config is one of the path that is reached when writing to an addr other than 0xE0 and 0xE4. Reading the code snippet above, we see that the write uses wmask and w1cmask, and that if wmask is set to 0xFF and w1cmask set to 0, we can get arbitrary write!

Now the plan is to write underflow, overwrite wmask and w1cmask to point to my mem array, set my mem array contents to 0x00000000 and 0xFFFFFFFFF, and we get arbitrary write!!!

Finally, the last part on Code Execution. Reading some more online write-ups, I noticed that we can overwrite the read and write handlers.

pwndbg> dt "struct PCIDevice" $rdi
struct PCIDevice @ 0x7ffff913bb10
0x00007ffff913bb10 +0x0000 qdev : DeviceState
0x00007ffff913bbb0 +0x00a0 partially_hotplugged : _Bool
0x00007ffff913bbb1 +0x00a1 has_power : _Bool
0x00007ffff913bbb8 +0x00a8 config : uint8_t *
0x00007ffff913bbc0 +0x00b0 cmask : uint8_t *
0x00007ffff913bbc8 +0x00b8 wmask : uint8_t *
0x00007ffff913bbd0 +0x00c0 w1cmask : uint8_t *
0x00007ffff913bbd8 +0x00c8 used : uint8_t *

...

0x00007ffff913c010 +0x0500 config_read : PCIConfigReadFunc *
0x00007ffff913c018 +0x0508 config_write : PCIConfigWriteFunc *

...

By overwriting config_read, we can get RIP control.

Thankfully, there is also an RWX area in qemu memory mappings.

pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x7fff8c000000 0x7fff8c021000 rw-p 21000 0 [anon_7fff8c000]
0x7fff8c021000 0x7fff90000000 ---p 3fdf000 0 [anon_7fff8c021]
0x7fff94000000 0x7fff95146000 rw-p 1146000 0 [anon_7fff94000]
0x7fff95146000 0x7fff98000000 ---p 2eba000 0 [anon_7fff95146]
0x7fff99800000 0x7fff99801000 ---p 1000 0 [anon_7fff99800]
0x7fff99801000 0x7fff9a001000 rw-p 800000 0 [anon_7fff99801]
0x7fff9a200000 0x7fffaa200000 rw-p 10000000 0 [anon_7fff9a200]
0x7fffaa200000 0x7fffaa201000 ---p 1000 0 [anon_7fffaa200]
0x7fffaa400000 0x7fffe7ed1000 rwxp 3dad1000 0 [anon_7fffaa400]
0x7fffe7ed1000 0x7fffe7ed2000 ---p 1000 0 [anon_7fffe7ed1]
0x7fffe8000000 0x7fffe8021000 rw-p 21000 0 [anon_7fffe8000]
0x7fffe8021000 0x7fffec000000 ---p 3fdf000 0 [anon_7fffe8021]
0x7fffec800000 0x7fffeca01000 rw-p 201000 0 [anon_7fffec800]
0x7fffecc00000 0x7fffecc01000 rw-p 1000 0 [anon_7fffecc00]
0x7fffecc01000 0x7fffecc02000 ---p 1000 0 [anon_7fffecc01]
0x7fffed000000 0x7fffed010000 rw-p 10000 0 [anon_7fffed000]
0x7fffed010000 0x7fffed011000 ---p 1000 0 [anon_7fffed010]
0x7fffed400000 0x7fffed600000 rw-p 200000 0 [anon_7fffed400]

Hence, the final part is to use arbitrary write to put our shellcode in the RWX area, and there we go, we got a working qemu escape.

PWNED!

Note:

  1. The offset of remote differs slightly, so I ran a pattern scanner to find the address of RWX area.

--

--

No responses yet