From ab045778369a88e7cc4240df409f2585cab4f685 Mon Sep 17 00:00:00 2001 From: Per Buer Date: Sun, 2 Aug 2026 09:43:57 +0200 Subject: [PATCH] fuzz: add a target for the ELF symbol-resolution paths fuzz.cpp drives the loader only. section_by_name(), resolve_symbol() and resolve() hang off AddressOf() / address_of() / resolve(), which the loader never calls, so those three have had zero fuzz coverage -- despite resolve() running on every guest fault, since handle_exception() calls it to symbolise the faulting address. An adversarial ELF reaches the section and symbol walkers with nothing more than a guest that crashes on purpose. elfsymfuzzer calls all three with an input-derived symbol name and rip. Two things about the harness are load-bearing: - The image is copied into an aligned buffer rather than pointed at in place. machine_elf.cpp casts the base pointer straight to Elf64_Ehdr*, and libFuzzer's input is not 8-aligned once the name/rip header is stripped, so every single input would otherwise report misaligned access under UBSan and bury everything else. - resolve() formats into a fixed 2048-byte stack buffer, so a returned string at least that long means it copied past the end. That needs an explicit assertion: the copy compiles to an inline rep movsb, which ASan does not instrument, so a moderate over-read runs clean. gen_elf_symbol_seeds.py writes nine minimal ELFs, each with one poisoned header field, because bit-flipping a valid binary essentially never produces an e_shnum past EOF or an offset that wraps. Two details worth keeping if these are edited: the searched section must be *absent* or section_by_name() returns on the match long before it walks off the end, and .symtab must be 8-aligned or UBSan reports the cast before the loop reaches the bug the seed is aiming at. Against the current tree the seeds produce, all within a couple of seconds: shnum_huge SEGV machine_elf.cpp:251 (#100) shstrndx_oob SEGV machine_elf.cpp:246 (#100) stname_wild SEGV machine_elf.cpp:278 (#100) symtab_size_huge heap-buffer-overflow machine_elf.cpp:328 (#100) symbol_name_4k stack-buffer-overflow machine_elf.cpp:303 (#100) symtab_misaligned UBSan misaligned access machine_elf.cpp:277 valid, header_only, shoff_wrap clean symtab_misaligned is not part of #100: resolve_symbol() casts a file offset to Elf64_Sym* with no alignment check, so the ELF author picks the alignment. Benign on x86-64, a fault on strict-alignment targets -- it is the one seed here whose value is realised by running this target under UBSan on AArch64. Co-Authored-By: Claude Fable 5 --- fuzz/CMakeLists.txt | 5 +- fuzz/elf_symbols_fuzz.cpp | 134 +++++++++++++++++++++++++++ fuzz/gen_elf_symbol_seeds.py | 169 +++++++++++++++++++++++++++++++++++ 3 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 fuzz/elf_symbols_fuzz.cpp create mode 100755 fuzz/gen_elf_symbol_seeds.py diff --git a/fuzz/CMakeLists.txt b/fuzz/CMakeLists.txt index a96083e..03fd029 100644 --- a/fuzz/CMakeLists.txt +++ b/fuzz/CMakeLists.txt @@ -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 diff --git a/fuzz/elf_symbols_fuzz.cpp b/fuzz/elf_symbols_fuzz.cpp new file mode 100644 index 0000000..7fa3c92 --- /dev/null +++ b/fuzz/elf_symbols_fuzz.cpp @@ -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 +#include "helpers.cpp" + +#include +#include +#include +#include +#include + +/* 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(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 aligned; + aligned.assign(data, data + len); + const std::string_view binary { + reinterpret_cast(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; +} diff --git a/fuzz/gen_elf_symbol_seeds.py b/fuzz/gen_elf_symbol_seeds.py new file mode 100755 index 0000000..bf0c03a --- /dev/null +++ b/fuzz/gen_elf_symbol_seeds.py @@ -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 +""" +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('