Fuzz the serial console + parsers (finds a UB in backup import)#7
Open
d33mobile wants to merge 14 commits into
Open
Fuzz the serial console + parsers (finds a UB in backup import)#7d33mobile wants to merge 14 commits into
d33mobile wants to merge 14 commits into
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…ure & close coverage gaps Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…face Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e TOTP accept path 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) <noreply@anthropic.com>
…anches 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…ectable-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) <noreply@anthropic.com>
…, 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<INPUT_BUF_SIZE-1 false branch (chars dropped, no overrun). Harness (fuzz_console.c): call console_init() once in LLVMFuzzerInitialize; add a drained-but-connected poll (getchar timeout early return) and a second disconnected poll (the `if (!connected) return` idle-poll guard). No crash/ hang/ASan/UBSan in a 60s campaign. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ncate, 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…G.md coverage numbers Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A libFuzzer suite for the untrusted-input surface — the serial console command parser plus the backup / TOTP / storage parsers — run under ASan + UBSan. It found and fixes a real undefined-behaviour bug in the backup import path.
The bug (the important part — 9-line firmware fix)
storage/backup.c:to_key_record()read theis_enabled/is_adminflags asbooldirectly from an untrusted backup blob. A crafted backup whose flag bytes are neither 0 nor 1 but whose CRC matches passes validation and hits UB (load of value N, which is not a valid value for type 'bool') — reachable on real hardware viaimport-keys(a user pasting a base64 backup). Fixed by reading the bytes intouint8_tand canonicalising!= 0. A regression seed (nonbool_flags) guards it. This 9-line change instorage/backup.cis the only product-code change — everything else is undertest/.The fuzzers (test-only)
fuzz_console— drives the realconsole_task()char state machine → tokenizer →commands_dispatch()→ realcmd_*handlers on a RAM-mapped littlefs (mmap(MAP_FIXED)of the XIP window), exercising every command end-to-end.fuzz_backup/fuzz_totp/fuzz_storage— direct harnesses forbackup_import,totp_verify(real mbedtls, RFC 6238 vectors), and the storage CRUD API.make -C test fuzz/fuzz-run(clang + libFuzzer + ASan/UBSan; needslibmbedtls-dev) andmake -C test fuzz-cov(gcov coverage of the parse surface). These are separate from the existing gccasan/valgrind/coveragegates, so CI is unaffected.Coverage
Parse/logic surface: 94.8% lines / 89.8% branches / 100% functions. Every residual is documented in
test/FUZZING.mdas genuinely host-unreachable (flash/littlefs I/O error arms, corrupt-checksum-needs-bit-rot, post-watchdog reboot spin-loop). ~8M total executions across the four harnesses; no other findings.Footprint
Product code: 9 lines in
storage/backup.c. Everything else istest/(harnesses + curated seed corpora + coverage target +FUZZING.md). The 60-odd small files are 1–4-line fuzz seed fixtures.🤖 Generated with Claude Code