Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions fuzz/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ function(add_fuzzer NAME SOURCE MODE)
target_compile_definitions(${NAME} PRIVATE ${MODE}=1)
endfunction()

add_fuzzer(elffuzzer fuzz.cpp FUZZ_ELF)
add_fuzzer(syscallfuzzer syscall_fuzz.cpp FUZZ_SYSCALLS)
add_fuzzer(elffuzzer fuzz.cpp FUZZ_ELF)
add_fuzzer(elfsymfuzzer elf_symbols_fuzz.cpp FUZZ_ELF_SYMBOLS)
add_fuzzer(syscallfuzzer syscall_fuzz.cpp FUZZ_SYSCALLS)

# The syscall fuzzer needs a real, fully-initialized master VM to fork from,
# so it loads a small static guest ELF built here. It is an ordinary Linux
Expand Down
134 changes: 134 additions & 0 deletions fuzz/elf_symbols_fuzz.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Coverage-guided fuzzer for the ELF *symbol* paths in machine_elf.cpp.
*
* fuzz.cpp already fuzzes the loader (is_dynamic_elf, elf_load_ph) by handing
* malformed bytes to Machine::reset_to(). It never reaches section_by_name(),
* resolve_symbol() or resolve(), because nothing calls them: they hang off
* AddressOf() / address_of() / resolve(), which the loader does not use.
*
* That is not a niche corner. resolve() runs on every guest fault --
* handle_exception() calls it to symbolise the faulting address
* (vcpu_run.cpp) -- so an adversarial ELF reaches the section and symbol
* table walkers with nothing more than a guest that crashes on purpose.
* Embedders reach the same code directly through AddressOf().
*
* This target calls all three on the fuzzer's bytes:
*
* Machine::AddressOf(name, binary) static, no Machine needed
* machine.address_of(name, binary) instance form
* machine.resolve(rip, binary) with an input-derived rip
*
* The symbol name and rip are taken from the head of the input so the fuzzer
* can steer them, and the remainder is the ELF image. Names matter: the
* walkers strcmp() against the string table, so a name that matches a
* (possibly out-of-bounds) entry gets further than a random one.
*
* Findings here are host-memory reads driven by header fields -- out-of-range
* e_shnum/e_shstrndx/sh_name/sh_size/st_name, and offset+size sums that wrap
* 2^64. Note that ASan does not catch all of them: std::string's large copy
* compiles to an inline rep movsb, which is not instrumented, so an over-read
* of a few hundred bytes runs clean. The length assertion below is there for
* exactly that case.
*/
#include <tinykvm/machine.hpp>
#include "helpers.cpp"

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>

/* NB: deliberately no __asan_on_error()/abort() hook, unlike fuzz.cpp.
Aborting from that callback runs before the sanitizer prints its report, so
the finding arrives as a bare DEADLYSIGNAL with no stack trace. Use
ASAN_OPTIONS=abort_on_error=1 when a coredump is wanted. */

static const tinykvm::MachineOptions options {};
static tinykvm::Machine* machine = nullptr;

/* resolve() formats into a fixed 2048-byte stack buffer. Nothing it returns
can legitimately be longer, whatever the ELF says, so a longer result means
it copied past the end of that buffer. */
static constexpr size_t RESOLVE_BUFFER = 2048;

static void check_resolved(const std::string& s, const char* who)
{
if (s.size() >= RESOLVE_BUFFER) {
fprintf(stderr, "elf_symbols_fuzz: %s returned %zu bytes, "
"but it formats into a %zu-byte buffer\n",
who, s.size(), RESOLVE_BUFFER);
abort();
}
}

extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t len)
{
if (machine == nullptr) {
tinykvm::Machine::init();
machine = new tinykvm::Machine { std::string_view{}, options };
machine->install_unhandled_syscall_handler([](auto&, unsigned) {});
}

/* Header: rip:u64, name_len:u8, name[name_len]. Everything after it is
the ELF image. A truncated input just yields an empty name and a short
image, which is a fine thing to fuzz too. */
if (len < sizeof(uint64_t) + 1)
return 0;

uint64_t rip = 0;
memcpy(&rip, data, sizeof(rip));
data += sizeof(rip);
len -= sizeof(rip);

size_t name_len = *data++;
len--;
if (name_len > len)
name_len = len;
const std::string name(reinterpret_cast<const char*>(data), name_len);
data += name_len;
len -= name_len;

/* The image must be copied into a properly aligned buffer, not pointed at
in place. machine_elf.cpp casts the base pointer straight to
Elf64_Ehdr*, and libFuzzer's input is not 8-byte aligned once the header
above has been stripped off it -- every single input would report
misaligned access under UBSan and drown out real findings. Real callers
pass a std::vector, which is aligned. */
static std::vector<uint8_t> aligned;
aligned.assign(data, data + len);
const std::string_view binary {
reinterpret_cast<const char*>(aligned.data()), aligned.size() };

/* Every one of these is documented to throw on a malformed image. Any
other exception type, or none at all when the image is nonsense, is
what we are looking for -- along with whatever the sanitizers see. */
try {
tinykvm::Machine::AddressOf(name, binary);
} catch (const std::exception&) {
}

try {
machine->address_of(name, binary);
} catch (const std::exception&) {
}

try {
check_resolved(machine->resolve(rip, binary), "resolve(rip)");
} catch (const std::exception&) {
}

/* An address derived from the image itself is far more likely to land
inside a symbol's range than a uniformly random one, which is what
drives resolve() down the symtab path rather than straight out. */
if (len >= sizeof(uint64_t)) {
uint64_t inner = 0;
memcpy(&inner, data, sizeof(inner));
try {
check_resolved(machine->resolve(inner, binary), "resolve(inner)");
} catch (const std::exception&) {
}
}

return 0;
}
169 changes: 169 additions & 0 deletions fuzz/gen_elf_symbol_seeds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Seed corpus for elfsymfuzzer.

The bugs this target is aimed at need specific header field values -- an
e_shnum past the end of the file, a symtab sh_size larger than the image, an
offset that wraps 2^64 -- and bit-flipping a valid ELF essentially never
produces them. Each seed here is a minimal 64-bit ELF with exactly one such
field poisoned.

Input format matches the harness: rip:u64, name_len:u8, name, then the image.

Scope note: the phdr-side wraps in is_dynamic_elf()/elf_load_ph belong to the
elffuzzer target, which drives the loader. This target never calls them, so
seeds for them would be dead weight here.

