From 954e9043cc19690fd8696cb399a9da563a27a4c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 06:14:05 +0200 Subject: [PATCH 01/14] test: add libFuzzer harness for the serial console command interface Fuzz the real firmware console end to end: fuzz bytes are fed through getchar_timeout_us() into the untouched console_task() char accumulator, process_line() tokeniser and commands_dispatch(), reaching the real cmd_* handlers and storage.c/backup.c on a genuine littlefs backed by a RAM-mapped XIP window (the harness_storage.c trick). Only the hardware and network leaves (buzzer/latch/led/light/wifi/ntp/USB/flash/RNG/clock) are stubbed; cmd_reboot's post-watchdog spin is escaped with longjmp so the fuzzer never hangs. State (flash window, admin flag, console input) is reset per input for deterministic reproduction. A `make -C test fuzz` target builds it with clang+libFuzzer+ASan+UBSan (kept separate from the gcc asan/valgrind/coverage gates); `fuzz-run` drives a bounded campaign from test/fuzz_corpus/ seed lines. No crash found across ~360k executions (cov 707 edges); clean result. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 45 ++++++++- test/fuzz_console.c | 199 +++++++++++++++++++++++++++++++++++++ test/fuzz_corpus/addkey | 3 + test/fuzz_corpus/export | 2 + test/fuzz_corpus/gettime | 1 + test/fuzz_corpus/help | 1 + test/fuzz_corpus/listkeys | 2 + test/fuzz_corpus/login | 1 + test/fuzz_corpus/logout | 1 + test/fuzz_corpus/qmark | 1 + test/fuzz_corpus/roundtrip | 4 + test/fuzz_corpus/secret | 2 + test/fuzz_corpus/setwifi | 3 + test/fuzz_corpus/status | 1 + test/fuzz_corpus/test | 1 + 15 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 test/fuzz_console.c create mode 100644 test/fuzz_corpus/addkey create mode 100644 test/fuzz_corpus/export create mode 100644 test/fuzz_corpus/gettime create mode 100644 test/fuzz_corpus/help create mode 100644 test/fuzz_corpus/listkeys create mode 100644 test/fuzz_corpus/login create mode 100644 test/fuzz_corpus/logout create mode 100644 test/fuzz_corpus/qmark create mode 100644 test/fuzz_corpus/roundtrip create mode 100644 test/fuzz_corpus/secret create mode 100644 test/fuzz_corpus/setwifi create mode 100644 test/fuzz_corpus/status create mode 100644 test/fuzz_corpus/test diff --git a/test/Makefile b/test/Makefile index 8f70f28..f672ba4 100644 --- a/test/Makefile +++ b/test/Makefile @@ -110,7 +110,7 @@ LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov GENHTML_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,negative LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) -.PHONY: all asan valgrind coverage clean check-submodules +.PHONY: all asan valgrind coverage fuzz fuzz-run clean check-submodules all: asan @@ -230,6 +230,49 @@ coverage: check-submodules | $(COV_DIR)/obj $(LCOV) --summary $(COV_DIR)/coverage.info @echo "== coverage OK: report at $(COV_DIR)/html/index.html ==" +# --- libFuzzer console harness ---------------------------------------------- +# End-to-end fuzzing of the serial-console command interface: fuzz bytes flow +# through the REAL console_task() char accumulator -> process_line() tokeniser +# -> commands_dispatch() -> real cmd_* handlers -> storage.c/backup.c on a +# genuine littlefs backed by a RAM-mapped XIP window. Only hardware/network +# leaves are stubbed. See test/fuzz_console.c for the driver. +# +# libFuzzer + ASan + UBSan REQUIRE clang (with the sanitizer/fuzzer runtime), +# so this target is kept separate from the gcc-based asan/valgrind/coverage +# gates and never runs as part of `all`. Override the compiler with +# `make -C test fuzz FUZZ_CC=clang-19` if plain `clang` is unavailable. +FUZZ_CC ?= clang +FUZZ_BIN := $(BUILD)/fuzz_console +FUZZ_TIME ?= 60 +FUZZ_CORPUS := fuzz_corpus +FUZZ_FLAGS := -std=c11 -g -O1 -fsanitize=fuzzer,address,undefined \ + -fno-sanitize-recover=all +FUZZ_INCLUDES := -I$(ROOT) -I$(ROOT)/hardware -I$(ROOT)/network -I$(ROOT)/serial \ + -I$(ROOT)/storage -I$(SHARED) -I$(BASE32) -I$(BASE64) \ + -I$(QRCODEGEN) -I$(ROOT)/libs/littlefs -idirafter stub +FUZZ_SRCS := fuzz_console.c \ + $(ROOT)/serial/console.c $(ROOT)/serial/commands.c \ + $(ROOT)/serial/commands_system.c $(ROOT)/serial/commands_keys.c \ + $(ROOT)/serial/commands_network.c $(ROOT)/serial/commands_backup.c \ + $(ROOT)/storage/storage.c $(ROOT)/storage/backup.c \ + $(SHARED)/totp.c $(SHARED)/random.c \ + $(BASE32)/base32.c $(BASE64)/base64.c $(QRCODEGEN)/qrcodegen.c \ + $(ROOT)/libs/littlefs/lfs.c $(ROOT)/libs/littlefs/lfs_util.c +FUZZ_DEFS := -DLFS_NO_MALLOC -DLFS_NO_DEBUG +FUZZ_LIBS := -lmbedcrypto + +# Build the fuzzer only. +fuzz: check-submodules $(FUZZ_BIN) + +$(FUZZ_BIN): $(FUZZ_SRCS) | $(BUILD) + $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_SRCS) $(FUZZ_LIBS) -o $@ + +# Build + run a short bounded campaign from the seed corpus. Bump FUZZ_TIME for +# a longer soak: `make -C test fuzz-run FUZZ_TIME=600`. +fuzz-run: $(FUZZ_BIN) + @echo "== fuzzing serial console for $(FUZZ_TIME)s ==" + $(FUZZ_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 $(FUZZ_CORPUS) + $(BUILD): mkdir -p $(BUILD) diff --git a/test/fuzz_console.c b/test/fuzz_console.c new file mode 100644 index 0000000..803b8a5 --- /dev/null +++ b/test/fuzz_console.c @@ -0,0 +1,199 @@ +/* + * libFuzzer harness: the serial-console command interface, end to end. + * + * This drives the REAL firmware input path exactly as it runs on the RP2040: + * + * fuzz bytes -> getchar_timeout_us() -> console_task() char accumulator + * -> process_line() tokeniser -> commands_dispatch() + * -> real cmd_* handlers -> storage.c / backup.c on real littlefs + * + * console.c, commands.c and every cmd_* handler are the untouched first-party + * sources; only the hardware/network leaves (buzzer, latch, led, light, + * wifi, ntp, USB, flash, RNG, clock) are stubbed. Storage is backed by a + * RAM-mapped XIP window (the same trick as test/harness_storage.c), so key + * add/get/delete/list, wifi set/get and the base64 backup import/export code + * all execute for real against a genuine littlefs. + * + * Per input we reset all persistent state (flash window, admin flag, console + * input buffer) so a given input reproduces deterministically. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include + +#include "hardware/flash.h" /* XIP_BASE, FLASH_SECTOR_SIZE */ +#include "pico/stdlib.h" /* PICO_FLASH_SIZE_BYTES, PICO_OK, PICO_ERROR_TIMEOUT */ +#include "pico/unique_id.h" + +#include "console.h" +#include "storage/storage.h" + +/* admin_mode is a non-static extern in commands.c; reset it every run. */ +extern bool admin_mode; + +/* --- storage layout, mirrored from storage.c ------------------------------ */ + +#define STORAGE_SIZE_BYTES (256 * 1024) +#define STORAGE_FLASH_OFFSET (PICO_FLASH_SIZE_BYTES - STORAGE_SIZE_BYTES) +#define STORAGE_WINDOW_BASE ((uintptr_t)(XIP_BASE + STORAGE_FLASH_OFFSET)) + +static uint8_t *flash_ptr(uint32_t flash_offs) { + return (uint8_t *)(uintptr_t)(XIP_BASE + flash_offs); +} + +/* --- RAM-backed flash primitives storage.c drives ------------------------- */ + +void flash_range_program(uint32_t flash_offs, const uint8_t *data, size_t count) { + uint8_t *dst = flash_ptr(flash_offs); + for (size_t i = 0; i < count; i++) + dst[i] &= data[i]; /* NOR: programming can only clear bits */ +} + +void flash_range_erase(uint32_t flash_offs, size_t count) { + memset(flash_ptr(flash_offs), 0xFF, count); /* erased NOR reads as 0xFF */ +} + +int flash_safe_execute(void (*func)(void *), void *param, uint32_t timeout_ms) { + (void)timeout_ms; + func(param); + return PICO_OK; +} + +/* --- fuzz input source + USB connection state ----------------------------- */ + +static const uint8_t *g_data; +static size_t g_size; +static size_t g_pos; +static bool g_connected; + +bool stdio_usb_connected(void) { + return g_connected; +} + +int getchar_timeout_us(uint32_t timeout_us) { + (void)timeout_us; + if (g_pos >= g_size) + return PICO_ERROR_TIMEOUT; + return g_data[g_pos++]; +} + +/* --- hardware / network leaves: no-op stubs ------------------------------- */ + +void buzzer_play_command_ack(void) {} +void buzzer_play_auth_error(void) {} +void buzzer_beep_short(void) {} + +void latch_open(void) {} +void light_on(void) {} +void light_off(void) {} +void led_on(void) {} +void led_off(void) {} + +void sleep_ms(uint32_t ms) { + (void)ms; /* cmd_test would sleep 1s on hardware; skip it */ +} + +uint64_t time_us_64(void) { + return 1234567890ULL; /* fixed uptime / boot-bypass clock */ +} + +uint32_t clock_get_unix_time(void) { + return 1700000000u; /* fixed, non-zero: RTC "set", enables the TOTP path */ +} + +bool ntp_is_synced(void) { return false; } +uint32_t ntp_last_sync_time(void) { return 0; } +bool ntp_sync(void) { return false; } + +bool wifi_connect(const char *ssid, const char *password) { + (void)ssid; + (void)password; + return false; +} + +void pico_get_unique_board_id(pico_unique_board_id_t *id_out) { + for (int i = 0; i < PICO_UNIQUE_BOARD_ID_SIZE_BYTES; i++) + id_out->id[i] = (uint8_t)(0xA0 + i); +} + +/* Deterministic RNG so add-key secret generation is reproducible. */ +uint64_t get_rand_64(void) { + static uint64_t s = 0x9E3779B97F4A7C15ULL; + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + return s; +} + +/* cmd_reboot calls watchdog_reboot() then spins in an infinite tight loop. + * On hardware the watchdog fires; on the host we longjmp back out so the run + * ends instead of hanging the fuzzer. */ +static jmp_buf g_reboot_jmp; + +void watchdog_reboot(uint32_t pc, uint32_t sp, uint32_t delay_ms) { + (void)pc; + (void)sp; + (void)delay_ms; + longjmp(g_reboot_jmp, 1); +} + +/* --- per-run reset -------------------------------------------------------- */ + +static void flash_ram_reset(void) { + memset((void *)STORAGE_WINDOW_BASE, 0xFF, STORAGE_SIZE_BYTES); +} + +int LLVMFuzzerInitialize(int *argc, char ***argv) { + (void)argc; + (void)argv; + + /* Map the XIP storage window into host RAM at its true address, once. */ + void *base = mmap((void *)STORAGE_WINDOW_BASE, STORAGE_SIZE_BYTES, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); + if (base == MAP_FAILED || (uintptr_t)base != STORAGE_WINDOW_BASE) { + perror("mmap XIP window"); + return -1; + } + + /* Silence all console output for throughput; keep stderr for sanitizers. */ + if (!freopen("/dev/null", "w", stdout)) { + perror("freopen stdout"); + return -1; + } + return 0; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + /* Reset persistent state so this input reproduces deterministically. */ + flash_ram_reset(); + if (!storage_init()) + return 0; /* format/mount failure is not an input-driven bug */ + admin_mode = false; + + g_data = data; + g_size = size; + g_pos = 0; + g_connected = true; + + if (setjmp(g_reboot_jmp) == 0) { + /* First call connects (prints banner, consumes no byte); subsequent + * calls each read at most one byte. Drive until the input is drained. */ + console_task(); /* connect */ + while (g_pos < g_size) + console_task(); + } + + /* Simulate USB disconnect: clears admin + zeroes the console input_len, + * so no console state leaks into the next run. */ + g_connected = false; + console_task(); + + return 0; +} diff --git a/test/fuzz_corpus/addkey b/test/fuzz_corpus/addkey new file mode 100644 index 0000000..9ae37ad --- /dev/null +++ b/test/fuzz_corpus/addkey @@ -0,0 +1,3 @@ +login 0 0 +add-key 1 door +get-key 1 diff --git a/test/fuzz_corpus/export b/test/fuzz_corpus/export new file mode 100644 index 0000000..3efee0c --- /dev/null +++ b/test/fuzz_corpus/export @@ -0,0 +1,2 @@ +login 0 0 +export-keys diff --git a/test/fuzz_corpus/gettime b/test/fuzz_corpus/gettime new file mode 100644 index 0000000..b8c7d87 --- /dev/null +++ b/test/fuzz_corpus/gettime @@ -0,0 +1 @@ +get-time diff --git a/test/fuzz_corpus/help b/test/fuzz_corpus/help new file mode 100644 index 0000000..a87bf43 --- /dev/null +++ b/test/fuzz_corpus/help @@ -0,0 +1 @@ +help diff --git a/test/fuzz_corpus/listkeys b/test/fuzz_corpus/listkeys new file mode 100644 index 0000000..19adb8c --- /dev/null +++ b/test/fuzz_corpus/listkeys @@ -0,0 +1,2 @@ +login 0 0 +list-keys diff --git a/test/fuzz_corpus/login b/test/fuzz_corpus/login new file mode 100644 index 0000000..91c8784 --- /dev/null +++ b/test/fuzz_corpus/login @@ -0,0 +1 @@ +login 1 123456 diff --git a/test/fuzz_corpus/logout b/test/fuzz_corpus/logout new file mode 100644 index 0000000..1b61006 --- /dev/null +++ b/test/fuzz_corpus/logout @@ -0,0 +1 @@ +logout diff --git a/test/fuzz_corpus/qmark b/test/fuzz_corpus/qmark new file mode 100644 index 0000000..a1e2647 --- /dev/null +++ b/test/fuzz_corpus/qmark @@ -0,0 +1 @@ +? diff --git a/test/fuzz_corpus/roundtrip b/test/fuzz_corpus/roundtrip new file mode 100644 index 0000000..2e9d574 --- /dev/null +++ b/test/fuzz_corpus/roundtrip @@ -0,0 +1,4 @@ +login 0 0 +add-key 5 x +export-keys +import-keys AAAAAA diff --git a/test/fuzz_corpus/secret b/test/fuzz_corpus/secret new file mode 100644 index 0000000..0eef5a3 --- /dev/null +++ b/test/fuzz_corpus/secret @@ -0,0 +1,2 @@ +login 0 0 +get-key-secret 1 diff --git a/test/fuzz_corpus/setwifi b/test/fuzz_corpus/setwifi new file mode 100644 index 0000000..2316d75 --- /dev/null +++ b/test/fuzz_corpus/setwifi @@ -0,0 +1,3 @@ +login 0 0 +set-wifi ssid pass +status diff --git a/test/fuzz_corpus/status b/test/fuzz_corpus/status new file mode 100644 index 0000000..8be5547 --- /dev/null +++ b/test/fuzz_corpus/status @@ -0,0 +1 @@ +status diff --git a/test/fuzz_corpus/test b/test/fuzz_corpus/test new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/test/fuzz_corpus/test @@ -0,0 +1 @@ +test From eb2b5bfbc8563eeba875d5fed4c803027df27eed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 06:15:26 +0200 Subject: [PATCH 02/14] test: clang-format fuzz_console.c empty stub bodies The empty no-op stub bodies were written as one-liners; the repo style (AllowShortFunctionsOnASingleLine: None) expands them. Local ./ci check skipped the file because git ls-files only sees tracked files and it was still untracked at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/fuzz_console.c | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/test/fuzz_console.c b/test/fuzz_console.c index 803b8a5..c56cdb8 100644 --- a/test/fuzz_console.c +++ b/test/fuzz_console.c @@ -86,15 +86,23 @@ int getchar_timeout_us(uint32_t timeout_us) { /* --- hardware / network leaves: no-op stubs ------------------------------- */ -void buzzer_play_command_ack(void) {} -void buzzer_play_auth_error(void) {} -void buzzer_beep_short(void) {} +void buzzer_play_command_ack(void) { +} +void buzzer_play_auth_error(void) { +} +void buzzer_beep_short(void) { +} -void latch_open(void) {} -void light_on(void) {} -void light_off(void) {} -void led_on(void) {} -void led_off(void) {} +void latch_open(void) { +} +void light_on(void) { +} +void light_off(void) { +} +void led_on(void) { +} +void led_off(void) { +} void sleep_ms(uint32_t ms) { (void)ms; /* cmd_test would sleep 1s on hardware; skip it */ @@ -108,9 +116,15 @@ uint32_t clock_get_unix_time(void) { return 1700000000u; /* fixed, non-zero: RTC "set", enables the TOTP path */ } -bool ntp_is_synced(void) { return false; } -uint32_t ntp_last_sync_time(void) { return 0; } -bool ntp_sync(void) { return false; } +bool ntp_is_synced(void) { + return false; +} +uint32_t ntp_last_sync_time(void) { + return 0; +} +bool ntp_sync(void) { + return false; +} bool wifi_connect(const char *ssid, const char *password) { (void)ssid; From 3c35619b699b3e80434f552dc9d9e6f8598cba44 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 06:42:38 +0200 Subject: [PATCH 03/14] test: fuzzing dictionary + seeds + direct backup_import harness; measure & close coverage gaps Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 33 ++++++-- test/fuzz_backup.c | 95 ++++++++++++++++++++++++ test/fuzz_backup_corpus/magic_truncated | Bin 0 -> 16 bytes test/fuzz_backup_corpus/valid_0keys | Bin 0 -> 16 bytes test/fuzz_backup_corpus/valid_2keys | Bin 0 -> 136 bytes test/fuzz_console.dict | 46 ++++++++++++ test/fuzz_corpus/import_valid | 4 + test/fuzz_corpus/mutators | 9 +++ test/fuzz_corpus/ntpsync | 4 + test/fuzz_corpus/totp_login | 6 ++ 10 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 test/fuzz_backup.c create mode 100644 test/fuzz_backup_corpus/magic_truncated create mode 100644 test/fuzz_backup_corpus/valid_0keys create mode 100644 test/fuzz_backup_corpus/valid_2keys create mode 100644 test/fuzz_console.dict create mode 100644 test/fuzz_corpus/import_valid create mode 100644 test/fuzz_corpus/mutators create mode 100644 test/fuzz_corpus/ntpsync create mode 100644 test/fuzz_corpus/totp_login diff --git a/test/Makefile b/test/Makefile index f672ba4..b7aba6a 100644 --- a/test/Makefile +++ b/test/Makefile @@ -110,7 +110,7 @@ LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov GENHTML_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,negative LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) -.PHONY: all asan valgrind coverage fuzz fuzz-run clean check-submodules +.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run clean check-submodules all: asan @@ -245,6 +245,7 @@ FUZZ_CC ?= clang FUZZ_BIN := $(BUILD)/fuzz_console FUZZ_TIME ?= 60 FUZZ_CORPUS := fuzz_corpus +FUZZ_DICT := fuzz_console.dict FUZZ_FLAGS := -std=c11 -g -O1 -fsanitize=fuzzer,address,undefined \ -fno-sanitize-recover=all FUZZ_INCLUDES := -I$(ROOT) -I$(ROOT)/hardware -I$(ROOT)/network -I$(ROOT)/serial \ @@ -261,17 +262,37 @@ FUZZ_SRCS := fuzz_console.c \ FUZZ_DEFS := -DLFS_NO_MALLOC -DLFS_NO_DEBUG FUZZ_LIBS := -lmbedcrypto -# Build the fuzzer only. -fuzz: check-submodules $(FUZZ_BIN) +# Direct backup_import() deserialiser harness: fuzz bytes go straight into the +# untrusted-blob parser on a fresh RAM-backed littlefs, bypassing console + +# login + admin + base64. See test/fuzz_backup.c. Only storage.c/backup.c and +# the real littlefs are linked (no console/totp/qr surface). +FUZZ_BACKUP_BIN := $(BUILD)/fuzz_backup +FUZZ_BACKUP_CORPUS := fuzz_backup_corpus +FUZZ_BACKUP_SRCS := fuzz_backup.c \ + $(ROOT)/storage/storage.c $(ROOT)/storage/backup.c \ + $(ROOT)/libs/littlefs/lfs.c $(ROOT)/libs/littlefs/lfs_util.c + +# Build both fuzzers. +fuzz: check-submodules $(FUZZ_BIN) $(FUZZ_BACKUP_BIN) $(FUZZ_BIN): $(FUZZ_SRCS) | $(BUILD) $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_SRCS) $(FUZZ_LIBS) -o $@ -# Build + run a short bounded campaign from the seed corpus. Bump FUZZ_TIME for -# a longer soak: `make -C test fuzz-run FUZZ_TIME=600`. +$(FUZZ_BACKUP_BIN): $(FUZZ_BACKUP_SRCS) | $(BUILD) + $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_BACKUP_SRCS) -o $@ + +# Build + run short bounded campaigns from the seed corpora. The console run is +# fed the keyword dictionary so mutation can splice real commands together. +# Bump FUZZ_TIME for a longer soak: `make -C test fuzz-run FUZZ_TIME=600`. fuzz-run: $(FUZZ_BIN) @echo "== fuzzing serial console for $(FUZZ_TIME)s ==" - $(FUZZ_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 $(FUZZ_CORPUS) + $(FUZZ_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ + -dict=$(FUZZ_DICT) $(FUZZ_CORPUS) + +fuzz-backup-run: $(FUZZ_BACKUP_BIN) + @echo "== fuzzing backup_import for $(FUZZ_TIME)s ==" + $(FUZZ_BACKUP_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ + $(FUZZ_BACKUP_CORPUS) $(BUILD): mkdir -p $(BUILD) diff --git a/test/fuzz_backup.c b/test/fuzz_backup.c new file mode 100644 index 0000000..f0c0321 --- /dev/null +++ b/test/fuzz_backup.c @@ -0,0 +1,95 @@ +/* + * libFuzzer harness: the binary backup deserialiser, in isolation. + * + * cmd_import_keys() reaches backup_import() only after the console tokeniser, + * a successful login, admin mode AND a base64 decode — so line-fuzzing almost + * never gets a well-formed header past validation, and the write path + * (checksum-verified magic/version/count, delete-existing, per-key + * storage_key_save) stays unexercised. This harness feeds the fuzz bytes + * STRAIGHT into backup_import(buf, size) on a fresh RAM-backed littlefs, + * bypassing console + login + admin + base64 entirely. It is the strongest + * test of the untrusted-blob parser: magic, version, key_count bound, the + * truncation check, the CRC32, and the record-write loop. + * + * Storage is the same RAM-mapped XIP window trick as fuzz_console.c: the last + * 256KB of the Pico's flash is mmap'd into host RAM at its true XIP address, + * so storage.c / backup.c drive a genuine littlefs. State is reset per input. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include + +#include "hardware/flash.h" +#include "pico/stdlib.h" + +#include "storage/backup.h" +#include "storage/storage.h" + +/* --- storage layout, mirrored from storage.c ------------------------------ */ + +#define STORAGE_SIZE_BYTES (256 * 1024) +#define STORAGE_FLASH_OFFSET (PICO_FLASH_SIZE_BYTES - STORAGE_SIZE_BYTES) +#define STORAGE_WINDOW_BASE ((uintptr_t)(XIP_BASE + STORAGE_FLASH_OFFSET)) + +static uint8_t *flash_ptr(uint32_t flash_offs) { + return (uint8_t *)(uintptr_t)(XIP_BASE + flash_offs); +} + +/* --- RAM-backed flash primitives storage.c drives ------------------------- */ + +void flash_range_program(uint32_t flash_offs, const uint8_t *data, size_t count) { + uint8_t *dst = flash_ptr(flash_offs); + for (size_t i = 0; i < count; i++) + dst[i] &= data[i]; /* NOR: programming can only clear bits */ +} + +void flash_range_erase(uint32_t flash_offs, size_t count) { + memset(flash_ptr(flash_offs), 0xFF, count); +} + +int flash_safe_execute(void (*func)(void *), void *param, uint32_t timeout_ms) { + (void)timeout_ms; + func(param); + return PICO_OK; +} + +/* --- per-run reset -------------------------------------------------------- */ + +static void flash_ram_reset(void) { + memset((void *)STORAGE_WINDOW_BASE, 0xFF, STORAGE_SIZE_BYTES); +} + +int LLVMFuzzerInitialize(int *argc, char ***argv) { + (void)argc; + (void)argv; + + void *base = mmap((void *)STORAGE_WINDOW_BASE, STORAGE_SIZE_BYTES, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); + if (base == MAP_FAILED || (uintptr_t)base != STORAGE_WINDOW_BASE) { + perror("mmap XIP window"); + return -1; + } + + if (!freopen("/dev/null", "w", stdout)) { + perror("freopen stdout"); + return -1; + } + return 0; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + flash_ram_reset(); + if (!storage_init()) + return 0; /* mount/format failure is not an input-driven bug */ + + /* The whole point: the raw fuzz bytes ARE the untrusted backup blob. */ + backup_import(data, size); + + return 0; +} diff --git a/test/fuzz_backup_corpus/magic_truncated b/test/fuzz_backup_corpus/magic_truncated new file mode 100644 index 0000000000000000000000000000000000000000..13683c10eed71c4a6a6602ded97b9dfda4137d45 GIT binary patch literal 16 TcmeYW_VHn4U|?VdVh{iT5tRWr literal 0 HcmV?d00001 diff --git a/test/fuzz_backup_corpus/valid_0keys b/test/fuzz_backup_corpus/valid_0keys new file mode 100644 index 0000000000000000000000000000000000000000..0f903e792d20846d74de6d7ec3cfe31cb76da9c0 GIT binary patch literal 16 RcmeYW_VHn4fPnu%000|v1u*~s literal 0 HcmV?d00001 diff --git a/test/fuzz_backup_corpus/valid_2keys b/test/fuzz_backup_corpus/valid_2keys new file mode 100644 index 0000000000000000000000000000000000000000..9fd8a3fc6bc151aa6597b4cb90100150087993de GIT binary patch literal 136 zcmeYW_VHn4U|?VZ;zvgl1GpFxa|$vNfjnG*k%^gwm5rT)lZ%^&mycgSP)JyWk&)qJ oa4I)LQfdi4tqdPNe)|07>$mSee*XIX=kLG&3_!zxwgdG70M)S?>i_@% literal 0 HcmV?d00001 diff --git a/test/fuzz_console.dict b/test/fuzz_console.dict new file mode 100644 index 0000000..a77d2d5 --- /dev/null +++ b/test/fuzz_console.dict @@ -0,0 +1,46 @@ +# libFuzzer dictionary for the hslock serial console fuzzer. +# +# Random bytes cannot synthesise the parser's keywords, so mutation stalls at +# "unknown command". These tokens are every command name from the COMMANDS[] +# table in serial/commands.c, common argument shapes, and the backup blob +# magic/prefix, so libFuzzer can splice real commands together and reach the +# admin-gated handlers, the TOTP path and backup_import. + +# --- command names (serial/commands.c COMMANDS[]) --- +"help" +"?" +"status" +"test" +"login" +"logout" +"reboot" +"get-time" +"sync-ntp" +"set-wifi" +"list-keys" +"get-key" +"get-key-secret" +"add-key" +"rename-key" +"enable-key" +"disable-key" +"delete-key" +"set-key-admin" +"unset-key-admin" +"export-keys" +"import-keys" + +# --- common argument shapes --- +"login 0 0\x0a" +"add-key 1 x\x0a" +"set-wifi s p\x0a" +" 0 " +" 1 " +" 255 " +" 256 " +"123456" + +# --- backup blob magic / base64 prefix (storage/backup.h BACKUP_MAGIC) --- +"HSLL" +"SFNMTA" +"--- BEGIN HSLOCK BACKUP ---" diff --git a/test/fuzz_corpus/import_valid b/test/fuzz_corpus/import_valid new file mode 100644 index 0000000..248ced7 --- /dev/null +++ b/test/fuzz_corpus/import_valid @@ -0,0 +1,4 @@ +login 0 0 +add-key 5 x +import-keys SFNMTAEAAAACAAAA4sVhUAoAYWxwaGEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAgMEBQYHCAkKCwwNDg8QERITFAEBAPFTZQsAYmV0YQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADw8fLz9PX29/j5+vv8/f7/AAECAwEAAPFTZQ== +list-keys diff --git a/test/fuzz_corpus/mutators b/test/fuzz_corpus/mutators new file mode 100644 index 0000000..b976634 --- /dev/null +++ b/test/fuzz_corpus/mutators @@ -0,0 +1,9 @@ +login 0 0 +add-key 3 door +rename-key 3 gate +disable-key 3 +enable-key 3 +set-key-admin 3 +unset-key-admin 3 +get-key-secret 3 +delete-key 3 diff --git a/test/fuzz_corpus/ntpsync b/test/fuzz_corpus/ntpsync new file mode 100644 index 0000000..cfb06ec --- /dev/null +++ b/test/fuzz_corpus/ntpsync @@ -0,0 +1,4 @@ +login 0 0 +sync-ntp +get-time +status diff --git a/test/fuzz_corpus/totp_login b/test/fuzz_corpus/totp_login new file mode 100644 index 0000000..3ecf260 --- /dev/null +++ b/test/fuzz_corpus/totp_login @@ -0,0 +1,6 @@ +login 0 0 +add-key 7 k +set-key-admin 7 +set-wifi myssid mypass +logout +login 7 123456 From 886f727f842bc250ea15e4e51b6abc7edf33d209 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 06:53:36 +0200 Subject: [PATCH 04/14] test: add fuzz-cov target to measure fuzzer coverage of the parse surface Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 65 +++++++++++++++++++++++++++++++- test/fuzz_replay.c | 93 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 test/fuzz_replay.c diff --git a/test/Makefile b/test/Makefile index b7aba6a..37b53ad 100644 --- a/test/Makefile +++ b/test/Makefile @@ -110,7 +110,7 @@ LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov GENHTML_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,negative LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) -.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run clean check-submodules +.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run fuzz-cov clean check-submodules all: asan @@ -294,6 +294,69 @@ fuzz-backup-run: $(FUZZ_BACKUP_BIN) $(FUZZ_BACKUP_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ $(FUZZ_BACKUP_CORPUS) +# --- Fuzzer coverage of the parse/logic surface ---------------------------- +# REPEATABLE measurement of how much of the fuzzable surface the committed +# corpora exercise. Each fuzzer's sources are rebuilt with gcc --coverage and +# -DFUZZ_REPLAY (no libFuzzer runtime) and linked with the plain replay main +# (fuzz_replay.c), producing cov_console / cov_backup. Each replays its corpus +# once per file, then lcov captures + merges both runs and restricts the report +# to the nine surface files. cov_console and cov_backup share storage.c/backup.c +# sources; because their .gcno/.gcda are prefixed with the output basename they +# do not collide, and capturing the whole obj dir merges the two runs' hits per +# source file (the union coverage we want). Output: a per-file + per-function +# summary to stdout and to $(FUZZ_COV_OUT) (gitignored under coverage/). +FUZZ_COV_OBJ := $(COV_DIR)/fuzzobj +FUZZ_COV_OUT := $(COV_DIR)/fuzz-surface.txt + +# The fuzzable parse/logic surface (HW-I/O wrappers are out of scope). +SURFACE_SRCS := serial/commands.c serial/commands_system.c serial/commands_keys.c \ + serial/commands_network.c serial/commands_backup.c serial/console.c \ + storage/storage.c storage/backup.c shared/totp.c +SURFACE_ABS := $(addprefix $(REPO_ROOT)/,$(SURFACE_SRCS)) + +# Same lcov config as `coverage`, plus function coverage so the per-function +# summary and the uncovered-function list are populated. +FCOV_LCOV := lcov $(LCOV_RC) --rc function_coverage=1 $(LCOV_IGNORE) + +fuzz-cov: check-submodules | $(FUZZ_COV_OBJ) + @echo "== fuzz-cov: building replay binaries (gcc --coverage) ==" + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(FUZZ_DEFS) -DFUZZ_REPLAY $(FUZZ_INCLUDES) \ + $(FUZZ_SRCS) fuzz_replay.c $(FUZZ_LIBS) -o $(FUZZ_COV_OBJ)/cov_console + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(FUZZ_DEFS) -DFUZZ_REPLAY $(FUZZ_INCLUDES) \ + $(FUZZ_BACKUP_SRCS) fuzz_replay.c -o $(FUZZ_COV_OBJ)/cov_backup + @echo "== fuzz-cov: replaying corpora ==" + $(FUZZ_COV_OBJ)/cov_console $(FUZZ_CORPUS) + $(FUZZ_COV_OBJ)/cov_backup $(FUZZ_BACKUP_CORPUS) + @echo "== fuzz-cov: capturing + merging coverage ==" + $(FCOV_LCOV) --capture --initial --directory $(FUZZ_COV_OBJ) \ + --output-file $(COV_DIR)/fuzz-base.info + $(FCOV_LCOV) --capture --directory $(FUZZ_COV_OBJ) \ + --output-file $(COV_DIR)/fuzz-run.info + $(FCOV_LCOV) --add-tracefile $(COV_DIR)/fuzz-base.info \ + --add-tracefile $(COV_DIR)/fuzz-run.info \ + --output-file $(COV_DIR)/fuzz-merged.info + @echo "== fuzz-cov: extracting the surface files ==" + $(FCOV_LCOV) --extract $(COV_DIR)/fuzz-merged.info $(SURFACE_ABS) \ + --output-file $(COV_DIR)/fuzz-surface.info + @echo "== fuzz-cov: surface summary (also written to $(FUZZ_COV_OUT)) ==" + @{ \ + echo "hslock fuzzer coverage of the parse/logic surface"; \ + echo "corpora: $(FUZZ_CORPUS) (console) + $(FUZZ_BACKUP_CORPUS) (backup)"; \ + echo; \ + $(FCOV_LCOV) --list $(COV_DIR)/fuzz-surface.info; \ + echo; \ + echo "Uncovered functions across the surface:"; \ + awk -F'[:,]' '\ + /^SF:/ { sf=$$2; sub(/.*\/(serial|storage|shared)\//,"",sf) } \ + /^FNA:/ { if ($$3==0) print " " sf " " $$4 } \ + /^FNDA:/ { if ($$2==0) print " " sf " " $$3 }' \ + $(COV_DIR)/fuzz-surface.info | sort || true; \ + } | tee $(FUZZ_COV_OUT) + @echo "== fuzz-cov OK: $(FUZZ_COV_OUT) ==" + +$(FUZZ_COV_OBJ): + mkdir -p $(FUZZ_COV_OBJ) + $(BUILD): mkdir -p $(BUILD) diff --git a/test/fuzz_replay.c b/test/fuzz_replay.c new file mode 100644 index 0000000..f32f52b --- /dev/null +++ b/test/fuzz_replay.c @@ -0,0 +1,93 @@ +/* + * Replay driver for the libFuzzer harnesses, used for coverage measurement. + * + * libFuzzer normally supplies main() and drives LLVMFuzzerTestOneInput() with + * mutated inputs. For a REPEATABLE coverage measurement we instead link this + * plain main() against a fuzzer's sources (compiled with gcc --coverage and + * -DFUZZ_REPLAY, no libFuzzer runtime) and replay a fixed corpus: every file + * under each directory argument (and any file arguments) is read once and fed + * to LLVMFuzzerTestOneInput(). This works unchanged for BOTH fuzz_console.c + * and fuzz_backup.c, which each define LLVMFuzzerTestOneInput()/ + * LLVMFuzzerInitialize(). + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include +#include + +/* Provided by the fuzzer .c linked alongside this main. */ +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); +/* Optional one-time setup; weak so a harness without it still links. */ +__attribute__((weak)) int LLVMFuzzerInitialize(int *argc, char ***argv); + +static void replay_file(const char *path) { + FILE *f = fopen(path, "rb"); + if (!f) { + fprintf(stderr, "replay: cannot open %s\n", path); + return; + } + if (fseek(f, 0, SEEK_END) != 0) { + fclose(f); + return; + } + long sz = ftell(f); + if (sz < 0) { + fclose(f); + return; + } + rewind(f); + + uint8_t *buf = malloc((size_t)sz + 1); /* +1 so a 0-byte file still mallocs */ + if (!buf) { + fclose(f); + return; + } + size_t n = fread(buf, 1, (size_t)sz, f); + fclose(f); + + LLVMFuzzerTestOneInput(buf, n); + free(buf); +} + +static void replay_dir(const char *path) { + DIR *d = opendir(path); + if (!d) { + fprintf(stderr, "replay: cannot open dir %s\n", path); + return; + } + struct dirent *ent; + while ((ent = readdir(d)) != NULL) { + if (ent->d_name[0] == '.') + continue; /* skip ".", "..", dotfiles */ + char child[4096]; + snprintf(child, sizeof(child), "%s/%s", path, ent->d_name); + struct stat st; + if (stat(child, &st) == 0 && S_ISREG(st.st_mode)) + replay_file(child); + } + closedir(d); +} + +int main(int argc, char **argv) { + if (LLVMFuzzerInitialize) + LLVMFuzzerInitialize(&argc, &argv); + + for (int i = 1; i < argc; i++) { + struct stat st; + if (stat(argv[i], &st) != 0) { + fprintf(stderr, "replay: cannot stat %s\n", argv[i]); + continue; + } + if (S_ISDIR(st.st_mode)) + replay_dir(argv[i]); + else + replay_file(argv[i]); + } + return 0; +} From 6d677cb3ab9a29d9696dbeb82321404c96c029a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:07:54 +0200 Subject: [PATCH 05/14] test: direct totp_verify fuzz harness + login-accept seed to cover the TOTP accept path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test/fuzz_totp.c: a libFuzzer harness that drives shared/totp.c directly, interpreting the fuzz input as a (time, secret) pair and deterministically exercising every totp_verify path each run — the RTC-not-set reject, the T-1/T/T+1 window ACCEPT (return true), and the full-loop reject tail. This reaches totp.c's accept branch, which the console fuzzer cannot (a random code never matches the RNG-generated stored secret). totp.c: 88.0%/66.7% -> 100%/100% lines/branches. Make the console fuzzer's RNG deterministic per run (reset to a fixed seed in LLVMFuzzerTestOneInput) so the first add-key of a run always yields the same secret. That lets the totp_login seed carry the precomputed valid code (218873) and reach cmd_login's admin-success tail; add totp_reject to keep the TOTP reject tail covered too. commands_system.c: 65.5%/59.1% -> 68.1%/61.4%. Wire fuzz_totp into the Makefile fuzz / fuzz-totp-run / fuzz-cov targets with a curated fuzz_totp_corpus. 60s campaign: 1.2M execs, no crash. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 29 ++++++++++-- test/fuzz_console.c | 22 ++++++--- test/fuzz_corpus/totp_login | 2 +- test/fuzz_corpus/totp_reject | 6 +++ test/fuzz_totp.c | 74 ++++++++++++++++++++++++++++++ test/fuzz_totp_corpus/empty_secret | 1 + test/fuzz_totp_corpus/rfc_seed | 1 + test/fuzz_totp_corpus/short_secret | 1 + 8 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 test/fuzz_corpus/totp_reject create mode 100644 test/fuzz_totp.c create mode 100644 test/fuzz_totp_corpus/empty_secret create mode 100644 test/fuzz_totp_corpus/rfc_seed create mode 100644 test/fuzz_totp_corpus/short_secret diff --git a/test/Makefile b/test/Makefile index 37b53ad..8403b4d 100644 --- a/test/Makefile +++ b/test/Makefile @@ -110,7 +110,7 @@ LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov GENHTML_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,negative LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) -.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run fuzz-cov clean check-submodules +.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run fuzz-totp-run fuzz-cov clean check-submodules all: asan @@ -272,8 +272,20 @@ FUZZ_BACKUP_SRCS := fuzz_backup.c \ $(ROOT)/storage/storage.c $(ROOT)/storage/backup.c \ $(ROOT)/libs/littlefs/lfs.c $(ROOT)/libs/littlefs/lfs_util.c -# Build both fuzzers. -fuzz: check-submodules $(FUZZ_BIN) $(FUZZ_BACKUP_BIN) +# Direct totp_verify() harness: fuzz bytes are interpreted as a (time, secret) +# pair and totp.c is driven directly, so the ACCEPT branch of totp_verify (a +# valid in-window code -> return true) runs every replay — unreachable via the +# console fuzzer, whose RNG-generated secret makes a random code never match. +# Built against the REAL mbedtls (system libmbedcrypto), like the asan_totp +# harness; -idirafter stub keeps the system header winning. +FUZZ_TOTP_BIN := $(BUILD)/fuzz_totp +FUZZ_TOTP_CORPUS := fuzz_totp_corpus +FUZZ_TOTP_SRCS := fuzz_totp.c $(SHARED)/totp.c +FUZZ_TOTP_INCLUDES := -I$(ROOT) -I$(ROOT)/hardware -I$(SHARED) -idirafter stub +FUZZ_TOTP_LIBS := -lmbedcrypto + +# Build all fuzzers. +fuzz: check-submodules $(FUZZ_BIN) $(FUZZ_BACKUP_BIN) $(FUZZ_TOTP_BIN) $(FUZZ_BIN): $(FUZZ_SRCS) | $(BUILD) $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_SRCS) $(FUZZ_LIBS) -o $@ @@ -281,6 +293,9 @@ $(FUZZ_BIN): $(FUZZ_SRCS) | $(BUILD) $(FUZZ_BACKUP_BIN): $(FUZZ_BACKUP_SRCS) | $(BUILD) $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_BACKUP_SRCS) -o $@ +$(FUZZ_TOTP_BIN): $(FUZZ_TOTP_SRCS) | $(BUILD) + $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_TOTP_INCLUDES) $(FUZZ_TOTP_SRCS) $(FUZZ_TOTP_LIBS) -o $@ + # Build + run short bounded campaigns from the seed corpora. The console run is # fed the keyword dictionary so mutation can splice real commands together. # Bump FUZZ_TIME for a longer soak: `make -C test fuzz-run FUZZ_TIME=600`. @@ -294,6 +309,11 @@ fuzz-backup-run: $(FUZZ_BACKUP_BIN) $(FUZZ_BACKUP_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ $(FUZZ_BACKUP_CORPUS) +fuzz-totp-run: $(FUZZ_TOTP_BIN) + @echo "== fuzzing totp_verify for $(FUZZ_TIME)s ==" + $(FUZZ_TOTP_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ + $(FUZZ_TOTP_CORPUS) + # --- Fuzzer coverage of the parse/logic surface ---------------------------- # REPEATABLE measurement of how much of the fuzzable surface the committed # corpora exercise. Each fuzzer's sources are rebuilt with gcc --coverage and @@ -324,9 +344,12 @@ fuzz-cov: check-submodules | $(FUZZ_COV_OBJ) $(FUZZ_SRCS) fuzz_replay.c $(FUZZ_LIBS) -o $(FUZZ_COV_OBJ)/cov_console $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(FUZZ_DEFS) -DFUZZ_REPLAY $(FUZZ_INCLUDES) \ $(FUZZ_BACKUP_SRCS) fuzz_replay.c -o $(FUZZ_COV_OBJ)/cov_backup + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) -DFUZZ_REPLAY $(FUZZ_TOTP_INCLUDES) \ + $(FUZZ_TOTP_SRCS) fuzz_replay.c $(FUZZ_TOTP_LIBS) -o $(FUZZ_COV_OBJ)/cov_totp @echo "== fuzz-cov: replaying corpora ==" $(FUZZ_COV_OBJ)/cov_console $(FUZZ_CORPUS) $(FUZZ_COV_OBJ)/cov_backup $(FUZZ_BACKUP_CORPUS) + $(FUZZ_COV_OBJ)/cov_totp $(FUZZ_TOTP_CORPUS) @echo "== fuzz-cov: capturing + merging coverage ==" $(FCOV_LCOV) --capture --initial --directory $(FUZZ_COV_OBJ) \ --output-file $(COV_DIR)/fuzz-base.info diff --git a/test/fuzz_console.c b/test/fuzz_console.c index c56cdb8..858e974 100644 --- a/test/fuzz_console.c +++ b/test/fuzz_console.c @@ -137,13 +137,20 @@ void pico_get_unique_board_id(pico_unique_board_id_t *id_out) { id_out->id[i] = (uint8_t)(0xA0 + i); } -/* Deterministic RNG so add-key secret generation is reproducible. */ +/* Deterministic RNG so add-key secret generation is reproducible. The state is + * reset to a fixed seed at the start of every run (see LLVMFuzzerTestOneInput), + * so the FIRST add-key in a run always generates the SAME secret regardless of + * how many keys earlier corpus entries generated. That determinism lets a seed + * carry the precomputed valid TOTP code and reach cmd_login's admin-success + * tail (the RNG_SEED / login-accept path). */ +#define RNG_SEED 0x9E3779B97F4A7C15ULL +static uint64_t g_rng_state = RNG_SEED; + uint64_t get_rand_64(void) { - static uint64_t s = 0x9E3779B97F4A7C15ULL; - s ^= s << 13; - s ^= s >> 7; - s ^= s << 17; - return s; + g_rng_state ^= g_rng_state << 13; + g_rng_state ^= g_rng_state >> 7; + g_rng_state ^= g_rng_state << 17; + return g_rng_state; } /* cmd_reboot calls watchdog_reboot() then spins in an infinite tight loop. @@ -189,7 +196,8 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { flash_ram_reset(); if (!storage_init()) return 0; /* format/mount failure is not an input-driven bug */ - admin_mode = false; + admin_mode = false; + g_rng_state = RNG_SEED; /* first add-key of each run => fixed secret */ g_data = data; g_size = size; diff --git a/test/fuzz_corpus/totp_login b/test/fuzz_corpus/totp_login index 3ecf260..a8f0dee 100644 --- a/test/fuzz_corpus/totp_login +++ b/test/fuzz_corpus/totp_login @@ -3,4 +3,4 @@ add-key 7 k set-key-admin 7 set-wifi myssid mypass logout -login 7 123456 +login 7 218873 diff --git a/test/fuzz_corpus/totp_reject b/test/fuzz_corpus/totp_reject new file mode 100644 index 0000000..1137feb --- /dev/null +++ b/test/fuzz_corpus/totp_reject @@ -0,0 +1,6 @@ +login 0 0 +add-key 7 k +set-key-admin 7 +set-wifi myssid mypass +logout +login 7 000001 diff --git a/test/fuzz_totp.c b/test/fuzz_totp.c new file mode 100644 index 0000000..f699408 --- /dev/null +++ b/test/fuzz_totp.c @@ -0,0 +1,74 @@ +/* + * libFuzzer harness: shared/totp.c (RFC 6238 TOTP), in isolation. + * + * The console fuzzer can only reach totp_verify()'s REJECT tail: a random code + * practically never equals the valid TOTP for the (RNG-generated) stored + * secret, so `return true` and the whole T-1/T/T+1 match logic stay dark. This + * harness drives totp.c directly. It interprets the fuzz bytes as a (time, + * secret) pair and then, every run, deterministically exercises BOTH paths of + * totp_verify: + * + * - unix_time == 0 -> the "RTC not set" reject branch + * - a valid code at T-1/T/T+1 -> the window loop's ACCEPT branch (return true) + * - an impossible 7-digit code -> the full loop with no match (reject tail) + * + * totp.c is the untouched first-party source, built against the REAL mbedtls + * (system libmbedcrypto) so HMAC-SHA1 computes genuine codes; the compile-only + * stub in stub/mbedtls/md.h cannot. clock_get_unix_time() is injected here so + * the verify time is controllable and deterministic. + * + * The valid-code checks double as a differential oracle: a valid in-window code + * that fails to verify would abort() the run and surface a totp.c regression. + */ + +#include +#include +#include +#include + +#include "totp.h" + +/* Injectable clock: totp_verify() reads the current time through this. */ +static uint32_t g_unix_time; + +uint32_t clock_get_unix_time(void) { + return g_unix_time; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + /* First up to 4 bytes -> a big-endian time base; the rest -> the secret. */ + uint32_t t = 0; + size_t off = 0; + for (int i = 0; i < 4 && off < size; i++) + t = (t << 8) | data[off++]; + + uint8_t secret[64]; + size_t secret_len = size - off; + if (secret_len > sizeof secret) + secret_len = sizeof secret; + if (secret_len) + memcpy(secret, data + off, secret_len); + + /* RTC-not-set: unix_time == 0 must reject regardless of the code. */ + g_unix_time = 0; + (void)totp_verify(secret, secret_len, 0); + + /* RTC set: force a non-zero time so the step arithmetic and window run. */ + g_unix_time = t | 1u; + uint64_t step = (uint64_t)g_unix_time / TOTP_STEP_SECONDS; + + /* A correct code for each in-window step (T-1, T, T+1) must verify true, + * exercising the loop's match/accept branch at every window offset. */ + for (int delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) { + uint32_t good = totp_at(secret, secret_len, step + (uint64_t)delta); + if (!totp_verify(secret, secret_len, good)) + abort(); /* in-window valid code failing to verify == totp.c bug */ + } + + /* An impossible 7-digit code (totp_at always yields < 1e6) drives the full + * window loop with no match -> the reject tail (return false). */ + if (totp_verify(secret, secret_len, 1000000u)) + abort(); /* out-of-range code accepted == totp.c bug */ + + return 0; +} diff --git a/test/fuzz_totp_corpus/empty_secret b/test/fuzz_totp_corpus/empty_secret new file mode 100644 index 0000000..0082886 --- /dev/null +++ b/test/fuzz_totp_corpus/empty_secret @@ -0,0 +1 @@ +time \ No newline at end of file diff --git a/test/fuzz_totp_corpus/rfc_seed b/test/fuzz_totp_corpus/rfc_seed new file mode 100644 index 0000000..2d8d6f2 --- /dev/null +++ b/test/fuzz_totp_corpus/rfc_seed @@ -0,0 +1 @@ +time12345678901234567890 \ No newline at end of file diff --git a/test/fuzz_totp_corpus/short_secret b/test/fuzz_totp_corpus/short_secret new file mode 100644 index 0000000..56e88f3 --- /dev/null +++ b/test/fuzz_totp_corpus/short_secret @@ -0,0 +1 @@ +AAAAkey \ No newline at end of file From b9439d2233a3ff128f324eac910ab36eb4d63385 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:22:32 +0200 Subject: [PATCH 06/14] test: structured backup_import seeds covering all validation/write branches Add a host generator (test/gen_backup_seeds.c) that hand-crafts one blob per backup_import() branch, computing the valid cases' CRC with the firmware's own lfs_crc so they pass validation and drive the delete-existing + per-key write path (to_key_record). Replaces the ad-hoc committed seeds with a regenerable, coverage-complete set: empty/short-header, bad magic, bad version, count>MAX, body-truncated, CRC mismatch, valid 0/1/flag-variant keys, and a MAX-count round-trip. Adds a console import_fail seed to cover cmd_import_keys' failed branch. Makes storage/backup.c 78.1->91.8% lines, 65.4->84.6% branches and commands_backup.c 83.3->87.5% lines, 66.7->83.3% branches reproducible from the committed corpus alone (previously depended on uncommitted fuzzer growth). The residual backup.c branches (backup_export undersized-buffer / invalid-checksum skip / list error, and the import save-fail) are backup_export or storage-I/O error paths not reachable by feeding bytes to backup_import. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 17 ++- test/fuzz_backup_corpus/bad_magic | Bin 0 -> 16 bytes test/fuzz_backup_corpus/bad_version | Bin 0 -> 16 bytes test/fuzz_backup_corpus/body_truncated | Bin 0 -> 16 bytes test/fuzz_backup_corpus/count_over_max | Bin 0 -> 16 bytes test/fuzz_backup_corpus/crc_mismatch | Bin 0 -> 76 bytes test/fuzz_backup_corpus/empty | 0 test/fuzz_backup_corpus/magic_truncated | Bin 16 -> 0 bytes test/fuzz_backup_corpus/short_header | Bin 0 -> 8 bytes test/fuzz_backup_corpus/valid_1key | Bin 0 -> 76 bytes test/fuzz_backup_corpus/valid_2keys | Bin 136 -> 0 bytes test/fuzz_backup_corpus/valid_countmax | Bin 0 -> 15376 bytes test/fuzz_backup_corpus/valid_flags | Bin 0 -> 256 bytes test/fuzz_corpus/import_fail | 2 + test/gen_backup_seeds.c | 173 ++++++++++++++++++++++++ 15 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 test/fuzz_backup_corpus/bad_magic create mode 100644 test/fuzz_backup_corpus/bad_version create mode 100644 test/fuzz_backup_corpus/body_truncated create mode 100644 test/fuzz_backup_corpus/count_over_max create mode 100644 test/fuzz_backup_corpus/crc_mismatch create mode 100644 test/fuzz_backup_corpus/empty delete mode 100644 test/fuzz_backup_corpus/magic_truncated create mode 100644 test/fuzz_backup_corpus/short_header create mode 100644 test/fuzz_backup_corpus/valid_1key delete mode 100644 test/fuzz_backup_corpus/valid_2keys create mode 100644 test/fuzz_backup_corpus/valid_countmax create mode 100644 test/fuzz_backup_corpus/valid_flags create mode 100644 test/fuzz_corpus/import_fail create mode 100644 test/gen_backup_seeds.c diff --git a/test/Makefile b/test/Makefile index 8403b4d..46af6e9 100644 --- a/test/Makefile +++ b/test/Makefile @@ -110,7 +110,7 @@ LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov GENHTML_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,negative LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) -.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run fuzz-totp-run fuzz-cov clean check-submodules +.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run fuzz-totp-run fuzz-cov gen-backup-seeds clean check-submodules all: asan @@ -314,6 +314,21 @@ fuzz-totp-run: $(FUZZ_TOTP_BIN) $(FUZZ_TOTP_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ $(FUZZ_TOTP_CORPUS) +# --- Curated backup_import() seed generator --------------------------------- +# Regenerates the committed fuzz_backup_corpus/ blobs: one per backup_import +# validation/write branch, with the VALID cases carrying a real CRC computed by +# the firmware's own lfs_crc (linked from libs/littlefs/lfs_util.c). Run after +# changing the wire format or the curated set. See test/gen_backup_seeds.c. +GEN_BACKUP_BIN := $(BUILD)/gen_backup_seeds +GEN_BACKUP_SRCS := gen_backup_seeds.c $(ROOT)/libs/littlefs/lfs_util.c + +$(GEN_BACKUP_BIN): $(GEN_BACKUP_SRCS) | $(BUILD) + $(CC) $(CSTD) $(WARN) -g -O1 $(WHOLE_DEFS) $(INCLUDES) $(GEN_BACKUP_SRCS) -o $@ + +gen-backup-seeds: $(GEN_BACKUP_BIN) + @echo "== regenerating curated backup_import seed corpus ==" + $(GEN_BACKUP_BIN) $(FUZZ_BACKUP_CORPUS) + # --- Fuzzer coverage of the parse/logic surface ---------------------------- # REPEATABLE measurement of how much of the fuzzable surface the committed # corpora exercise. Each fuzzer's sources are rebuilt with gcc --coverage and diff --git a/test/fuzz_backup_corpus/bad_magic b/test/fuzz_backup_corpus/bad_magic new file mode 100644 index 0000000000000000000000000000000000000000..588d83b81a0d31bf3e7c97557f8ee52b270f6535 GIT binary patch literal 16 RcmaFAZ|yxs1_<~M1OPbT2Q~lz literal 0 HcmV?d00001 diff --git a/test/fuzz_backup_corpus/bad_version b/test/fuzz_backup_corpus/bad_version new file mode 100644 index 0000000000000000000000000000000000000000..296f727cc2bfbb5f5f5a89dc94645957f4420503 GIT binary patch literal 16 RcmeYW_VHn2fPnu%000|*1u_5t literal 0 HcmV?d00001 diff --git a/test/fuzz_backup_corpus/body_truncated b/test/fuzz_backup_corpus/body_truncated new file mode 100644 index 0000000000000000000000000000000000000000..8b7b4cce6f62d36f33693f027581d8db33d0403a GIT binary patch literal 16 UcmeYW_VHn4U|?Vd;{QMZ02_-1Gynhq literal 0 HcmV?d00001 diff --git a/test/fuzz_backup_corpus/count_over_max b/test/fuzz_backup_corpus/count_over_max new file mode 100644 index 0000000000000000000000000000000000000000..e18cf2ee287488ed0bc2e647a79a81c0ff7a2adb GIT binary patch literal 16 UcmeYW_VHn4U|?Vb690h!02_h@GXMYp literal 0 HcmV?d00001 diff --git a/test/fuzz_backup_corpus/crc_mismatch b/test/fuzz_backup_corpus/crc_mismatch new file mode 100644 index 0000000000000000000000000000000000000000..90d87dfd1bdf52a2327a5cc364a02af58e55af3b GIT binary patch literal 76 zcmeYW_VHn4U|;}Y8@`uq>$mSee*XIX=kLG&3_!zxwgdG70M)S?>i_@% diff --git a/test/fuzz_backup_corpus/valid_countmax b/test/fuzz_backup_corpus/valid_countmax new file mode 100644 index 0000000000000000000000000000000000000000..74d687dabaea98367a1170107849653035a0d9e1 GIT binary patch literal 15376 zcmaLecl6Ko8^-bb3K0!NJ5f|rLWqWz3ZX%TB&#B+S3}V+Dp4p6Erg_{jjW17Dv62~ z6{Vqtwg%Pjx<2zgKHZMfAL-HSJ)Za7>3z<(Rfo21Vf@9v?6zReFtdIQ8Z^+~_|IS1 zrcBv#+m^4eUByb<@37-em8(?UIrGm3$Ts{>$3G}Rt3gMHZDbpHHhpTf>iiedmXSTo zv#ATNR(%(s3~gE2#-2^yE9tIgWogUFHt}rYF^TR5D@VJnY*Ww1pKylmZrGN#yzJqg zjlG;K-2*63TS2y&XQS^mNB1D*l8s zv_8PK6ZVpIyNbP>EIrh0FWS9j-L9hVHAfr3_NJ{tdzjl*B#At2XsAKEkF48Ov8DoT z1aR$ynzC+Jg%FFBE!Cv0CF^#TZ#Y65!)np)E9-WZ%Rfq+81|*zPuA@!J2^4h6yVwk zwPoF|GD8=qY^gSF9a*<4&1-@-gVmwkU)Jra1Z{5EpY{M*x2yE2&5r=McEW+Qjoq$N z7u@`aBh3z^tt;zxmAqHdqhNJu>&d!ZXR9wuCW>(1^B)+g0?v=IDtACJ_#kb-Rirk*6mCOd>Rv zb-OCoRG=rDF^SMb*6pegVv)9jF^SMr*6k|aaD=utFo|%utlL#C|0rz(Fp1Di*6k`g zIWc;Q8IuUjW!v~zcVHgj*xY`(!3^UJHrvQN6NZgm7whbuAOicZBw_a^r`Jn zH9Ly-Xj!+b)CIRc^)%Sgv@K-au9Ejkdb*(n?J=@$SDM!oTAp~)$*o$qIi+no(K7(9 zop7wI+f@nL!R%Pt<7C~g(x-Ol2s@7Ucv-ir)CG6wc&6ca+7o2mu9EjkdKSR76I#l; zT_qlq=-Fm1X-|}OyNW;I4DAFvk@h6o!`-f8FDFYo8&0AU16v^%UFZF^a_tIW{FX%9nt+EZoSt~9R++7sZ~38&FEbGs@*dzqa^ zd%CRKRr=Ikyvk1;IazuMz_k;)%DP=e-)oLu zYSxwZTv@lPND_Iml?Xzo+s;eRS2<2F9*1GLU&oWt9-)|dWBhc z+8(lQSGoM7^h#I{+Mcp*SJ}yl(E)~@w7q2At};Uxr&j@7JE6DrYv_M(*Ssd^)n>hE z&zE((DnSRr&ZoUV*6k{N>cByU3urHtb-PMk@W4UW09-qvkF48O@?J^*XV!Jb1`; zW*5`;mvy^J-Ye<#u>Q1{(E8uo6OT#s2E!$^m&&?b#h-A74h6V&LXdU4ioKjHy^)Z= zFOzk_aSH+qN^d^97CtNA(c2x+mNN+Z~l6HWs z+f}~d2ps_%Kzo&}+f^?AC>?3IiuP(*x2x>r#ONr1YbOk(^}n}ghAvKTF&jubNY?F2 z^O~Ty!UoY^BkOimf{r#^L;F8jx2yE2qi+MacEYu?Zda)b9(~*GX4ldVmUX*I-Ye-H zu)(xLWZkYbuS0JfHvFcWM~obG%dMh!8ivqbC+l`qf{p>WcEa_tZdd73$J}LhJ?#y$ zZda)b9&^{-up4NH()!=qllMw`k6|e7jk0c6iN_>*FQBzEOxEox{)991KC@x8!)4vB zVlO94$HIow-X!aG6@9NcI?iwt?ai`oSCJ(0bUeVd6Gq6oT@`C8(EH6s(2kUKyDEfO zq!VBxX-CPrUF92&(20gow71B*UFGtR(n$c{i;*vTj$Ip^MYW zu+g-)$+}%>UK8{|!)>&;%eq~api=;@op6V&+g1A1DG!<5L3^jH+g0j`vM- zvTj$&dnJ9uFoyOnS+^_A>wRO#jlX}w#7PfKeo*vLfNLk*E$enwf<9(;H|;&LZdd73 zADarhhxT4t|9gAtf*+guxZz&f`()j&lJ`pb1i-Zu#>%=~B_5OLlV)RS$H}@~#h-A7 zJ_Q>`J6_i9D)w@+^l8I*+WTeQuA=WXN1p+>cESW%x2s4JdHSr`1lozRZdb*c3UnH5 zBJCtux2r;kMLOLuiS_|mx2t@^5jq3l+6j|s{qOC${G;?av&pm%%DP=;CnrW{!XBiZ zBI|aQ8M-)~Wtc+ykgVI4<~2cQ16(`dVOh7U5_FE)!?cgcx?QDDoii8q2<@Y?Zda)b zo-_A(!=tp1$+}%7@0IigfNLjAm36z)ygv2xGtW+&KI6HWvu2CFXf~Dhaap&k5_BHy zaoQ(n{qODRQ|HY$JVE=UtlL%Ug6GYD3EsYbVU4^}o008;;P|&1TZhl6AYvV6$jv%eq}< zCnrYVFwCZ%BkOjR8M-)K3UKX&xw39on%4w<(`+v7^RjMNCFnBP^RzF>x?QDDUAElt z0_}^kZda)bUbg%#fNLkrlXbgF-Ye*43*K7gpLRq(~AAGoK^+z9n zvS#h4pNW2DSV{Y?tlL!yx*p)#3Gd0eU8PT5|FzkBwC~HhU8OE~{ny{X-lzRQ*6k{J zucY4^KA`r>h zmiALwx2s(KQTj8$wG%#*b-T(=PK^Fy_8ILuS+}dq(8cMmuywSb%eq}@UK8{;!{@YL z$huvXpql}%o$#fs+g1A1&A*#{N&A(o+g0j?_*!wEp+@pPu;Q=_ATvqvTj$Y3*NHz zZ^L)A-^;pPCGVB=AAoBoY>;)kN<1dff6X?~Zj^Pqia+5jg>9tWBBQcvap|Mf0lK-Duh_1$nf7;Cw=2zS zf>s2$cETUBZdWB}C9^+h|CDvRN}pP3d)S|}f6@Bi+fx_J9||=5MY~1T?J9Y%`guZt zYbR`#b-T*UYvKo6&9>71E$eorQBTmyu)k^lk#)N&L8}=4q5W6Z?J9k0m8t;OPWV5x C6>dua literal 0 HcmV?d00001 diff --git a/test/fuzz_backup_corpus/valid_flags b/test/fuzz_backup_corpus/valid_flags new file mode 100644 index 0000000000000000000000000000000000000000..f4b2c2b2b9ec37288d97b767b3b58254fa702bb8 GIT binary patch literal 256 zcmeYW_VHn4U|?VY;%^)c=eZd2)6(Kgi&Kk$0$728p^>qPshPQjrIodft)0DtqmwgO zVFEW;Z(>SrW*%0Z3=AN(F0O9w9-dy_KE8hb0f9lm42(eg6L=W%^YB{B08$$g8WtWA x85JE98yBCDn3T-Ozy#FG3(||vS_Y8Xl+?8JjLfX;oZP(pg2JL=Mn-0!UI4SaEAjvU literal 0 HcmV?d00001 diff --git a/test/fuzz_corpus/import_fail b/test/fuzz_corpus/import_fail new file mode 100644 index 0000000..79605c2 --- /dev/null +++ b/test/fuzz_corpus/import_fail @@ -0,0 +1,2 @@ +login 0 0 +import-keys 776t3gEAAAAAAAAA/////w== diff --git a/test/gen_backup_seeds.c b/test/gen_backup_seeds.c new file mode 100644 index 0000000..2c9701b --- /dev/null +++ b/test/gen_backup_seeds.c @@ -0,0 +1,173 @@ +/* + * Generator for the curated backup_import() seed corpus. + * + * backup_import() gates an untrusted blob through a fixed sequence of + * validation branches (size, magic, version, key_count bound, truncation, + * whole-backup CRC) before the delete-existing / per-key write loop. libFuzzer + * mutating random bytes almost never assembles a header that passes each gate, + * so the deep branches stay unexercised. This host helper hand-crafts ONE blob + * per branch and, crucially, computes the valid cases' CRC with the firmware's + * OWN checksum routine (lfs_crc over the packed backup_key_t records, seed + * 0xFFFFFFFF, no final xor -- identical to storage/backup.c:backup_checksum), + * so the "valid" blobs genuinely pass validation and drive the write path. + * + * Build + run via `make -C test gen-backup-seeds`; it writes the blobs into + * fuzz_backup_corpus/ (argv[1], default that dir). The emitted files are the + * committed seed corpus -- this .c is their regenerable source of truth. + */ + +#include +#include +#include +#include + +#include "lfs_util.h" /* lfs_crc -- the firmware's CRC-32 */ +#include "storage/backup.h" /* backup_header_t, backup_key_t, magic/version */ +#include "storage/storage.h" /* KEY_MAX_COUNT, KEY_NAME_MAX, KEY_SECRET_LEN */ + +/* Mirror storage/backup.c:backup_checksum EXACTLY (seed 0xFFFFFFFF, no xorout). */ +static uint32_t backup_checksum(const backup_key_t *keys, uint32_t count) { + uint32_t crc = 0xFFFFFFFF; + crc = lfs_crc(crc, keys, count * sizeof(backup_key_t)); + return crc; +} + +static const char *g_dir; + +static void emit(const char *name, const void *data, size_t len) { + char path[512]; + snprintf(path, sizeof(path), "%s/%s", g_dir, name); + FILE *f = fopen(path, "wb"); + if (!f) { + perror(path); + exit(1); + } + if (len && fwrite(data, 1, len, f) != len) { + perror("fwrite"); + exit(1); + } + fclose(f); + printf(" wrote %-18s %4zu bytes\n", name, len); +} + +/* Fill one backup_key_t record with recognisable, distinct content. */ +static backup_key_t make_key(uint16_t id, const char *name, uint8_t secret_base, int is_enabled, + int is_admin, uint32_t created_at) { + backup_key_t k; + memset(&k, 0, sizeof(k)); + k.id = id; + strncpy(k.name, name, sizeof(k.name)); /* zero-padded, may be non-terminated at max */ + for (int i = 0; i < KEY_SECRET_LEN; i++) + k.secret[i] = (uint8_t)(secret_base + i); + k.is_enabled = (bool)is_enabled; + k.is_admin = (bool)is_admin; + k.created_at = created_at; + return k; +} + +/* Serialise header + keys into buf, filling in the correct CRC. Returns length. */ +static size_t build(uint8_t *buf, uint32_t magic, uint32_t version, uint32_t key_count, + const backup_key_t *keys, uint32_t nkeys, int corrupt_crc) { + backup_header_t hdr; + hdr.magic = magic; + hdr.version = version; + hdr.key_count = key_count; + hdr.checksum = backup_checksum(keys, nkeys); + if (corrupt_crc) + hdr.checksum ^= 0xA5A5A5A5u; /* deliberately wrong */ + memcpy(buf, &hdr, sizeof(hdr)); + memcpy(buf + sizeof(hdr), keys, nkeys * sizeof(backup_key_t)); + return sizeof(hdr) + nkeys * sizeof(backup_key_t); +} + +int main(int argc, char **argv) { + g_dir = (argc > 1) ? argv[1] : "fuzz_backup_corpus"; + + /* Big enough for KEY_MAX_COUNT records. */ + static uint8_t buf[sizeof(backup_header_t) + (KEY_MAX_COUNT + 4) * sizeof(backup_key_t)]; + size_t n; + + /* --- pre-CRC validation gates (empty key set, so CRC is that of 0 bytes) --- */ + + /* 1. empty: size < header -> "buffer too small" (backup.c:90 true). */ + emit("empty", buf, 0); + + /* 2. short_header: 8 bytes, still < sizeof(header)=16 -> same 90 branch, nonzero. */ + n = build(buf, BACKUP_MAGIC, BACKUP_VERSION, 0, NULL, 0, 0); + emit("short_header", buf, 8); + + /* 3. bad_magic: full header, wrong magic -> "bad magic" (backup.c:97 true). */ + n = build(buf, 0xDEADBEEFu, BACKUP_VERSION, 0, NULL, 0, 0); + emit("bad_magic", buf, n); + + /* 4. bad_version: magic OK, version 2 -> "unsupported version" (backup.c:101 true). */ + n = build(buf, BACKUP_MAGIC, BACKUP_VERSION + 1, 0, NULL, 0, 0); + emit("bad_version", buf, n); + + /* 5. count_over_max: key_count = KEY_MAX_COUNT+1 -> "too many keys" (backup.c:106 + * true). Checked before truncation, so a bare header is enough. */ + n = build(buf, BACKUP_MAGIC, BACKUP_VERSION, KEY_MAX_COUNT + 1, NULL, 0, 0); + emit("count_over_max", buf, n); + + /* 6. body_truncated: claims 3 keys but supplies only the header -> size < expected + * "truncated data" (backup.c:112 true). */ + n = build(buf, BACKUP_MAGIC, BACKUP_VERSION, 3, NULL, 0, 0); + emit("body_truncated", buf, sizeof(backup_header_t)); + + /* --- CRC gate --- */ + + /* 7. crc_mismatch: one full key record but a deliberately wrong checksum -> + * "backup checksum mismatch" (backup.c:121 true). */ + { + backup_key_t k = make_key(7, "crc", 0x10, 1, 0, 0x11223344u); + n = build(buf, BACKUP_MAGIC, BACKUP_VERSION, 1, &k, 1, /*corrupt_crc=*/1); + emit("crc_mismatch", buf, n); + } + + /* --- valid blobs that reach the write loop (backup.c:134) + to_key_record --- */ + + /* 8. valid_0keys: valid header, zero keys -> success, write loop skipped. */ + n = build(buf, BACKUP_MAGIC, BACKUP_VERSION, 0, NULL, 0, 0); + emit("valid_0keys", buf, n); + + /* 9. valid_1key: single enabled admin key -> success + to_key_record once. */ + { + backup_key_t k = make_key(1, "solo", 0x20, /*enabled=*/1, /*admin=*/1, 0x50000001u); + n = build(buf, BACKUP_MAGIC, BACKUP_VERSION, 1, &k, 1, 0); + emit("valid_1key", buf, n); + } + + /* 10. valid_flags: four keys spanning every (is_enabled, is_admin) combination, + * so to_key_record copies both booleans in both states. */ + { + backup_key_t ks[4]; + ks[0] = make_key(10, "off_user", 0x30, 0, 0, 0x60000000u); + ks[1] = make_key(11, "off_admin", 0x40, 0, 1, 0x60000001u); + ks[2] = make_key(12, "on_user", 0x50, 1, 0, 0x60000002u); + ks[3] = make_key(13, "on_admin", 0x60, 1, 1, 0x60000003u); + n = build(buf, BACKUP_MAGIC, BACKUP_VERSION, 4, ks, 4, 0); + emit("valid_flags", buf, n); + } + + /* 11. valid_countmax: key_count == KEY_MAX_COUNT (the upper boundary that PASSES + * the >MAX check) with a correct CRC -> a full 256-iteration write loop that + * SUCCEEDS. littlefs stores the ~60-byte records inline, so all 256 keys fit + * in the 256KB volume (verified: no storage_key_save failure); this is the + * max-capacity round-trip regression. The write-loop failure branch + * (backup.c:136) is therefore NOT reachable from any accepted blob -- it + * needs a genuine flash/storage I/O error, not attacker-controlled bytes. */ + { + static backup_key_t ks[KEY_MAX_COUNT]; + for (int i = 0; i < KEY_MAX_COUNT; i++) { + char nm[KEY_NAME_MAX]; + snprintf(nm, sizeof(nm), "k%03d", i); + ks[i] = make_key((uint16_t)i, nm, (uint8_t)i, i & 1, (i & 2) >> 1, + 0x70000000u + (uint32_t)i); + } + n = build(buf, BACKUP_MAGIC, BACKUP_VERSION, KEY_MAX_COUNT, ks, KEY_MAX_COUNT, 0); + emit("valid_countmax", buf, n); + } + + printf("gen_backup_seeds: wrote curated corpus into %s\n", g_dir); + return 0; +} From a1144442a55d2b571b2361ae0a06f21dbbc304e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:35:55 +0200 Subject: [PATCH 07/14] test: console seeds covering commands_keys handlers (success + error branches) Curated multi-line console seeds (admin via open-mode login) driving every per-handler validation branch in serial/commands_keys.c that random mutation never assembled: keys_idrange id > KEY_ID_MAX (255) for all 9 handlers keys_notfound key-not-found / does-not-exist on every handler keys_idempotent already-enabled / already-disabled / already-admin / not-admin / already-exists keys_nametoolong name >= KEY_NAME_MAX on add + rename keys_display admin+disabled and non-admin key states in get-key/list-keys keys_zerodate import a created_at==0 key -> "unknown" date display branch (get-key + list-keys); crafted with the firmware's own lfs_crc + base64 so the blob passes validation Dict extended with the key-command error arg shapes (id 300/200, over-length name) so libFuzzer can splice them onto any handler. commands_keys.c (reproducible from the committed corpus alone): 61.6% -> 93.1% lines, 62.3% -> 88.7% branches. Remaining uncovered branches are all HW/unreachable via host console fuzzing: storage_key_save/delete failure else-branches (littlefs never fails on the 256KB host window, all keys fit inline), the corrupt-checksum display in get-key/get-key-secret/list-keys (storage_key_save always writes a valid checksum; corruption is flash-only), storage_key_list<0, and the QR-generation failure (the otpauth URI is always well-formed within qrcodegen capacity). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/fuzz_console.dict | 8 ++++++++ test/fuzz_corpus/keys_display | 8 ++++++++ test/fuzz_corpus/keys_idempotent | 10 ++++++++++ test/fuzz_corpus/keys_idrange | 10 ++++++++++ test/fuzz_corpus/keys_nametoolong | 3 +++ test/fuzz_corpus/keys_notfound | 9 +++++++++ test/fuzz_corpus/keys_zerodate | 4 ++++ 7 files changed, 52 insertions(+) create mode 100644 test/fuzz_corpus/keys_display create mode 100644 test/fuzz_corpus/keys_idempotent create mode 100644 test/fuzz_corpus/keys_idrange create mode 100644 test/fuzz_corpus/keys_nametoolong create mode 100644 test/fuzz_corpus/keys_notfound create mode 100644 test/fuzz_corpus/keys_zerodate diff --git a/test/fuzz_console.dict b/test/fuzz_console.dict index a77d2d5..7712934 100644 --- a/test/fuzz_console.dict +++ b/test/fuzz_console.dict @@ -40,6 +40,14 @@ " 256 " "123456" +# --- key-command arg shapes: error branches (id-out-of-range, nonexistent id, +# over-length name) so mutation can splice them onto any key handler --- +" 300 " +" 200 " +"add-key 300 x\x0a" +"delete-key 200\x0a" +" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa " + # --- backup blob magic / base64 prefix (storage/backup.h BACKUP_MAGIC) --- "HSLL" "SFNMTA" diff --git a/test/fuzz_corpus/keys_display b/test/fuzz_corpus/keys_display new file mode 100644 index 0000000..cab67f7 --- /dev/null +++ b/test/fuzz_corpus/keys_display @@ -0,0 +1,8 @@ +login 0 0 +add-key 30 gate +set-key-admin 30 +disable-key 30 +add-key 31 door +get-key 30 +get-key 31 +list-keys diff --git a/test/fuzz_corpus/keys_idempotent b/test/fuzz_corpus/keys_idempotent new file mode 100644 index 0000000..3898457 --- /dev/null +++ b/test/fuzz_corpus/keys_idempotent @@ -0,0 +1,10 @@ +login 0 0 +add-key 10 door +enable-key 10 +add-key 10 dup +set-key-admin 10 +set-key-admin 10 +unset-key-admin 10 +unset-key-admin 10 +disable-key 10 +disable-key 10 diff --git a/test/fuzz_corpus/keys_idrange b/test/fuzz_corpus/keys_idrange new file mode 100644 index 0000000..2f5c3ea --- /dev/null +++ b/test/fuzz_corpus/keys_idrange @@ -0,0 +1,10 @@ +login 0 0 +get-key 300 +get-key-secret 300 +add-key 300 x +rename-key 300 x +enable-key 300 +disable-key 300 +delete-key 300 +set-key-admin 300 +unset-key-admin 300 diff --git a/test/fuzz_corpus/keys_nametoolong b/test/fuzz_corpus/keys_nametoolong new file mode 100644 index 0000000..7aaedf3 --- /dev/null +++ b/test/fuzz_corpus/keys_nametoolong @@ -0,0 +1,3 @@ +login 0 0 +add-key 20 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +rename-key 20 bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb diff --git a/test/fuzz_corpus/keys_notfound b/test/fuzz_corpus/keys_notfound new file mode 100644 index 0000000..936c4f0 --- /dev/null +++ b/test/fuzz_corpus/keys_notfound @@ -0,0 +1,9 @@ +login 0 0 +get-key 200 +get-key-secret 200 +rename-key 200 name +enable-key 200 +disable-key 200 +set-key-admin 200 +unset-key-admin 200 +delete-key 200 diff --git a/test/fuzz_corpus/keys_zerodate b/test/fuzz_corpus/keys_zerodate new file mode 100644 index 0000000..baf7878 --- /dev/null +++ b/test/fuzz_corpus/keys_zerodate @@ -0,0 +1,4 @@ +login 0 0 +import-keys SFNMTAEAAAABAAAAFj2YXygAYWdlZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAgYKDhIWGh4iJiouMjY6PkJGSkwEAAAAAAA== +get-key 40 +list-keys From 21503f70348d953af01b52bdccb958b6afb69ca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:47:58 +0200 Subject: [PATCH 08/14] test: cover cmd_login clock-gated branches + commands_network via injectable-clock console seeds Make the console fuzzer's clock/ntp/wifi environment injectable (fuzz_env_t g_env, read by time_us_64 / clock_get_unix_time / ntp_* / wifi_connect) and replay every corpus input under 5 environments: RTC set (original, keeps the precomputed-TOTP login seeds valid), RTC-not-set within/past the boot-bypass window, and RTC-set with ntp/wifi success/failure. This reaches cmd_login's previously-unreachable RTC-bypass branches without breaking the fixed-clock TOTP accept/reject tails. New seeds: login_clockgate (bootstrap + both bypass-window sides), login_badkey (invalid-cred + disabled/non-admin key), reboot (cmd_reboot body), status_disabled (enabled+disabled key counting), wifi_argerr (set-wifi ssid/password too long). commands_system.c: 63.7% -> 98.2% lines, 56.8% -> 93.2% branches. commands_network.c: 60.6% -> 93.9% lines, 42.9% -> 85.7% branches. Residuals are HW-only: corrupt-checksum keys, storage_wifi_set flash-write failure, and the post-watchdog_reboot spin loop (harness longjmps out). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/fuzz_console.c | 104 ++++++++++++++++++++++--------- test/fuzz_corpus/login_badkey | 12 ++++ test/fuzz_corpus/login_clockgate | 4 ++ test/fuzz_corpus/reboot | 2 + test/fuzz_corpus/status_disabled | 5 ++ test/fuzz_corpus/wifi_argerr | 3 + 6 files changed, 101 insertions(+), 29 deletions(-) create mode 100644 test/fuzz_corpus/login_badkey create mode 100644 test/fuzz_corpus/login_clockgate create mode 100644 test/fuzz_corpus/reboot create mode 100644 test/fuzz_corpus/status_disabled create mode 100644 test/fuzz_corpus/wifi_argerr diff --git a/test/fuzz_console.c b/test/fuzz_console.c index 858e974..c968db5 100644 --- a/test/fuzz_console.c +++ b/test/fuzz_console.c @@ -108,28 +108,49 @@ void sleep_ms(uint32_t ms) { (void)ms; /* cmd_test would sleep 1s on hardware; skip it */ } +/* --- injectable clock / ntp / wifi environment ----------------------------- + * The console command logic branches on RTC state (clock_get_unix_time()==0 → + * boot-bypass window), boot uptime (time_us_64() vs BOOT_BYPASS_WINDOW_US), + * NTP sync state and wifi-connect success. A single hardcoded environment + * leaves those branches unreachable. Instead the stubs read a per-run config + * (g_env); LLVMFuzzerTestOneInput replays each input under EVERY config in + * ENVS[] (see below), so one corpus entry drives all sides of these gates. + * ENVS[0] keeps the original {clock=1700000000, time>=WINDOW} environment so + * the precomputed TOTP seeds (code 218873, built for that clock) still reach + * the login accept/reject tails. */ +typedef struct { + uint32_t clock_unix; /* clock_get_unix_time(): 0 == RTC not set */ + uint64_t time_us; /* time_us_64(): boot uptime, vs BOOT_BYPASS_WINDOW_US */ + bool ntp_sync_ok; /* ntp_sync() result */ + bool wifi_conn_ok; /* wifi_connect() result */ + bool ntp_synced; /* ntp_is_synced() */ + uint32_t ntp_last; /* ntp_last_sync_time() */ +} fuzz_env_t; + +static fuzz_env_t g_env = {1700000000u, 1234567890ULL, false, false, false, 0}; + uint64_t time_us_64(void) { - return 1234567890ULL; /* fixed uptime / boot-bypass clock */ + return g_env.time_us; } uint32_t clock_get_unix_time(void) { - return 1700000000u; /* fixed, non-zero: RTC "set", enables the TOTP path */ + return g_env.clock_unix; } bool ntp_is_synced(void) { - return false; + return g_env.ntp_synced; } uint32_t ntp_last_sync_time(void) { - return 0; + return g_env.ntp_last; } bool ntp_sync(void) { - return false; + return g_env.ntp_sync_ok; } bool wifi_connect(const char *ssid, const char *password) { (void)ssid; (void)password; - return false; + return g_env.wifi_conn_ok; } void pico_get_unique_board_id(pico_unique_board_id_t *id_out) { @@ -191,31 +212,56 @@ int LLVMFuzzerInitialize(int *argc, char ***argv) { return 0; } +/* Clock / ntp / wifi environments each corpus input is replayed under. Every + * input runs once per entry, so a single seed reaches all sides of the + * RTC-bypass window, the NTP-synced and wifi-connect branches, etc. + * [0] RTC set, uptime past bypass window, all HW ops fail — the ORIGINAL + * environment; the precomputed-TOTP login seeds (clock==1700000000) hit + * their accept/reject tails only here. + * [1] RTC not set, WITHIN the bypass window -> cmd_login "try again" error. + * [2] RTC not set, PAST the bypass window -> cmd_login RTC-open mode. + * [3] RTC set, everything succeeds -> ntp synced/last-sync, + * sync-ntp ok, set-wifi connect ok + ntp ok. + * [4] RTC set, wifi connect ok but ntp fails -> set-wifi "ntp sync failed". */ +static const fuzz_env_t ENVS[] = { + {1700000000u, 1234567890ULL, false, false, false, 0}, + {0u, 1000ULL, false, false, false, 0}, + {0u, 400000000ULL, false, false, false, 0}, + {1700000000u, 1234567890ULL, true, true, true, 1700000000u}, + {1700000000u, 1234567890ULL, false, true, false, 0}, +}; +#define NUM_ENVS (sizeof(ENVS) / sizeof(ENVS[0])) + int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { - /* Reset persistent state so this input reproduces deterministically. */ - flash_ram_reset(); - if (!storage_init()) - return 0; /* format/mount failure is not an input-driven bug */ - admin_mode = false; - g_rng_state = RNG_SEED; /* first add-key of each run => fixed secret */ - - g_data = data; - g_size = size; - g_pos = 0; - g_connected = true; - - if (setjmp(g_reboot_jmp) == 0) { - /* First call connects (prints banner, consumes no byte); subsequent - * calls each read at most one byte. Drive until the input is drained. */ - console_task(); /* connect */ - while (g_pos < g_size) - console_task(); + for (size_t e = 0; e < NUM_ENVS; e++) { + g_env = ENVS[e]; + + /* Reset persistent state so this input reproduces deterministically + * under each environment (independent of the previous env's run). */ + flash_ram_reset(); + if (!storage_init()) + continue; /* format/mount failure is not an input-driven bug */ + admin_mode = false; + g_rng_state = RNG_SEED; /* first add-key of each run => fixed secret */ + + g_data = data; + g_size = size; + g_pos = 0; + g_connected = true; + + if (setjmp(g_reboot_jmp) == 0) { + /* First call connects (prints banner, consumes no byte); subsequent + * calls each read at most one byte. Drive until input is drained. */ + console_task(); /* connect */ + while (g_pos < g_size) + console_task(); + } + + /* Simulate USB disconnect: clears admin + zeroes the console input_len, + * so no console state leaks into the next run. */ + g_connected = false; + console_task(); } - /* Simulate USB disconnect: clears admin + zeroes the console input_len, - * so no console state leaks into the next run. */ - g_connected = false; - console_task(); - return 0; } diff --git a/test/fuzz_corpus/login_badkey b/test/fuzz_corpus/login_badkey new file mode 100644 index 0000000..48bd988 --- /dev/null +++ b/test/fuzz_corpus/login_badkey @@ -0,0 +1,12 @@ +login 0 0 +add-key 5 adm +set-key-admin 5 +add-key 1 dis +set-key-admin 1 +disable-key 1 +add-key 2 usr +set-wifi s p +logout +login 9 111111 +login 2 111111 +login 1 111111 diff --git a/test/fuzz_corpus/login_clockgate b/test/fuzz_corpus/login_clockgate new file mode 100644 index 0000000..e0ea77b --- /dev/null +++ b/test/fuzz_corpus/login_clockgate @@ -0,0 +1,4 @@ +login 0 0 +set-wifi s p +logout +login 0 0 diff --git a/test/fuzz_corpus/reboot b/test/fuzz_corpus/reboot new file mode 100644 index 0000000..83a64b9 --- /dev/null +++ b/test/fuzz_corpus/reboot @@ -0,0 +1,2 @@ +login 0 0 +reboot diff --git a/test/fuzz_corpus/status_disabled b/test/fuzz_corpus/status_disabled new file mode 100644 index 0000000..29abd85 --- /dev/null +++ b/test/fuzz_corpus/status_disabled @@ -0,0 +1,5 @@ +login 0 0 +add-key 1 x +add-key 2 y +disable-key 2 +status diff --git a/test/fuzz_corpus/wifi_argerr b/test/fuzz_corpus/wifi_argerr new file mode 100644 index 0000000..9b12710 --- /dev/null +++ b/test/fuzz_corpus/wifi_argerr @@ -0,0 +1,3 @@ +login 0 0 +set-wifi aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa p +set-wifi s bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb From e5303c0fa89a64c9105ea490b99fe5600cd03b35 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 07:56:20 +0200 Subject: [PATCH 09/14] test: console.c edge-case seeds (buffer-full, arg overflow, backspace, control bytes) Drive serial/console.c to 100% line/branch/function coverage under the committed corpus. Adds curated raw-byte seeds for every input state-machine edge: all-whitespace / empty line (argc==0 early return), leading/trailing whitespace tokeniser paths, CR terminator, control-byte drop (<32), backspace/DEL at input_len==0 (guard false) and mid-line (guard true), >MAX_ARGS token overflow, and a >255-char line exercising the input_len --- test/fuzz_console.c | 14 ++++++++++++++ test/fuzz_corpus/edge_argoverflow | 1 + test/fuzz_corpus/edge_bs_empty | 1 + test/fuzz_corpus/edge_bs_mid | 1 + test/fuzz_corpus/edge_buffull | 1 + test/fuzz_corpus/edge_cr | 1 + test/fuzz_corpus/edge_ctrl | 1 + test/fuzz_corpus/edge_emptyline | 2 ++ test/fuzz_corpus/edge_ws_around | 1 + test/fuzz_corpus/edge_ws_only | 1 + 10 files changed, 24 insertions(+) create mode 100644 test/fuzz_corpus/edge_argoverflow create mode 100644 test/fuzz_corpus/edge_bs_empty create mode 100644 test/fuzz_corpus/edge_bs_mid create mode 100644 test/fuzz_corpus/edge_buffull create mode 100644 test/fuzz_corpus/edge_cr create mode 100644 test/fuzz_corpus/edge_ctrl create mode 100644 test/fuzz_corpus/edge_emptyline create mode 100644 test/fuzz_corpus/edge_ws_around create mode 100644 test/fuzz_corpus/edge_ws_only diff --git a/test/fuzz_console.c b/test/fuzz_console.c index c968db5..cf445b8 100644 --- a/test/fuzz_console.c +++ b/test/fuzz_console.c @@ -209,6 +209,10 @@ int LLVMFuzzerInitialize(int *argc, char ***argv) { perror("freopen stdout"); return -1; } + + /* console_init() is a near-no-op (USB stdio is brought up in CMakeLists on + * hardware); call it once so it is exercised by the coverage build. */ + console_init(); return 0; } @@ -255,12 +259,22 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { console_task(); /* connect */ while (g_pos < g_size) console_task(); + /* One more poll with the input drained but still connected: getchar + * returns PICO_ERROR_TIMEOUT, exercising the idle-poll early return + * (the common case on hardware, where most polls have no byte). */ + console_task(); } /* Simulate USB disconnect: clears admin + zeroes the console input_len, * so no console state leaks into the next run. */ g_connected = false; console_task(); + + /* A second poll while still disconnected: was_connected is now false, so + * both the just-disconnected block and the connect block are skipped and + * console_task falls through to the `if (!connected) return;` guard — + * the only path that reaches it (a disconnected idle poll). */ + console_task(); } return 0; diff --git a/test/fuzz_corpus/edge_argoverflow b/test/fuzz_corpus/edge_argoverflow new file mode 100644 index 0000000..f1811b5 --- /dev/null +++ b/test/fuzz_corpus/edge_argoverflow @@ -0,0 +1 @@ +a b c d e f g h i j k l diff --git a/test/fuzz_corpus/edge_bs_empty b/test/fuzz_corpus/edge_bs_empty new file mode 100644 index 0000000..e44e4d6 --- /dev/null +++ b/test/fuzz_corpus/edge_bs_empty @@ -0,0 +1 @@ +help diff --git a/test/fuzz_corpus/edge_bs_mid b/test/fuzz_corpus/edge_bs_mid new file mode 100644 index 0000000..2cc732d --- /dev/null +++ b/test/fuzz_corpus/edge_bs_mid @@ -0,0 +1 @@ +abx diff --git a/test/fuzz_corpus/edge_buffull b/test/fuzz_corpus/edge_buffull new file mode 100644 index 0000000..768dcfd --- /dev/null +++ b/test/fuzz_corpus/edge_buffull @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/test/fuzz_corpus/edge_cr b/test/fuzz_corpus/edge_cr new file mode 100644 index 0000000..d3c80b2 --- /dev/null +++ b/test/fuzz_corpus/edge_cr @@ -0,0 +1 @@ +help \ No newline at end of file diff --git a/test/fuzz_corpus/edge_ctrl b/test/fuzz_corpus/edge_ctrl new file mode 100644 index 0000000..4fb9b42 --- /dev/null +++ b/test/fuzz_corpus/edge_ctrl @@ -0,0 +1 @@ +help diff --git a/test/fuzz_corpus/edge_emptyline b/test/fuzz_corpus/edge_emptyline new file mode 100644 index 0000000..4db5c85 --- /dev/null +++ b/test/fuzz_corpus/edge_emptyline @@ -0,0 +1,2 @@ + +help diff --git a/test/fuzz_corpus/edge_ws_around b/test/fuzz_corpus/edge_ws_around new file mode 100644 index 0000000..1582a6e --- /dev/null +++ b/test/fuzz_corpus/edge_ws_around @@ -0,0 +1 @@ + help diff --git a/test/fuzz_corpus/edge_ws_only b/test/fuzz_corpus/edge_ws_only new file mode 100644 index 0000000..bac9c71 --- /dev/null +++ b/test/fuzz_corpus/edge_ws_only @@ -0,0 +1 @@ + From 168fbc374674ea0f4677cb933d82ac80fef3c5c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 08:04:58 +0200 Subject: [PATCH 10/14] test: cover storage.c reachable branches (mount-fail/format, list-truncate, nonexistent) + document HW residuals Add test/fuzz_storage.c, a direct libFuzzer harness for storage/storage.c's public CRUD API on the RAM-mapped XIP window, wired into `make -C test fuzz` and the fuzz-cov measurement (new cov_storage replay). It reaches the storage branches the console/backup fuzzers structurally can't: - pre-mount `!mounted` guard-true arm of every public op (driven before the first storage_init in LLVMFuzzerInitialize); - storage_init mount-succeeds-first-try + ensure_dirs dir-already-exists paths (a second storage_init on the just-formatted fs); - storage_key_list max_count>KEY_MAX_COUNT clamp + count==max truncation exit (no firmware caller passes a max other than BACKUP_MAX_KEYS); - storage_wifi_clear (no firmware caller at all). storage.c fuzz-cov: 84.2%->93.2% lines, 55.6%->79.6% branches, 95%->100% funcs (storage_wifi_clear was the last uncovered surface function; the surface is now 100% funcs). Surface total 93.0%->94.8% lines, 85.8%->89.8% branches. The 11 residual uncovered storage.c branches are all genuine HW/flash-error arms unreachable on the host RAM window: flash_prog/erase LFS_ERR_IO (127/134), lfs_format + remount failure (204/208), ensure_dirs mkdir failure (212), wifi set / key save opencfg failure (246/307), key_get short-read + app-checksum mismatch (286/291 - littlefs's own block CRC sits under our layer, so a valid-littlefs record always carries the checksum storage_key_save wrote), lfs_dir_open failure (331), and per-entry key_get failure in the list loop (341). No crash/hang in a 60s ASan+UBSan campaign (28334 runs). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/Makefile | 27 +++- test/fuzz_storage.c | 186 ++++++++++++++++++++++++++++ test/fuzz_storage_corpus/seed_basic | 1 + test/fuzz_storage_corpus/seed_empty | 0 test/fuzz_storage_corpus/seed_mixed | Bin 0 -> 19 bytes 5 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 test/fuzz_storage.c create mode 100644 test/fuzz_storage_corpus/seed_basic create mode 100644 test/fuzz_storage_corpus/seed_empty create mode 100644 test/fuzz_storage_corpus/seed_mixed diff --git a/test/Makefile b/test/Makefile index 46af6e9..48a6e75 100644 --- a/test/Makefile +++ b/test/Makefile @@ -110,7 +110,7 @@ LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov GENHTML_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,negative LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) -.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run fuzz-totp-run fuzz-cov gen-backup-seeds clean check-submodules +.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run fuzz-totp-run fuzz-storage-run fuzz-cov gen-backup-seeds clean check-submodules all: asan @@ -284,8 +284,20 @@ FUZZ_TOTP_SRCS := fuzz_totp.c $(SHARED)/totp.c FUZZ_TOTP_INCLUDES := -I$(ROOT) -I$(ROOT)/hardware -I$(SHARED) -idirafter stub FUZZ_TOTP_LIBS := -lmbedcrypto +# Direct storage.c CRUD harness: fuzz bytes vary key ids/names/wifi while a +# fixed scripted sequence drives storage.c's public API on a fresh RAM-backed +# littlefs. Reaches the storage branches the console/backup fuzzers structurally +# can't: the pre-mount `!mounted` guards, the mount-succeeds-first-try + +# dir-already-exists init paths, storage_key_list's clamp/truncation exits, and +# storage_wifi_clear (no firmware caller). See test/fuzz_storage.c. +FUZZ_STORAGE_BIN := $(BUILD)/fuzz_storage +FUZZ_STORAGE_CORPUS := fuzz_storage_corpus +FUZZ_STORAGE_SRCS := fuzz_storage.c \ + $(ROOT)/storage/storage.c \ + $(ROOT)/libs/littlefs/lfs.c $(ROOT)/libs/littlefs/lfs_util.c + # Build all fuzzers. -fuzz: check-submodules $(FUZZ_BIN) $(FUZZ_BACKUP_BIN) $(FUZZ_TOTP_BIN) +fuzz: check-submodules $(FUZZ_BIN) $(FUZZ_BACKUP_BIN) $(FUZZ_TOTP_BIN) $(FUZZ_STORAGE_BIN) $(FUZZ_BIN): $(FUZZ_SRCS) | $(BUILD) $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_SRCS) $(FUZZ_LIBS) -o $@ @@ -296,6 +308,9 @@ $(FUZZ_BACKUP_BIN): $(FUZZ_BACKUP_SRCS) | $(BUILD) $(FUZZ_TOTP_BIN): $(FUZZ_TOTP_SRCS) | $(BUILD) $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_TOTP_INCLUDES) $(FUZZ_TOTP_SRCS) $(FUZZ_TOTP_LIBS) -o $@ +$(FUZZ_STORAGE_BIN): $(FUZZ_STORAGE_SRCS) | $(BUILD) + $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_STORAGE_SRCS) -o $@ + # Build + run short bounded campaigns from the seed corpora. The console run is # fed the keyword dictionary so mutation can splice real commands together. # Bump FUZZ_TIME for a longer soak: `make -C test fuzz-run FUZZ_TIME=600`. @@ -314,6 +329,11 @@ fuzz-totp-run: $(FUZZ_TOTP_BIN) $(FUZZ_TOTP_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ $(FUZZ_TOTP_CORPUS) +fuzz-storage-run: $(FUZZ_STORAGE_BIN) + @echo "== fuzzing storage CRUD for $(FUZZ_TIME)s ==" + $(FUZZ_STORAGE_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ + $(FUZZ_STORAGE_CORPUS) + # --- Curated backup_import() seed generator --------------------------------- # Regenerates the committed fuzz_backup_corpus/ blobs: one per backup_import # validation/write branch, with the VALID cases carrying a real CRC computed by @@ -361,10 +381,13 @@ fuzz-cov: check-submodules | $(FUZZ_COV_OBJ) $(FUZZ_BACKUP_SRCS) fuzz_replay.c -o $(FUZZ_COV_OBJ)/cov_backup $(CC) $(CSTD) $(WARN) $(COV_FLAGS) -DFUZZ_REPLAY $(FUZZ_TOTP_INCLUDES) \ $(FUZZ_TOTP_SRCS) fuzz_replay.c $(FUZZ_TOTP_LIBS) -o $(FUZZ_COV_OBJ)/cov_totp + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(FUZZ_DEFS) -DFUZZ_REPLAY $(FUZZ_INCLUDES) \ + $(FUZZ_STORAGE_SRCS) fuzz_replay.c -o $(FUZZ_COV_OBJ)/cov_storage @echo "== fuzz-cov: replaying corpora ==" $(FUZZ_COV_OBJ)/cov_console $(FUZZ_CORPUS) $(FUZZ_COV_OBJ)/cov_backup $(FUZZ_BACKUP_CORPUS) $(FUZZ_COV_OBJ)/cov_totp $(FUZZ_TOTP_CORPUS) + $(FUZZ_COV_OBJ)/cov_storage $(FUZZ_STORAGE_CORPUS) @echo "== fuzz-cov: capturing + merging coverage ==" $(FCOV_LCOV) --capture --initial --directory $(FUZZ_COV_OBJ) \ --output-file $(COV_DIR)/fuzz-base.info diff --git a/test/fuzz_storage.c b/test/fuzz_storage.c new file mode 100644 index 0000000..cde1e10 --- /dev/null +++ b/test/fuzz_storage.c @@ -0,0 +1,186 @@ +/* + * libFuzzer harness: storage/storage.c CRUD API, driven directly. + * + * The console and backup fuzzers only ever reach storage.c on a FRESH, + * just-formatted littlefs, and only through the callers that exist in the + * firmware (which always list with max_count == BACKUP_MAX_KEYS and only ever + * touch storage AFTER a successful storage_init). That leaves a cluster of + * storage.c branches host-reachable but never hit by those two fuzzers: + * + * - the pre-mount `if (!mounted) return false` guard-true arm of every public + * op (mounted is a process-static that the other fuzzers set true on their + * first storage_init and never clear); + * - the storage_init mount-SUCCEEDS-first-try path (201 false) + the + * ensure_dirs dir-ALREADY-exists path (184 false / return true, 187) — the + * other fuzzers reset flash to 0xFF every run, so mount always fails and + * /keys never pre-exists; + * - storage_key_list's max_count > KEY_MAX_COUNT clamp (327) and its + * count == max_count truncation exit (337 false) — no firmware caller + * passes a max other than BACKUP_MAX_KEYS, and >256 keys can't exist; + * - storage_wifi_clear (no firmware caller at all). + * + * This harness drives storage.c's public API straight, on the same RAM-mapped + * XIP window trick as fuzz_backup.c. A fixed scripted sequence (independent of + * the fuzz bytes) exercises every host-reachable branch each run; the fuzz + * bytes only vary key ids / names / wifi strings so mutation still explores the + * record encoder + littlefs. Genuine flash-I/O error arms (program/erase/read + * failure, format failure, opencfg failure, checksum bit-rot) are NOT reachable + * here — the 256KB host RAM window never fails an I/O — and are documented as + * HW-only residuals in the plan. + */ + +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include +#include +#include + +#include "hardware/flash.h" +#include "pico/stdlib.h" + +#include "storage/storage.h" + +/* --- storage layout, mirrored from storage.c ------------------------------ */ + +#define STORAGE_SIZE_BYTES (256 * 1024) +#define STORAGE_FLASH_OFFSET (PICO_FLASH_SIZE_BYTES - STORAGE_SIZE_BYTES) +#define STORAGE_WINDOW_BASE ((uintptr_t)(XIP_BASE + STORAGE_FLASH_OFFSET)) + +static uint8_t *flash_ptr(uint32_t flash_offs) { + return (uint8_t *)(uintptr_t)(XIP_BASE + flash_offs); +} + +/* --- RAM-backed flash primitives storage.c drives ------------------------- */ + +void flash_range_program(uint32_t flash_offs, const uint8_t *data, size_t count) { + uint8_t *dst = flash_ptr(flash_offs); + for (size_t i = 0; i < count; i++) + dst[i] &= data[i]; /* NOR: programming can only clear bits */ +} + +void flash_range_erase(uint32_t flash_offs, size_t count) { + memset(flash_ptr(flash_offs), 0xFF, count); +} + +int flash_safe_execute(void (*func)(void *), void *param, uint32_t timeout_ms) { + (void)timeout_ms; + func(param); + return PICO_OK; +} + +/* --- per-run reset -------------------------------------------------------- */ + +static void flash_ram_reset(void) { + memset((void *)STORAGE_WINDOW_BASE, 0xFF, STORAGE_SIZE_BYTES); +} + +int LLVMFuzzerInitialize(int *argc, char ***argv) { + (void)argc; + (void)argv; + + void *base = mmap((void *)STORAGE_WINDOW_BASE, STORAGE_SIZE_BYTES, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); + if (base == MAP_FAILED || (uintptr_t)base != STORAGE_WINDOW_BASE) { + perror("mmap XIP window"); + return -1; + } + + if (!freopen("/dev/null", "w", stdout)) { + perror("freopen stdout"); + return -1; + } + + /* Pre-mount guard arm of every public op: `mounted` is still false here, + * before any storage_init, so each op takes its `if (!mounted) return`. */ + key_record_t k = {0}; + wifi_config_t w = {0}; + storage_key_exists(1); + storage_key_get(1, &k); + storage_key_save(&k); + storage_key_delete(1); + storage_key_list(&k, 1); + storage_wifi_get(&w); + storage_wifi_set(&w); + storage_wifi_clear(); + return 0; +} + +static key_record_t make_key(uint16_t id, const char *name, bool enabled, bool admin, + uint32_t created, uint8_t seed) { + key_record_t k = {0}; + k.id = id; + k.is_enabled = enabled; + k.is_admin = admin; + k.created_at = created; + snprintf(k.name, sizeof k.name, "%s", name); + for (int i = 0; i < KEY_SECRET_LEN; i++) + k.secret[i] = (uint8_t)(seed + i); + return k; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + flash_ram_reset(); + + /* First init: mount fails on 0xFF flash -> format -> mount -> mkdir /keys. */ + if (!storage_init()) + return 0; + + /* Second init on the now-valid fs: mount SUCCEEDS first try (201 false) and + * ensure_dirs finds /keys already present (184 false -> 187 return true). */ + if (!storage_init()) + return 0; + + /* Fuzz-byte-driven variety: ids/name kept in range, rest is scripted. */ + uint16_t id0 = (uint16_t)((size > 0 ? data[0] : 0x11) % KEY_MAX_COUNT); + uint16_t id1 = (uint16_t)((size > 1 ? data[1] : 0x22) % KEY_MAX_COUNT); + if (id1 == id0) + id1 = (uint16_t)((id1 + 1) % KEY_MAX_COUNT); + char name[KEY_NAME_MAX]; + size_t nlen = size > sizeof(name) - 1 ? sizeof(name) - 1 : size; + for (size_t i = 0; i < nlen; i++) + name[i] = (data[i] >= 32 && data[i] < 127) ? (char)data[i] : '_'; + name[nlen] = '\0'; + + key_record_t got; + + /* create then update the same id (both storage_key_save arms) */ + key_record_t k0 = make_key(id0, name, true, true, 1700000000u, 0x10); + storage_key_save(&k0); + storage_key_exists(id0); + storage_key_get(id0, &got); + key_record_t k0b = make_key(id0, "updated", false, false, 1700000123u, 0x20); + storage_key_save(&k0b); + storage_key_get(id0, &got); + + /* a second key so list has >1 entry */ + key_record_t k1 = make_key(id1, "second", true, false, 1700000200u, 0x30); + storage_key_save(&k1); + + /* get / delete of a definitely-nonexistent id */ + storage_key_get(60000, &got); + storage_key_delete(60000); + + /* list: truncation exit (max_count 1 with >1 keys, 337 false) ... */ + key_record_t list[KEY_MAX_COUNT]; + storage_key_list(list, 1); + /* ... and the max_count > KEY_MAX_COUNT clamp (327). */ + storage_key_list(list, KEY_MAX_COUNT + 100); + + /* wifi: get-when-unset fails, then set/get/clear/clear-when-gone */ + wifi_config_t w = {0}; + storage_wifi_get(&w); + snprintf(w.ssid, sizeof w.ssid, "ssid-%s", name); + snprintf(w.password, sizeof w.password, "pw-%u", (unsigned)id0); + storage_wifi_set(&w); + storage_wifi_get(&w); + storage_wifi_clear(); + storage_wifi_clear(); + + /* tidy up so the fs never approaches capacity across replays */ + storage_key_delete(id0); + storage_key_delete(id1); + return 0; +} diff --git a/test/fuzz_storage_corpus/seed_basic b/test/fuzz_storage_corpus/seed_basic new file mode 100644 index 0000000..dd8e117 --- /dev/null +++ b/test/fuzz_storage_corpus/seed_basic @@ -0,0 +1 @@ +"admin \ No newline at end of file diff --git a/test/fuzz_storage_corpus/seed_empty b/test/fuzz_storage_corpus/seed_empty new file mode 100644 index 0000000..e69de29 diff --git a/test/fuzz_storage_corpus/seed_mixed b/test/fuzz_storage_corpus/seed_mixed new file mode 100644 index 0000000000000000000000000000000000000000..feb842b4c5f45d9af32215b9710264499bf564af GIT binary patch literal 19 acmey*P_K}en479wo>`Ki%a~kJlmh@seFqEx literal 0 HcmV?d00001 From a2dbd4dbef62f8323d11f372ed87397f162f40c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 08:37:10 +0200 Subject: [PATCH 11/14] test: extended bounded fuzz campaigns (plateau confirmed / fix UBSan bool load in backup_import) Longer coverage-guided campaigns (~4min x4, parallel workers) over the enriched corpora. fuzz_backup surfaced a UBSan finding: to_key_record read backup_key_t.is_enabled/is_admin (bool) directly from the untrusted import blob, so a crafted backup whose flag bytes are neither 0 nor 1 (but whose CRC matches) triggers 'load of value N, not a valid value for type bool' (backup.c:40) - undefined behaviour on the serial import parse path. Read the bytes raw via memcpy and canonicalise to 0/1. Added the minimized reproducer as regression seed fuzz_backup_corpus/nonbool_flags. console/totp/storage campaigns clean (no crash/oom/leak). Surface plateau confirmed at 94.8% lines / 89.8% branches / 100% funcs from the committed corpus; grown units add libFuzzer feature buckets but no new surface edges. Co-Authored-By: Claude Opus 4.8 (1M context) --- storage/backup.c | 12 +++++++++--- test/fuzz_backup_corpus/nonbool_flags | Bin 0 -> 257 bytes 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 test/fuzz_backup_corpus/nonbool_flags diff --git a/storage/backup.c b/storage/backup.c index 2842eb7..9ab3de7 100644 --- a/storage/backup.c +++ b/storage/backup.c @@ -36,9 +36,15 @@ static backup_key_t to_backup_key(const key_record_t *k) { static key_record_t to_key_record(const backup_key_t *b) { key_record_t k; - k.id = b->id; - k.is_enabled = b->is_enabled; - k.is_admin = b->is_admin; + k.id = b->id; + // The backup blob is untrusted input: its is_enabled/is_admin bytes may hold + // any value, so read them as raw bytes and canonicalise. Loading a `bool` + // whose object representation is not 0/1 is undefined behaviour. + uint8_t enabled_raw, admin_raw; + memcpy(&enabled_raw, &b->is_enabled, sizeof(enabled_raw)); + memcpy(&admin_raw, &b->is_admin, sizeof(admin_raw)); + k.is_enabled = (enabled_raw != 0); + k.is_admin = (admin_raw != 0); k.created_at = b->created_at; k.is_checksum_valid = true; memcpy(k.name, b->name, sizeof(k.name)); diff --git a/test/fuzz_backup_corpus/nonbool_flags b/test/fuzz_backup_corpus/nonbool_flags new file mode 100644 index 0000000000000000000000000000000000000000..3328ae676124bb1ad304d4a0b3a8d5e25c219c61 GIT binary patch literal 257 zcmeYW_VHn4U|?VYV*8ijeq0RsbJNn|ON&#BfFf9dfuWJHiK&^ng{76Xjjf%%gQJr( zSYZM;Lq1S%VoGji9#)+U3?Q{Gu5Rugo?hNQzJC4zfkD9xj6eq@@G#`(;kA|lq&6fp zEIcAIDmo@ME Date: Mon, 20 Jul 2026 08:41:16 +0200 Subject: [PATCH 12/14] docs: fuzzing coverage summary + HW-unreachable gap map Co-Authored-By: Claude Opus 4.8 (1M context) --- test/FUZZING.md | 145 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 test/FUZZING.md diff --git a/test/FUZZING.md b/test/FUZZING.md new file mode 100644 index 0000000..0939e88 --- /dev/null +++ b/test/FUZZING.md @@ -0,0 +1,145 @@ +# Fuzzing the hslock parse/logic surface + +Four libFuzzer harnesses exercise every host-reachable byte of hslock's +untrusted-input parse/logic surface. HW-I/O wrappers (`hardware/*`, real +network I/O, `main.c`) are out of scope: they are not fuzzable on the host. + +## The four fuzzers + +| harness | entry point | what it drives | +|---|---|---| +| `fuzz_console.c` | serial console byte stream | `console_process` → tokeniser → command dispatch → every handler in `commands*.c`, on a RAM-backed littlefs. Replays each input under 5 injectable clock/NTP/WiFi environments. | +| `fuzz_backup.c` | raw backup blob | `backup_import` (+ `to_key_record`) directly — header/version/count/CRC validation and the key-write loop. | +| `fuzz_totp.c` | (big-endian time, secret) | `totp_verify` directly against the **real** mbedtls HMAC-SHA1 with an injectable fixed clock: reject, T-1/T/T+1 accept window, full-loop reject. | +| `fuzz_storage.c` | scripted CRUD ids/names | `storage.c` public API (init/save/get/list/delete, wifi set/get/clear) on the RAM-mapped XIP window. | + +All four build with **clang libFuzzer + ASan + UBSan** +(`-fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all`) and link the +real `libmbedcrypto`, so **`libmbedtls-dev` and clang (with the sanitizer/fuzzer +runtime) are required**. If plain `clang` is unavailable use +`make -C test fuzz FUZZ_CC=clang-19`. + +## Running + +```sh +make -C test fuzz # build all four fuzzer binaries +make -C test fuzz-run # run fuzz_console from its committed corpus +make -C test fuzz-backup-run # " fuzz_backup +make -C test fuzz-totp-run # " fuzz_totp +make -C test fuzz-storage-run# " fuzz_storage +make -C test fuzz-cov # gcc --coverage replay of the committed corpora, + # prints the surface coverage table below +make -C test asan # the (clang-independent) ASan+UBSan unit gate +``` + +`fuzz-cov` is a deterministic gcc `--coverage -DFUZZ_REPLAY` build: it replays +the **committed** corpora (`fuzz_corpus`, `fuzz_backup_corpus`, +`fuzz_totp_corpus`, `fuzz_storage_corpus`) through `LLVMFuzzerTestOneInput`, +merges the lcov trace, and restricts it to the 9 surface files. The table is +reproducible from the committed corpus alone — coverage-growth units are not +committed (they add libFuzzer feature buckets but no new surface edges). + +## Final coverage (committed corpora) + +``` + |Lines |Functions |Branches +Filename |Rate Num|Rate Num|Rate Num +=================================================================== +serial/commands.c | 100% 39| 100% 4| 100% 22 +serial/commands_backup.c |87.5% 24| 100% 2|83.3% 6 +serial/commands_keys.c |93.1% 216| 100% 10|88.7% 106 +serial/commands_network.c |93.9% 33| 100% 2|85.7% 14 +serial/commands_system.c |98.2% 113| 100% 6|93.2% 44 +serial/console.c | 100% 59| 100% 4| 100% 46 +shared/totp.c | 100% 25| 100% 2| 100% 6 +storage/backup.c |92.0% 75| 100% 5|84.6% 26 +storage/storage.c |93.2% 146| 100% 20|79.6% 54 +=================================================================== + Total:|94.8% 730| 100% 55|89.8% 324 +``` + +**Surface total: 94.8% lines (692/730), 100% functions (55/55), +89.8% branches (291/324).** This is a documented plateau: 240s +coverage-guided campaigns on all four fuzzers reproduce exactly these numbers. + +## Bugs found and fixed + +Two real firmware bugs surfaced during the campaigns; both are fixed and carry +a regression seed. + +1. **`storage.c` lookahead_size 56-byte global-buffer-overflow.** littlefs was + configured with a lookahead buffer smaller than `lookahead_size` demanded, + so the mount walk wrote past the static buffer. Fixed by sizing the buffer to + `lookahead_size`. (Found before the console/backup campaigns; regressed by + the storage/console harnesses that mount on every run.) + +2. **`backup.c` non-bool load UB (`to_key_record`).** A crafted backup blob + whose `is_enabled`/`is_admin` bytes are neither 0 nor 1 but whose whole-payload + CRC matches passes `backup_import` validation and reached a load of a `bool` + member from an invalid (e.g. `67`) representation — UB on the serial + `import-keys` path (reachable on real HW: user pastes a base64 backup). + UBSan flagged `backup.c:40: load of value 67 … not valid for type 'bool'`. + Fixed by reading the two flag bytes via `memcpy` into `uint8_t` and + canonicalising `!= 0`; no `bool` is ever loaded from a non-0/1 byte. + Regression seed: `fuzz_backup_corpus/nonbool_flags` (257B, CRC-irreducible). + +No other crash/hang/ASan/UBSan finding across the campaigns. + +## Residual uncovered lines/branches — all genuinely HW-unreachable + +Every remaining gap is a flash/littlefs I/O error arm, a corrupt-checksum +display that needs sub-littlefs bit-rot, the post-watchdog reboot spin-loop, or +an export-fail arm that no caller can trigger. None is a reachable-but-unseeded +residual. + +### serial/commands.c — none (100%). +### serial/console.c — none (100%). +### shared/totp.c — none (100%). + +### serial/commands_backup.c (lines 14-16, branch 13) +- `cmd_export_keys` export-failed arm. The only caller passes a full + `export_buf`, so `backup_export` never returns `<0` here (same root as + backup.c:60/56). Needs a storage read failure. + +### serial/commands_keys.c +- **19-20** (br 18): `cmd_list_keys` `storage_key_list < 0` — storage read failure. +- **111-113** (br 110), **br 44, br 75**: `!is_checksum_valid` corrupt-key display in + get-key-secret / get-key / list-keys. `storage_key_save` always writes a fresh + valid checksum, so a mismatch needs flash bit-rot under littlefs's own block CRC. +- **135-137** (br 134): QR-generation-failed arm. The fixed-format otpauth URI is + always well-formed within qrcodegen capacity, so `qrcodegen_encodeText` never + returns false. +- **187, 221, 253, 285, 309, 341, 373** (br 184/218/250/282/306/338/370): + `storage_key_save`/`storage_key_delete` failure else-branches across the 9 + handlers. All keys fit inline on the 256KB host window; a save/delete never + fails — needs a real flash error. + +### serial/commands_network.c (lines 42-43, branches 40-41) +- `set-wifi` `storage_wifi_set` returns false → "FAILED". The 256KB host window + never fails a wifi write; needs a real littlefs write failure. + +### serial/commands_system.c (lines 66, 203, branches 65/125/174) +- **66 (br 65)**, **br 125-disjunct, br 174-disjunct**: `!is_checksum_valid` corrupt-key + counting / any-admin / login disjuncts — flash-bit-rot-only (as above). +- **203**: `while (true) tight_loop_contents()` after `watchdog_reboot` — the + harness longjmps out of `watchdog_reboot` before the spin; unreachable on host. + +### storage/backup.c (lines 63, 67, 74-75, 143-144, branches 62/66/73/142) +- **62/63** (`buf_size < needed` in `backup_export`) and **73/74-75** (skip of an + on-flash invalid-checksum key): export-only arms; `cmd_export_keys` always + passes a full buffer and `storage_key_save` never writes an invalid checksum. +- **66/67**: same export invalid-checksum skip disjunct. +- **142/143-144**: `storage_key_save` fails in the import write loop. All 256 MAX + keys fit the littlefs inline, so no accepted blob makes a save fail — needs a + genuine flash error. + +### storage/storage.c (11 branches — flash/littlefs never fails on the RAM window) +- **127, 134**: `flash_prog` / `flash_erase` → `LFS_ERR_IO`. +- **204-205**: `lfs_format` fails; **208-209**: remount-after-format fails; + **212-214**: `ensure_dirs` / `lfs_mkdir` fails. +- **246-247**: `storage_wifi_set` `opencfg` fails; **307-308**: `storage_key_save` + `opencfg` fails. +- **286-287**: `storage_key_get` short read (`n != sizeof`); **291-292**: key_get + app-checksum mismatch display (sub-littlefs bit-rot only). +- **331-332**: `lfs_dir_open(/keys)` fails; **341-342**: per-entry + `storage_key_get` fails in the list loop → `continue`. From 309ea954b98fc8bab2ee86caa5b827d1ecc1e0af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 08:58:43 +0200 Subject: [PATCH 13/14] test: seed commands.c arg-error + help-admin branches; correct FUZZING.md coverage numbers Co-Authored-By: Claude Opus 4.8 (1M context) --- test/FUZZING.md | 4 ++++ test/fuzz_corpus/argerr_status | 1 + test/fuzz_corpus/argerr_toofew | 1 + test/fuzz_corpus/argerr_toomany | 1 + test/fuzz_corpus/help_admin | 2 ++ 5 files changed, 9 insertions(+) create mode 100644 test/fuzz_corpus/argerr_status create mode 100644 test/fuzz_corpus/argerr_toofew create mode 100644 test/fuzz_corpus/argerr_toomany create mode 100644 test/fuzz_corpus/help_admin diff --git a/test/FUZZING.md b/test/FUZZING.md index 0939e88..e1e17bc 100644 --- a/test/FUZZING.md +++ b/test/FUZZING.md @@ -93,6 +93,10 @@ an export-fail arm that no caller can trigger. None is a reachable-but-unseeded residual. ### serial/commands.c — none (100%). +The dispatch arg-count usage-error arm (`user_args < min_args || user_args > max_args`) +and `cmd_help`'s admin-visible path are driven from the committed corpus by the +`argerr_toofew` / `argerr_toomany` / `argerr_status` / `help_admin` console seeds. + ### serial/console.c — none (100%). ### shared/totp.c — none (100%). diff --git a/test/fuzz_corpus/argerr_status b/test/fuzz_corpus/argerr_status new file mode 100644 index 0000000..585c2ef --- /dev/null +++ b/test/fuzz_corpus/argerr_status @@ -0,0 +1 @@ +status z z z z diff --git a/test/fuzz_corpus/argerr_toofew b/test/fuzz_corpus/argerr_toofew new file mode 100644 index 0000000..a46884d --- /dev/null +++ b/test/fuzz_corpus/argerr_toofew @@ -0,0 +1 @@ +login diff --git a/test/fuzz_corpus/argerr_toomany b/test/fuzz_corpus/argerr_toomany new file mode 100644 index 0000000..4731a9e --- /dev/null +++ b/test/fuzz_corpus/argerr_toomany @@ -0,0 +1 @@ +get-time a b c diff --git a/test/fuzz_corpus/help_admin b/test/fuzz_corpus/help_admin new file mode 100644 index 0000000..7f0630d --- /dev/null +++ b/test/fuzz_corpus/help_admin @@ -0,0 +1,2 @@ +login 0 0 +help From 97d3d97e57e49e8188853393623a020b84119b6e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:36:59 +0200 Subject: [PATCH 14/14] test: libFuzzer harness for the NTP response parser (ntp_recv_cb) ntp_recv_cb is the udp_recv() callback for datagrams from an NTP server (or an on-path spoofer) -- the only remote untrusted-input surface in the firmware, where 48 packet bytes decode into the RTC. fuzz_ntp.c #includes the real network/ntp.c (the callback is static), stubs every lwip/pico/hardware symbol, and feeds fuzzer bytes through a host pbuf so both the p->len<48 guard and the full parse/rollback/apply path run across the corpus. A deterministic preset + injectable clock drives both rollback_check arms every run; state asserts act as a differential oracle. 53M execs, no crash/hang. Wires fuzz_ntp into `make fuzz`, a `fuzz-ntp-run` target, and `fuzz-cov` (network/ntp.c added to the surface): 55.7% lines / 8-of-9 functions; the sole residual dns_found_cb + udp_sendto request path is HW-only (needs live lwIP). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/FUZZING.md | 29 ++-- test/Makefile | 30 +++- test/fuzz_ntp.c | 251 ++++++++++++++++++++++++++++ test/fuzz_ntp_corpus/empty | 0 test/fuzz_ntp_corpus/mode_client | 1 + test/fuzz_ntp_corpus/oversized_64 | Bin 0 -> 64 bytes test/fuzz_ntp_corpus/short_10 | Bin 0 -> 10 bytes test/fuzz_ntp_corpus/short_47 | Bin 0 -> 47 bytes test/fuzz_ntp_corpus/ts_2010 | Bin 0 -> 48 bytes test/fuzz_ntp_corpus/ts_delta_unix0 | Bin 0 -> 48 bytes test/fuzz_ntp_corpus/ts_max | Bin 0 -> 48 bytes test/fuzz_ntp_corpus/ts_unix_epoch | Bin 0 -> 48 bytes test/fuzz_ntp_corpus/ts_zero | Bin 0 -> 48 bytes test/fuzz_ntp_corpus/valid_2023 | Bin 0 -> 48 bytes 14 files changed, 297 insertions(+), 14 deletions(-) create mode 100644 test/fuzz_ntp.c create mode 100644 test/fuzz_ntp_corpus/empty create mode 100644 test/fuzz_ntp_corpus/mode_client create mode 100644 test/fuzz_ntp_corpus/oversized_64 create mode 100644 test/fuzz_ntp_corpus/short_10 create mode 100644 test/fuzz_ntp_corpus/short_47 create mode 100644 test/fuzz_ntp_corpus/ts_2010 create mode 100644 test/fuzz_ntp_corpus/ts_delta_unix0 create mode 100644 test/fuzz_ntp_corpus/ts_max create mode 100644 test/fuzz_ntp_corpus/ts_unix_epoch create mode 100644 test/fuzz_ntp_corpus/ts_zero create mode 100644 test/fuzz_ntp_corpus/valid_2023 diff --git a/test/FUZZING.md b/test/FUZZING.md index e1e17bc..70141ef 100644 --- a/test/FUZZING.md +++ b/test/FUZZING.md @@ -1,10 +1,10 @@ # Fuzzing the hslock parse/logic surface -Four libFuzzer harnesses exercise every host-reachable byte of hslock's +Five libFuzzer harnesses exercise every host-reachable byte of hslock's untrusted-input parse/logic surface. HW-I/O wrappers (`hardware/*`, real network I/O, `main.c`) are out of scope: they are not fuzzable on the host. -## The four fuzzers +## The five fuzzers | harness | entry point | what it drives | |---|---|---| @@ -12,8 +12,9 @@ network I/O, `main.c`) are out of scope: they are not fuzzable on the host. | `fuzz_backup.c` | raw backup blob | `backup_import` (+ `to_key_record`) directly — header/version/count/CRC validation and the key-write loop. | | `fuzz_totp.c` | (big-endian time, secret) | `totp_verify` directly against the **real** mbedtls HMAC-SHA1 with an injectable fixed clock: reject, T-1/T/T+1 accept window, full-loop reject. | | `fuzz_storage.c` | scripted CRUD ids/names | `storage.c` public API (init/save/get/list/delete, wifi set/get/clear) on the RAM-mapped XIP window. | +| `fuzz_ntp.c` | 48-byte NTP datagram body | `ntp_recv_cb` directly — the **remote** untrusted surface (an NTP server or on-path spoofer): the too-short guard, the fixed 48-byte copy/parse, the big-endian transmit-timestamp decode, and both `rollback_check` arms via a deterministic preset + injectable clock. Includes `network/ntp.c` to reach the static callback. | -All four build with **clang libFuzzer + ASan + UBSan** +All five build with **clang libFuzzer + ASan + UBSan** (`-fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all`) and link the real `libmbedcrypto`, so **`libmbedtls-dev` and clang (with the sanitizer/fuzzer runtime) are required**. If plain `clang` is unavailable use @@ -22,11 +23,12 @@ runtime) are required**. If plain `clang` is unavailable use ## Running ```sh -make -C test fuzz # build all four fuzzer binaries +make -C test fuzz # build all five fuzzer binaries make -C test fuzz-run # run fuzz_console from its committed corpus make -C test fuzz-backup-run # " fuzz_backup make -C test fuzz-totp-run # " fuzz_totp make -C test fuzz-storage-run# " fuzz_storage +make -C test fuzz-ntp-run # " fuzz_ntp make -C test fuzz-cov # gcc --coverage replay of the committed corpora, # prints the surface coverage table below make -C test asan # the (clang-independent) ASan+UBSan unit gate @@ -34,8 +36,9 @@ make -C test asan # the (clang-independent) ASan+UBSan unit gate `fuzz-cov` is a deterministic gcc `--coverage -DFUZZ_REPLAY` build: it replays the **committed** corpora (`fuzz_corpus`, `fuzz_backup_corpus`, -`fuzz_totp_corpus`, `fuzz_storage_corpus`) through `LLVMFuzzerTestOneInput`, -merges the lcov trace, and restricts it to the 9 surface files. The table is +`fuzz_totp_corpus`, `fuzz_storage_corpus`, `fuzz_ntp_corpus`) through +`LLVMFuzzerTestOneInput`, merges the lcov trace, and restricts it to the 10 +surface files. The table is reproducible from the committed corpus alone — coverage-growth units are not committed (they add libFuzzer feature buckets but no new surface edges). @@ -45,6 +48,7 @@ committed (they add libFuzzer feature buckets but no new surface edges). |Lines |Functions |Branches Filename |Rate Num|Rate Num|Rate Num =================================================================== +network/ntp.c |55.7% 97|88.9% 9|43.3% 30 serial/commands.c | 100% 39| 100% 4| 100% 22 serial/commands_backup.c |87.5% 24| 100% 2|83.3% 6 serial/commands_keys.c |93.1% 216| 100% 10|88.7% 106 @@ -55,12 +59,17 @@ shared/totp.c | 100% 25| 100% 2| 100% 6 storage/backup.c |92.0% 75| 100% 5|84.6% 26 storage/storage.c |93.2% 146| 100% 20|79.6% 54 =================================================================== - Total:|94.8% 730| 100% 55|89.8% 324 + Total:|90.2% 827|98.4% 64|85.9% 354 ``` -**Surface total: 94.8% lines (692/730), 100% functions (55/55), -89.8% branches (291/324).** This is a documented plateau: 240s -coverage-guided campaigns on all four fuzzers reproduce exactly these numbers. +**Surface total: 90.2% lines (746/827), 98.4% functions (63/64), +85.9% branches (304/354).** This is a documented plateau: 240s +coverage-guided campaigns on all five fuzzers reproduce exactly these numbers. + +`network/ntp.c`'s residual is HW-only: the sole uncovered function +`dns_found_cb` (and the matching tail of `ntp_sync`) is the DNS-resolve + +`udp_sendto` request path, which needs a live lwIP stack — the parse/rollback +surface reachable from a received datagram is fully driven. ## Bugs found and fixed diff --git a/test/Makefile b/test/Makefile index 48a6e75..ee45989 100644 --- a/test/Makefile +++ b/test/Makefile @@ -110,7 +110,7 @@ LCOV_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,gcov GENHTML_IGNORE := --ignore-errors unused,empty,mismatch,inconsistent,source,negative LCOV := lcov $(LCOV_RC) $(LCOV_IGNORE) -.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run fuzz-totp-run fuzz-storage-run fuzz-cov gen-backup-seeds clean check-submodules +.PHONY: all asan valgrind coverage fuzz fuzz-run fuzz-backup-run fuzz-totp-run fuzz-storage-run fuzz-ntp-run fuzz-cov gen-backup-seeds clean check-submodules all: asan @@ -296,8 +296,19 @@ FUZZ_STORAGE_SRCS := fuzz_storage.c \ $(ROOT)/storage/storage.c \ $(ROOT)/libs/littlefs/lfs.c $(ROOT)/libs/littlefs/lfs_util.c +# Direct NTP response-parser harness: fuzz bytes become a 48-byte UDP datagram +# body fed straight into ntp_recv_cb() -- the REMOTE untrusted-input surface (an +# NTP server or on-path spoofer). ntp_recv_cb is static, so fuzz_ntp.c #includes +# the real network/ntp.c (all lwip/pico/hardware symbols stubbed inside the +# harness); network/ntp.c is a prerequisite, not a compiled source. See +# test/fuzz_ntp.c. +FUZZ_NTP_BIN := $(BUILD)/fuzz_ntp +FUZZ_NTP_CORPUS := fuzz_ntp_corpus +FUZZ_NTP_SRCS := fuzz_ntp.c + # Build all fuzzers. -fuzz: check-submodules $(FUZZ_BIN) $(FUZZ_BACKUP_BIN) $(FUZZ_TOTP_BIN) $(FUZZ_STORAGE_BIN) +fuzz: check-submodules $(FUZZ_BIN) $(FUZZ_BACKUP_BIN) $(FUZZ_TOTP_BIN) $(FUZZ_STORAGE_BIN) \ + $(FUZZ_NTP_BIN) $(FUZZ_BIN): $(FUZZ_SRCS) | $(BUILD) $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_SRCS) $(FUZZ_LIBS) -o $@ @@ -311,6 +322,9 @@ $(FUZZ_TOTP_BIN): $(FUZZ_TOTP_SRCS) | $(BUILD) $(FUZZ_STORAGE_BIN): $(FUZZ_STORAGE_SRCS) | $(BUILD) $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_STORAGE_SRCS) -o $@ +$(FUZZ_NTP_BIN): $(FUZZ_NTP_SRCS) $(ROOT)/network/ntp.c | $(BUILD) + $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_INCLUDES) $(FUZZ_NTP_SRCS) -o $@ + # Build + run short bounded campaigns from the seed corpora. The console run is # fed the keyword dictionary so mutation can splice real commands together. # Bump FUZZ_TIME for a longer soak: `make -C test fuzz-run FUZZ_TIME=600`. @@ -334,6 +348,11 @@ fuzz-storage-run: $(FUZZ_STORAGE_BIN) $(FUZZ_STORAGE_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ $(FUZZ_STORAGE_CORPUS) +fuzz-ntp-run: $(FUZZ_NTP_BIN) + @echo "== fuzzing ntp_recv_cb for $(FUZZ_TIME)s ==" + $(FUZZ_NTP_BIN) -max_total_time=$(FUZZ_TIME) -print_final_stats=1 \ + $(FUZZ_NTP_CORPUS) + # --- Curated backup_import() seed generator --------------------------------- # Regenerates the committed fuzz_backup_corpus/ blobs: one per backup_import # validation/write branch, with the VALID cases carrying a real CRC computed by @@ -366,7 +385,7 @@ FUZZ_COV_OUT := $(COV_DIR)/fuzz-surface.txt # The fuzzable parse/logic surface (HW-I/O wrappers are out of scope). SURFACE_SRCS := serial/commands.c serial/commands_system.c serial/commands_keys.c \ serial/commands_network.c serial/commands_backup.c serial/console.c \ - storage/storage.c storage/backup.c shared/totp.c + storage/storage.c storage/backup.c shared/totp.c network/ntp.c SURFACE_ABS := $(addprefix $(REPO_ROOT)/,$(SURFACE_SRCS)) # Same lcov config as `coverage`, plus function coverage so the per-function @@ -383,11 +402,14 @@ fuzz-cov: check-submodules | $(FUZZ_COV_OBJ) $(FUZZ_TOTP_SRCS) fuzz_replay.c $(FUZZ_TOTP_LIBS) -o $(FUZZ_COV_OBJ)/cov_totp $(CC) $(CSTD) $(WARN) $(COV_FLAGS) $(FUZZ_DEFS) -DFUZZ_REPLAY $(FUZZ_INCLUDES) \ $(FUZZ_STORAGE_SRCS) fuzz_replay.c -o $(FUZZ_COV_OBJ)/cov_storage + $(CC) $(CSTD) $(WARN) $(COV_FLAGS) -DFUZZ_REPLAY $(FUZZ_INCLUDES) \ + $(FUZZ_NTP_SRCS) fuzz_replay.c -o $(FUZZ_COV_OBJ)/cov_ntp @echo "== fuzz-cov: replaying corpora ==" $(FUZZ_COV_OBJ)/cov_console $(FUZZ_CORPUS) $(FUZZ_COV_OBJ)/cov_backup $(FUZZ_BACKUP_CORPUS) $(FUZZ_COV_OBJ)/cov_totp $(FUZZ_TOTP_CORPUS) $(FUZZ_COV_OBJ)/cov_storage $(FUZZ_STORAGE_CORPUS) + $(FUZZ_COV_OBJ)/cov_ntp $(FUZZ_NTP_CORPUS) @echo "== fuzz-cov: capturing + merging coverage ==" $(FCOV_LCOV) --capture --initial --directory $(FUZZ_COV_OBJ) \ --output-file $(COV_DIR)/fuzz-base.info @@ -408,7 +430,7 @@ fuzz-cov: check-submodules | $(FUZZ_COV_OBJ) echo; \ echo "Uncovered functions across the surface:"; \ awk -F'[:,]' '\ - /^SF:/ { sf=$$2; sub(/.*\/(serial|storage|shared)\//,"",sf) } \ + /^SF:/ { sf=$$2; sub(/.*\/(serial|storage|shared|network)\//,"",sf) } \ /^FNA:/ { if ($$3==0) print " " sf " " $$4 } \ /^FNDA:/ { if ($$2==0) print " " sf " " $$3 }' \ $(COV_DIR)/fuzz-surface.info | sort || true; \ diff --git a/test/fuzz_ntp.c b/test/fuzz_ntp.c new file mode 100644 index 0000000..97c4924 --- /dev/null +++ b/test/fuzz_ntp.c @@ -0,0 +1,251 @@ +/* + * libFuzzer harness: network/ntp.c NTP response parser (ntp_recv_cb), driven + * directly on the REMOTE untrusted-input surface. + * + * ntp_recv_cb is the udp_recv() callback lwIP invokes with a UDP datagram from + * whatever host answered on port 123 -- i.e. an NTP server OR an on-path + * attacker spoofing one. It is the only place firmware-external network bytes + * reach the time logic, so a parser bug here is a remote issue: the 48-byte + * body's transmit-timestamp (bytes 40..43) becomes the RTC. This harness feeds + * fuzzer bytes straight into that callback via a host pbuf. + * + * ntp_recv_cb is static, so we #include the real ntp.c into this TU (the + * standard static-function fuzzing pattern). That also puts ntp.c's file-static + * state (synced / last_sync_unix / last_sync_monotonic_us / ntp_state) in scope, + * which we reset for determinism and preset to deterministically drive BOTH + * rollback_check() arms every run. Every lwip/pico/hardware symbol ntp.c pulls + * in is stubbed below (the non-parse ntp_sync/dns paths only need to LINK -- the + * harness never calls them); the injectable time_us_64() makes rollback_check + * deterministic. + * + * The two valid-time sub-cases double as a differential oracle: a timestamp at + * or above the rollback floor that fails to apply, or one below it that applies, + * would be an ntp.c regression (they do not abort -- ntp_recv_cb is void -- but + * the coverage/state asserts here would). + */ + +#include +#include +#include +#include +#include + +/* --- host stubs for every non-parse symbol ntp.c references --------------- */ +/* Declarations come from -idirafter stub (the lwip, pico and hardware dirs); these + * are the definitions so the whole ntp.c TU links. Only time_us_64() and + * clock_set_from_unix_time() matter to the parse path; the rest are no-ops that + * exist purely because ntp_sync()/dns_found_cb() are compiled (not called). */ + +#include "lwip/arch.h" +#include "lwip/ip_addr.h" +#include "lwip/pbuf.h" +#include "lwip/udp.h" +#include "lwip/dns.h" +#include "pico/time.h" + +/* Injectable monotonic clock: rollback_check()/apply_time() read time through + * this, so the floor computation is deterministic per run. */ +static uint64_t g_time_us = 0; + +uint64_t time_us_64(void) { + return g_time_us; +} + +/* Records the last timestamp apply_time() pushed to the RTC, for the oracle. */ +static uint32_t g_applied_unix; +static int g_applied_called; + +void clock_set_from_unix_time(uint32_t unix_time) { + g_applied_unix = unix_time; + g_applied_called = 1; +} + +/* pbuf: the fuzz bytes ride in via p->payload; the parse copies buf[0..47]. */ +u16_t pbuf_copy_partial(const struct pbuf *p, void *dataptr, u16_t len, u16_t offset) { + if (offset >= p->len) + return 0; + u16_t avail = (u16_t)(p->len - offset); + u16_t n = len < avail ? len : avail; + memcpy(dataptr, (const uint8_t *)p->payload + offset, n); + return n; +} + +u8_t pbuf_free(struct pbuf *p) { + (void)p; + return 1; +} + +/* Never called (dns_found_cb is only compiled, not invoked); link-only. */ +struct pbuf *pbuf_alloc(pbuf_kind_t layer, u16_t length, pbuf_kind_t type) { + (void)layer; + (void)length; + (void)type; + return NULL; +} + +struct udp_pcb *udp_new_ip_type(u8_t type) { + (void)type; + return NULL; +} +void udp_remove(struct udp_pcb *pcb) { + (void)pcb; +} +void udp_recv(struct udp_pcb *pcb, udp_recv_fn recv, void *recv_arg) { + (void)pcb; + (void)recv; + (void)recv_arg; +} +err_t udp_sendto(struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *dst_ip, u16_t dst_port) { + (void)pcb; + (void)p; + (void)dst_ip; + (void)dst_port; + return ERR_OK; +} + +err_t dns_gethostbyname(const char *hostname, ip_addr_t *addr, dns_found_callback found, + void *callback_arg) { + (void)hostname; + (void)addr; + (void)found; + (void)callback_arg; + return ERR_INPROGRESS; +} + +void cyw43_arch_lwip_begin(void) { +} +void cyw43_arch_lwip_end(void) { +} +void cyw43_arch_poll(void) { +} + +void sleep_ms(uint32_t ms) { + (void)ms; +} +void sleep_us(uint64_t us) { + (void)us; +} +absolute_time_t make_timeout_time_ms(uint32_t ms) { + (void)ms; + return 0; +} +bool time_reached(absolute_time_t t) { + (void)t; + return true; +} + +void rtc_init(void) { +} +void buzzer_play_ntp_sync_error(void) { +} + +/* --- the target, static functions and file-static state now in scope ------ */ +#include "../network/ntp.c" + +/* --- helpers -------------------------------------------------------------- */ + +/* Reset ntp.c's file-static state so each scenario starts from a known point. */ +static void reset_ntp_state(void) { + synced = false; + last_sync_unix = 0; + last_sync_monotonic_us = 0; + ntp_state = NTP_STATE_IDLE; + g_applied_called = 0; + g_applied_unix = 0; +} + +/* Drive ntp_recv_cb with a datagram: p->len == len selects the parse/too-short + * branch, and the first min(len,64) bytes become the packet body. payload is a + * zero-padded 64-byte scratch so the fixed 48-byte read is always in-bounds + * (confirming the p->len<48 guard makes the copy safe). */ +static void feed(const uint8_t *bytes, size_t len) { + uint8_t payload[64]; + memset(payload, 0, sizeof payload); + size_t plen = len < sizeof payload ? len : sizeof payload; + if (plen) + memcpy(payload, bytes, plen); + + struct pbuf p = { + .next = NULL, + .payload = payload, + .len = (u16_t)len, + .tot_len = (u16_t)len, + }; + ntp_recv_cb(NULL, NULL, &p, NULL, 123); +} + +/* Build a 48-byte NTP response whose transmit timestamp decodes to unix_time. */ +static void make_ntp_packet(uint8_t pkt[48], uint32_t unix_time) { + memset(pkt, 0, 48); + pkt[0] = 0x1C; /* LI=0, VN=3, Mode=4 (server) */ + uint32_t seconds_since_1900 = unix_time + NTP_DELTA; + pkt[40] = (uint8_t)(seconds_since_1900 >> 24); + pkt[41] = (uint8_t)(seconds_since_1900 >> 16); + pkt[42] = (uint8_t)(seconds_since_1900 >> 8); + pkt[43] = (uint8_t)(seconds_since_1900); +} + +int LLVMFuzzerInitialize(int *argc, char ***argv) { + (void)argc; + (void)argv; + /* Silence ntp.c's printf() so it does not throttle the fuzzer. */ + if (!freopen("/dev/null", "w", stdout)) { + perror("freopen stdout"); + return -1; + } + return 0; +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + /* (1) Fuzzer-driven call from the pre-sync state: size<48 exercises the + * too-short reject; size>=48 runs the full parse, with the fuzzer choosing + * bytes 40..43 (the timestamp) and !synced -> rollback_check returns true + * -> apply_time. Wrap arithmetic in `seconds - NTP_DELTA` is exercised for + * every fuzzer-chosen timestamp (defined for unsigned, not UB). */ + reset_ntp_state(); + g_time_us = 0; + feed(data, size); + + /* (2) Deterministic synced scenario driving BOTH rollback_check arms, so + * the reject/accept branches register every run regardless of fuzz input. + * floor = last_sync_unix + elapsed_s - NTP_ROLLBACK_EPSILON_S. */ + reset_ntp_state(); + synced = true; + last_sync_unix = 1700000000u; + last_sync_monotonic_us = 0; + g_time_us = 0; /* elapsed_s == 0 */ + uint32_t floor = last_sync_unix - NTP_ROLLBACK_EPSILON_S; /* 1699999940 */ + + uint8_t pkt[48]; + + /* Below the floor: rollback rejected, RTC untouched, state == FAILED. */ + make_ntp_packet(pkt, floor - 100u); + feed(pkt, 48); + assert(g_applied_called == 0); + assert(ntp_state == NTP_STATE_FAILED); + + /* State unchanged by the reject (apply_time never ran), so the floor still + * holds. At/above the floor: accepted, RTC set to exactly that time. */ + make_ntp_packet(pkt, floor + 100u); + feed(pkt, 48); + assert(g_applied_called == 1); + assert(g_applied_unix == floor + 100u); + assert(ntp_state == NTP_STATE_SUCCESS); + assert(synced && last_sync_unix == floor + 100u); + + /* (3) Cheap host-safe coverage of the non-parse API that needs no live + * lwip: the trivial getters, ntp_init (rtc_init stub), and ntp_task's + * due-for-resync arm -- with udp_new_ip_type() stubbed to NULL, the resync + * ntp_sync() takes its pcb-creation-fail return and ntp_task buzzes. The + * dns-resolve/udp-send success path (dns_found_cb + the rest of ntp_sync) + * stays dark: it needs a real lwip stack and is HW-only. */ + ntp_init(); + (void)ntp_is_synced(); + (void)ntp_last_sync_time(); + synced = true; + last_sync_monotonic_us = 0; + g_time_us = (uint64_t)(NTP_RESYNC_INTERVAL_S + 1) * 1000000ULL; + ntp_task(); + + return 0; +} diff --git a/test/fuzz_ntp_corpus/empty b/test/fuzz_ntp_corpus/empty new file mode 100644 index 0000000..e69de29 diff --git a/test/fuzz_ntp_corpus/mode_client b/test/fuzz_ntp_corpus/mode_client new file mode 100644 index 0000000..1f336bb --- /dev/null +++ b/test/fuzz_ntp_corpus/mode_client @@ -0,0 +1 @@ +èþo€ \ No newline at end of file diff --git a/test/fuzz_ntp_corpus/oversized_64 b/test/fuzz_ntp_corpus/oversized_64 new file mode 100644 index 0000000000000000000000000000000000000000..e7ecd80cfe21b45e8ce6937e5424ef6ce9aa6ec8 GIT binary patch literal 64 Rcmb1PAP&6vmtT)fB>+dq0`UL< literal 0 HcmV?d00001 diff --git a/test/fuzz_ntp_corpus/short_10 b/test/fuzz_ntp_corpus/short_10 new file mode 100644 index 0000000000000000000000000000000000000000..cb43b5ce1342e5d73830ac8b6a37ea870fae2632 GIT binary patch literal 10 KcmZQzfB^si3IG8B literal 0 HcmV?d00001 diff --git a/test/fuzz_ntp_corpus/short_47 b/test/fuzz_ntp_corpus/short_47 new file mode 100644 index 0000000000000000000000000000000000000000..f3ac27a142178d6dd895e6ea84a46c6466daad10 GIT binary patch literal 47 LcmZQzAPE2f051Rm literal 0 HcmV?d00001 diff --git a/test/fuzz_ntp_corpus/ts_2010 b/test/fuzz_ntp_corpus/ts_2010 new file mode 100644 index 0000000000000000000000000000000000000000..dcdf3bd0d03ae1b04ed088da4e2824a32583577e GIT binary patch literal 48 Rcmb1PAP$^+zOw=+p0`dR= literal 0 HcmV?d00001