From 75e57a4b19b09c4ff6f5c54ecd178426fde3bf56 Mon Sep 17 00:00:00 2001 From: Mathieu Tarral Date: Sun, 9 Aug 2026 01:38:30 -0700 Subject: [PATCH 1/2] harness: fix ARM32 HARNESS_START macros missing DEFAULT_INDEX argument tsffs-gcc-arm32.h's HARNESS_START, HARNESS_START_WITH_MAXIMUM_SIZE, and HARNESS_START_WITH_MAXIMUM_SIZE_AND_PTR call __orr_extended2/__orr_extended3 without the DEFAULT_INDEX argument that every other architecture's header passes (compare tsffs-gcc-x86.h, tsffs-gcc-aarch64.h, tsffs-gcc-riscv32.h, which all pass it explicitly as the first pseudo-argument). TSFFS's ARM harness support (src/arch/arm.rs) expects r10 to hold the harness index and the actual arguments in r9/r8. Without DEFAULT_INDEX, the buffer pointer lands in r10 instead, which the fuzzer reads as a (bogus) harness index - the start harness is silently never recognized, logged only as a debug-level "index is not configured" message. Found and confirmed via two independent bare-metal ARM32 targets on the AST2600 arm-cortex-a7 core in Simics: a synthetic smoke-test target, and a compiled-in harness for openbmc/libpldm's decode_pldm_firmware_update_package(). Both silently produced zero fuzzing activity until this fix was applied locally; after the fix, both fired correctly and ran full fuzzing campaigns (crash detection confirmed working end to end). HARNESS_STOP and HARNESS_ASSERT already pass DEFAULT_INDEX correctly via __orr/__orr_extended1 and are left unchanged. --- harness/tsffs-gcc-arm32.h | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/harness/tsffs-gcc-arm32.h b/harness/tsffs-gcc-arm32.h index 1ce4f932..8912badb 100644 --- a/harness/tsffs-gcc-arm32.h +++ b/harness/tsffs-gcc-arm32.h @@ -151,9 +151,10 @@ /// size_t size; /// HARNESS_START(buffer, &size); /// ``` -#define HARNESS_START(buffer, size_ptr) \ - do { \ - __orr_extended2(N_START_BUFFER_PTR_SIZE_PTR, buffer, size_ptr); \ +#define HARNESS_START(buffer, size_ptr) \ + do { \ + __orr_extended3(N_START_BUFFER_PTR_SIZE_PTR, DEFAULT_INDEX, buffer, \ + size_ptr); \ } while (0); /// Pseudo-hypercall number to signal the fuzzer to use the first argument to @@ -190,9 +191,10 @@ /// unsigned char buffer[1024]; /// HARNESS_START_WITH_MAXIMUM_SIZE(buffer, 1024); /// ``` -#define HARNESS_START_WITH_MAXIMUM_SIZE(buffer, max_size) \ - do { \ - __orr_extended2(N_START_BUFFER_PTR_SIZE_VAL, buffer, max_size); \ +#define HARNESS_START_WITH_MAXIMUM_SIZE(buffer, max_size) \ + do { \ + __orr_extended3(N_START_BUFFER_PTR_SIZE_VAL, DEFAULT_INDEX, buffer, \ + max_size); \ } while (0); /// Pseudo-hypercall number to signal the fuzzer to use the first argument to @@ -236,8 +238,8 @@ /// ``` #define HARNESS_START_WITH_MAXIMUM_SIZE_AND_PTR(buffer, size_ptr, max_size) \ do { \ - __orr_extended3(N_START_BUFFER_PTR_SIZE_PTR_VAL, buffer, size_ptr, \ - max_size); \ + __orr_extended4(N_START_BUFFER_PTR_SIZE_PTR_VAL, DEFAULT_INDEX, buffer, \ + size_ptr, max_size); \ } while (0); /// Pseudo-hypercall number to signal the fuzzer to stop the current fuzzing From 184b73103c6abbf514af15b2510c49d32fb635e9 Mon Sep 17 00:00:00 2001 From: Mathieu Tarral Date: Sun, 9 Aug 2026 02:21:59 -0700 Subject: [PATCH 2/2] docs: add a page on bare-metal and non-x86 compiled-in harnessing Adds docs/src/harnessing/bare-metal.md, covering the case of harnessing a single function (a parser or decoder, for example) that is normally part of a much larger system by compiling it into a small freestanding binary and loading it directly onto a CPU model, rather than booting the full system to reach it. Covers loading a binary directly via load-binary/set-pc, the minimal libc surface most parser/decoder code actually needs (memcpy/memcmp/ memset/__assert_fail) and how to find it via undefined-symbol inspection, a GCC ARM32 NEON-codegen gotcha that can make a harness look completely broken (every iteration faults identically on an Undefined Instruction that has nothing to do with the code under test), and querying architecture-specific exception numbers via list-exceptions rather than assuming values across CPU classes. Generalized from getting a compiled-in harness for a real daemon codebase working on an ARM Cortex-A7 core outside its native OS - none of that target-specific detail is included here, just the technique. Wired into docs/src/SUMMARY.md and docs/src/harnessing/README.md right after the existing compiled-in harnessing page, since this is a variant of that approach rather than a separate one. --- docs/src/SUMMARY.md | 1 + docs/src/harnessing/README.md | 1 + docs/src/harnessing/bare-metal.md | 142 ++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 docs/src/harnessing/bare-metal.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 31435bf4..3f5cf6ad 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,7 @@ - [Common Options](config/common-options.md) - [Harnessing Fuzzing Targets](harnessing/README.md) - [Using a Compiled-in Harness](harnessing/compiled-in.md) + - [Bare-Metal and Non-x86 Harnessing](harnessing/bare-metal.md) - [Using Closed-Box Testcase Injection](harnessing/closed-box.md) - [Using Testcase Data Manually](harnessing/manual.md) - [Running A Fuzzing Campaign](fuzzing/README.md) diff --git a/docs/src/harnessing/README.md b/docs/src/harnessing/README.md index dd5deb28..ab1cb767 100644 --- a/docs/src/harnessing/README.md +++ b/docs/src/harnessing/README.md @@ -8,5 +8,6 @@ not, try injecting testcases into its memory directly, and if this is still not or not appropriate for your use case, the fully-manual approach can be used. - [Using Compiled-In Harnesses](compiled-in.md) +- [Bare-Metal and Non-x86 Harnessing](bare-metal.md) - [Closed-Box Testcase Injection](closed-box.md) - [Manual Testcase Injection](manual.md) \ No newline at end of file diff --git a/docs/src/harnessing/bare-metal.md b/docs/src/harnessing/bare-metal.md new file mode 100644 index 00000000..4d3d9a6e --- /dev/null +++ b/docs/src/harnessing/bare-metal.md @@ -0,0 +1,142 @@ +# Bare-Metal and Non-x86 Compiled-In Harnessing + +- [Bare-Metal and Non-x86 Compiled-In Harnessing](#bare-metal-and-non-x86-compiled-in-harnessing) + - [Loading a Binary Directly Onto a CPU Model](#loading-a-binary-directly-onto-a-cpu-model) + - [Providing a Minimal Runtime](#providing-a-minimal-runtime) + - [Cross-Compiler Codegen Gotchas](#cross-compiler-codegen-gotchas) + - [Checking Which ARM Exceptions to Configure](#checking-which-arm-exceptions-to-configure) + +This page covers a specific but common case: harnessing a small piece of C +code (a single parser or decoder function, for example) that is normally +part of a much larger piece of software (an RTOS image, a full Linux +userspace daemon, a bootloader) which would be slow or inconvenient to boot +just to reach it. Rather than booting that larger system, this approach +compiles the function under test, plus a small custom entry point, into a +minimal freestanding binary that is loaded directly onto a CPU model and run +with no OS underneath it at all. + +This is a variant of [compiled-in harnessing](compiled-in.md): the harness +macros and their behavior are unchanged, the difference is entirely in how +the harnessed binary gets onto the target and what runtime support it has +available. + +## Loading a Binary Directly Onto a CPU Model + +Instead of the CPU model going through its normal reset/firmware-load path, +the harnessed binary can be loaded directly into memory and the CPU's +program counter pointed at it. On many Simics CPU models, this looks like: + +```simics +stop + +# If the CPU model's own boot/reset sequencing left a pending exception +# queued (common for models that come out of a board-level reset +# component), step once to retire it before overriding PC below - otherwise +# the first instruction at the injected entry point can be swallowed by the +# stale exception instead of executing. +.force-step-instruction 1 + +$entry = (.load-binary "/path/to/harness.elf") +.set-pc $entry + +run +``` + +`load-binary` reads the entry point and segment load addresses from the +ELF header, so as long as the binary's linker script places it at a valid, +mapped address for the target (e.g. the base of on-chip RAM), no other setup +is required. This works for any CPU architecture Simics models, not just +the ones with dedicated compiled-in harness headers in `harness/`: write a +small assembly entry stub for the target's calling convention and reset +behavior, and the same technique applies. + +## Providing a Minimal Runtime + +A freestanding binary has no OS underneath it, so anything the harnessed +code depends on from libc must be supplied by hand. Before writing a custom +runtime, check what's actually needed: compiling the target function with +`-ffreestanding -fno-builtin` and inspecting undefined symbols in the +resulting object file (`nm` on Linux, or the equivalent for other +toolchains) is a fast way to find out. Parsers and decoders in particular +often only need a handful of the smallest libc functions: + +* `memcpy` / `memcmp` / `memset`: trivial byte-loop implementations are + sufficient; there's no need to reach for an optimized libc implementation + in a fuzzing harness. +* `__assert_fail`: needed if the target code uses `assert()`. A minimal + implementation can call `HARNESS_ASSERT()` and then loop forever (the + fuzzer will restore a snapshot from `HARNESS_START` before the loop is + ever actually reached at runtime). +* Any target-specific weak symbols the code calls for real hardware + interaction (checksum validation, hardware-specific timing, etc.) that + are irrelevant to the logic under test: override them with trivial + stubs, the same way a normal unit test would mock them out. + +A minimal entry point then looks like: + +```c +#include "tsffs.h" + +static uint8_t testcase[MAX_TESTCASE_SIZE]; +static size_t testcase_size; + +void _start(void) { + for (;;) { + testcase_size = sizeof(testcase); + HARNESS_START(testcase, &testcase_size); + + function_under_test(testcase, testcase_size); + + HARNESS_STOP(); + } +} +``` + +linked with a linker script that places `.text`/`.data`/`.bss` at the +target's RAM base and reserves a small stack, and a short assembly stub +(`_reset`) that sets the stack pointer before branching to `_start`. The +CPU model's reset/entry conventions determine exactly what this stub needs +to do, but on most architectures it is only a few instructions. + +## Cross-Compiler Codegen Gotchas + +Freestanding code compiled at low optimization levels can still trigger +codegen a target CPU model doesn't support, in ways that have nothing to do +with the actual logic under test. A notable case: cross-compilers may +default to a hard-float ABI, which permits emitting vector/FPU instructions +(e.g. ARM NEON) for plain operations like zero-initializing a struct +(`struct foo x = {0};`), even in code that never touches floating-point +data. If the target CPU model doesn't implement those instructions, this +manifests as an Undefined Instruction exception on *every single* fuzzing +iteration, which looks identical to a genuinely broken harness. Check the +exact instruction address a crash actually happens at (a disassembly of the +harness binary makes this fast) before assuming a fuzzer, harness, or model +configuration problem. + +For GCC ARM32 targets, disabling autovectorization is usually enough to +avoid this without otherwise changing codegen: + +```sh +arm-linux-gnueabihf-gcc -ffreestanding -fno-builtin \ + -fno-tree-vectorize -fno-tree-slp-vectorize \ + ... +``` + +## Checking Which ARM Exceptions to Configure + +`@tsffs.exceptions` takes CPU exception numbers, which are +architecture-specific. For ARM cores, query the exact numbers from the CPU +model itself rather than assuming values from another architecture or +another ARM core class: + +```simics +simics> .list-exceptions +``` + +The Data Abort, Prefetch Abort, and Undefined Instruction exceptions are +the rough ARM equivalents of x86's Page Fault and General Protection Fault, +and are a reasonable starting set for most memory-safety bugs: + +```python +@tsffs.exceptions = [, , ] +```