./gen_elf_symbol_seeds.py <outdir>
"""
import os
import struct
import sys

EHDR_SIZE = 64
SHDR_SIZE = 64
SYM_SIZE = 24


def ehdr(e_shoff, e_shnum, e_shstrndx):
return b''.join([
b'\x7fELF\x02\x01\x01\x00', b'\x00' * 8, # e_ident
struct.pack('<H', 2), # e_type = ET_EXEC
struct.pack('<H', 0x3e), # e_machine = x86-64
struct.pack('<I', 1), # e_version
struct.pack('<Q', 0x401000), # e_entry
struct.pack('<Q', 0), # e_phoff
struct.pack('<Q', e_shoff),
struct.pack('<I', 0), # e_flags
struct.pack('<H', EHDR_SIZE),
struct.pack('<H', 56), struct.pack('<H', 0),
struct.pack('<H', SHDR_SIZE), struct.pack('<H', e_shnum),
struct.pack('<H', e_shstrndx),
])


def shdr(name, sh_type, offset, size, entsize=0, link=0, addr=0):
return struct.pack('<IIQQQQIIQQ', name, sh_type, 0, addr, offset, size,
link, 0, 1, entsize)


def pad8(blob):
"""Real linkers 8-align .symtab. An unaligned one makes the Elf64_Sym cast
in resolve_symbol() UB, which UBSan reports before the loop can reach the
bug a seed is actually aiming at -- so align deliberately, and leave the
misalignment to the one seed below that targets it on purpose."""
return blob + b'\x00' * (-len(blob) % 8)


def seed(rip, name, image):
nb = name.encode()
return struct.pack('<Q', rip) + bytes([len(nb)]) + nb + image


def seeds():
out = {}

# A plausible, well-formed-ish baseline: shstrtab plus a symtab/strtab
# pair with one symbol. Mutation has somewhere sane to start from.
names = pad8(b'\x00.shstrtab\x00.symtab\x00.strtab\x00')
syms = struct.pack('<IBBHQQ', 1, 0x12, 0, 1, 0x401000, 0x20)
strs = b'\x00main\x00'
base = EHDR_SIZE + 4 * SHDR_SIZE
o_names, o_syms, o_strs = base, base + len(names), base + len(names) + len(syms)
shdrs = [
shdr(0, 0, 0, 0),
shdr(1, 3, o_names, len(names)), # .shstrtab
shdr(11, 2, o_syms, len(syms), SYM_SIZE, link=3), # .symtab
shdr(19, 3, o_strs, len(strs)), # .strtab
]
valid = ehdr(EHDR_SIZE, 4, 1) + b''.join(shdrs) + names + syms + strs
out['valid'] = seed(0x401000, 'main', valid)

# e_shnum far past the end: the walker indexes shdr[i] off the end of the
# image, then chases sh_name into unmapped memory.
#
# The searched section must genuinely be absent, or the loop returns on the
# match long before it runs off the end. section_by_name() is only ever
# called for ".symtab" and ".strtab", so the table here holds neither.
stub_names = b'\x00.shstrtab\x00'
stub = [shdr(0, 0, 0, 0), shdr(1, 3, EHDR_SIZE + 2 * SHDR_SIZE, len(stub_names))]
out['shnum_huge'] = seed(0x401000, 'main',
ehdr(EHDR_SIZE, 0xFFFF, 1) + b''.join(stub) + stub_names)

# e_shstrndx past e_shnum: the string table header itself is out of range,
# so `strings` is already a wild pointer before the loop even starts.
out['shstrndx_oob'] = seed(0x401000, 'main',
ehdr(EHDR_SIZE, 2, 0xFFFE) + b''.join(stub) + stub_names)

# e_shoff + e_shnum * 64 wraps 2^64.
out['shoff_wrap'] = seed(0x401000, 'main',
ehdr(0xFFFFFFFFFFFFFF00, 0x400, 1) + b''.join(shdrs))

# .symtab claims 16MB inside a 24-byte file: the symbol loop runs off the
# end, and strcmp() follows each st_name.
big = list(shdrs)
big[2] = shdr(11, 2, o_syms, 0x1000000, SYM_SIZE, link=3)
out['symtab_size_huge'] = seed(0x401000, 'main',
ehdr(EHDR_SIZE, 4, 1) + b''.join(big)
+ names + syms + strs)

# st_name far outside .strtab: &strtab[st_name] is a wild pointer, which
# snprintf("%s") then reads as a string.
wild = struct.pack('<IBBHQQ', 0xFFFFF0, 0x12, 0, 1, 0x401000, 0x20)
out['stname_wild'] = seed(0x401000, 'main',
ehdr(EHDR_SIZE, 4, 1) + b''.join(shdrs)
+ names + wild + strs)

# A symbol name longer than resolve()'s 2048-byte format buffer, which is
# what makes snprintf report a would-be length past the end.
long_name = b'\x00' + b'A' * 4096 + b'\x00'
lsyms = struct.pack('<IBBHQQ', 1, 0x12, 0, 1, 0x401000, 0x2000)
lbase = EHDR_SIZE + 4 * SHDR_SIZE
lo_n, lo_sy, lo_st = lbase, lbase + len(names), lbase + len(names) + len(lsyms)
lshdrs = [
shdr(0, 0, 0, 0),
shdr(1, 3, lo_n, len(names)),
shdr(11, 2, lo_sy, len(lsyms), SYM_SIZE, link=3),
shdr(19, 3, lo_st, len(long_name)),
]
out['symbol_name_4k'] = seed(0x401800, 'A' * 64,
ehdr(EHDR_SIZE, 4, 1) + b''.join(lshdrs)
+ names + lsyms + long_name)

# Truncated to just the ELF header: everything downstream is out of range.
out['header_only'] = seed(0x401000, 'main', ehdr(EHDR_SIZE, 4, 1))

# .symtab at a deliberately odd offset. resolve_symbol() casts the file
# offset straight to Elf64_Sym* with no alignment check, so this is UB the
# ELF author chooses. Benign on x86-64, a fault on strict-alignment
# targets -- UBSan on AArch64 is where this one matters.
mis_names = b'\x00.shstrtab\x00.symtab\x00.strtab\x00\x00' # length 28: odd
m_base = EHDR_SIZE + 4 * SHDR_SIZE
m_n, m_sy, m_st = m_base, m_base + len(mis_names), m_base + len(mis_names) + len(syms)
assert m_sy % 8 != 0, 'the point of this seed is a misaligned .symtab'
mshdrs = [
shdr(0, 0, 0, 0),
shdr(1, 3, m_n, len(mis_names)),
shdr(11, 2, m_sy, len(syms), SYM_SIZE, link=3),
shdr(19, 3, m_st, len(strs)),
]
out['symtab_misaligned'] = seed(0x401000, 'main',
ehdr(EHDR_SIZE, 4, 1) + b''.join(mshdrs)
+ mis_names + syms + strs)

return out


def main():
if len(sys.argv) != 2:
sys.exit(__doc__)
outdir = sys.argv[1]
os.makedirs(outdir, exist_ok=True)
written = seeds()
for name, blob in written.items():
with open(os.path.join(outdir, name), 'wb') as f:
f.write(blob)
print(f'wrote {len(written)} seeds to {outdir}')


if __name__ == '__main__':
main()
Loading