Skip to content

Fuzz the serial console + parsers (finds a UB in backup import)#7

Open
d33mobile wants to merge 14 commits into
hakierspejs:masterfrom
d33mobile:chore/fuzz-console
Open

Fuzz the serial console + parsers (finds a UB in backup import)#7
d33mobile wants to merge 14 commits into
hakierspejs:masterfrom
d33mobile:chore/fuzz-console

Conversation

@d33mobile

Copy link
Copy Markdown

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 the is_enabled / is_admin flags as bool directly 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 via import-keys (a user pasting a base64 backup). Fixed by reading the bytes into uint8_t and canonicalising != 0. A regression seed (nonbool_flags) guards it. This 9-line change in storage/backup.c is the only product-code change — everything else is under test/.

The fuzzers (test-only)

  • fuzz_console — drives the real console_task() char state machine → tokenizer → commands_dispatch() → real cmd_* 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 for backup_import, totp_verify (real mbedtls, RFC 6238 vectors), and the storage CRUD API.
  • make -C test fuzz / fuzz-run (clang + libFuzzer + ASan/UBSan; needs libmbedtls-dev) and make -C test fuzz-cov (gcov coverage of the parse surface). These are separate from the existing gcc asan/valgrind/coverage gates, so CI is unaffected.

Coverage

Parse/logic surface: 94.8% lines / 89.8% branches / 100% functions. Every residual is documented in test/FUZZING.md as 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 is test/ (harnesses + curated seed corpora + coverage target + FUZZING.md). The 60-odd small files are 1–4-line fuzz seed fixtures.

🤖 Generated with Claude Code

Claude and others added 14 commits July 20, 2026 06:14
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant