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/FUZZING.md b/test/FUZZING.md new file mode 100644 index 0000000..70141ef --- /dev/null +++ b/test/FUZZING.md @@ -0,0 +1,158 @@ +# Fuzzing the hslock parse/logic surface + +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 five 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. | +| `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 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 +`make -C test fuzz FUZZ_CC=clang-19`. + +## Running + +```sh +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 +``` + +`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`, `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). + +## Final coverage (committed corpora) + +``` + |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 +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:|90.2% 827|98.4% 64|85.9% 354 +``` + +**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 + +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%). +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%). + +### 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`. diff --git a/test/Makefile b/test/Makefile index 8f70f28..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 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 @@ -230,6 +230,216 @@ 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_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 \ + -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 + +# 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 + +# 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 + +# 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 + +# 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_NTP_BIN) + +$(FUZZ_BIN): $(FUZZ_SRCS) | $(BUILD) + $(FUZZ_CC) $(FUZZ_FLAGS) $(FUZZ_DEFS) $(FUZZ_INCLUDES) $(FUZZ_SRCS) $(FUZZ_LIBS) -o $@ + +$(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 $@ + +$(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`. +fuzz-run: $(FUZZ_BIN) + @echo "== fuzzing serial console for $(FUZZ_TIME)s ==" + $(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) + +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) + +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) + +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 +# 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 +# -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 network/ntp.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 + $(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 + $(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 + $(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|network)\//,"",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_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/bad_magic b/test/fuzz_backup_corpus/bad_magic new file mode 100644 index 0000000..588d83b Binary files /dev/null and b/test/fuzz_backup_corpus/bad_magic differ diff --git a/test/fuzz_backup_corpus/bad_version b/test/fuzz_backup_corpus/bad_version new file mode 100644 index 0000000..296f727 Binary files /dev/null and b/test/fuzz_backup_corpus/bad_version differ diff --git a/test/fuzz_backup_corpus/body_truncated b/test/fuzz_backup_corpus/body_truncated new file mode 100644 index 0000000..8b7b4cc Binary files /dev/null and b/test/fuzz_backup_corpus/body_truncated differ diff --git a/test/fuzz_backup_corpus/count_over_max b/test/fuzz_backup_corpus/count_over_max new file mode 100644 index 0000000..e18cf2e Binary files /dev/null and b/test/fuzz_backup_corpus/count_over_max differ diff --git a/test/fuzz_backup_corpus/crc_mismatch b/test/fuzz_backup_corpus/crc_mismatch new file mode 100644 index 0000000..90d87df Binary files /dev/null and b/test/fuzz_backup_corpus/crc_mismatch differ diff --git a/test/fuzz_backup_corpus/empty b/test/fuzz_backup_corpus/empty new file mode 100644 index 0000000..e69de29 diff --git a/test/fuzz_backup_corpus/nonbool_flags b/test/fuzz_backup_corpus/nonbool_flags new file mode 100644 index 0000000..3328ae6 Binary files /dev/null and b/test/fuzz_backup_corpus/nonbool_flags differ diff --git a/test/fuzz_backup_corpus/short_header b/test/fuzz_backup_corpus/short_header new file mode 100644 index 0000000..673f385 Binary files /dev/null and b/test/fuzz_backup_corpus/short_header differ diff --git a/test/fuzz_backup_corpus/valid_0keys b/test/fuzz_backup_corpus/valid_0keys new file mode 100644 index 0000000..0f903e7 Binary files /dev/null and b/test/fuzz_backup_corpus/valid_0keys differ diff --git a/test/fuzz_backup_corpus/valid_1key b/test/fuzz_backup_corpus/valid_1key new file mode 100644 index 0000000..0f7c479 Binary files /dev/null and b/test/fuzz_backup_corpus/valid_1key differ diff --git a/test/fuzz_backup_corpus/valid_countmax b/test/fuzz_backup_corpus/valid_countmax new file mode 100644 index 0000000..74d687d Binary files /dev/null and b/test/fuzz_backup_corpus/valid_countmax differ diff --git a/test/fuzz_backup_corpus/valid_flags b/test/fuzz_backup_corpus/valid_flags new file mode 100644 index 0000000..f4b2c2b Binary files /dev/null and b/test/fuzz_backup_corpus/valid_flags differ diff --git a/test/fuzz_console.c b/test/fuzz_console.c new file mode 100644 index 0000000..cf445b8 --- /dev/null +++ b/test/fuzz_console.c @@ -0,0 +1,281 @@ +/* + * 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 */ +} + +/* --- 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 g_env.time_us; +} + +uint32_t clock_get_unix_time(void) { + return g_env.clock_unix; +} + +bool ntp_is_synced(void) { + return g_env.ntp_synced; +} +uint32_t ntp_last_sync_time(void) { + return g_env.ntp_last; +} +bool ntp_sync(void) { + return g_env.ntp_sync_ok; +} + +bool wifi_connect(const char *ssid, const char *password) { + (void)ssid; + (void)password; + return g_env.wifi_conn_ok; +} + +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. 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) { + 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. + * 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; + } + + /* 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; +} + +/* 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) { + 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(); + /* 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_console.dict b/test/fuzz_console.dict new file mode 100644 index 0000000..7712934 --- /dev/null +++ b/test/fuzz_console.dict @@ -0,0 +1,54 @@ +# 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" + +# --- 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" +"--- BEGIN HSLOCK BACKUP ---" 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/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/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 @@ + 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/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 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/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/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 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/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/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/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/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/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/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/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/test b/test/fuzz_corpus/test new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/test/fuzz_corpus/test @@ -0,0 +1 @@ +test diff --git a/test/fuzz_corpus/totp_login b/test/fuzz_corpus/totp_login new file mode 100644 index 0000000..a8f0dee --- /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 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_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 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 0000000..e7ecd80 Binary files /dev/null and b/test/fuzz_ntp_corpus/oversized_64 differ diff --git a/test/fuzz_ntp_corpus/short_10 b/test/fuzz_ntp_corpus/short_10 new file mode 100644 index 0000000..cb43b5c Binary files /dev/null and b/test/fuzz_ntp_corpus/short_10 differ diff --git a/test/fuzz_ntp_corpus/short_47 b/test/fuzz_ntp_corpus/short_47 new file mode 100644 index 0000000..f3ac27a Binary files /dev/null and b/test/fuzz_ntp_corpus/short_47 differ diff --git a/test/fuzz_ntp_corpus/ts_2010 b/test/fuzz_ntp_corpus/ts_2010 new file mode 100644 index 0000000..dcdf3bd Binary files /dev/null and b/test/fuzz_ntp_corpus/ts_2010 differ diff --git a/test/fuzz_ntp_corpus/ts_delta_unix0 b/test/fuzz_ntp_corpus/ts_delta_unix0 new file mode 100644 index 0000000..2a56096 Binary files /dev/null and b/test/fuzz_ntp_corpus/ts_delta_unix0 differ diff --git a/test/fuzz_ntp_corpus/ts_max b/test/fuzz_ntp_corpus/ts_max new file mode 100644 index 0000000..7945437 Binary files /dev/null and b/test/fuzz_ntp_corpus/ts_max differ diff --git a/test/fuzz_ntp_corpus/ts_unix_epoch b/test/fuzz_ntp_corpus/ts_unix_epoch new file mode 100644 index 0000000..7fdc2c2 Binary files /dev/null and b/test/fuzz_ntp_corpus/ts_unix_epoch differ diff --git a/test/fuzz_ntp_corpus/ts_zero b/test/fuzz_ntp_corpus/ts_zero new file mode 100644 index 0000000..f6e4b17 Binary files /dev/null and b/test/fuzz_ntp_corpus/ts_zero differ diff --git a/test/fuzz_ntp_corpus/valid_2023 b/test/fuzz_ntp_corpus/valid_2023 new file mode 100644 index 0000000..76418a0 Binary files /dev/null and b/test/fuzz_ntp_corpus/valid_2023 differ 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; +} 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 0000000..feb842b Binary files /dev/null and b/test/fuzz_storage_corpus/seed_mixed differ 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 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; +}