Skip to content

fix(M11): drop spoofed NTP datagrams instead of failing the sync - #13

Merged
Tomunek merged 2 commits into
hakierspejs:masterfrom
d33mobile:fix/m11-ntp-drop
Aug 5, 2026
Merged

fix(M11): drop spoofed NTP datagrams instead of failing the sync#13
Tomunek merged 2 commits into
hakierspejs:masterfrom
d33mobile:fix/m11-ntp-drop

Conversation

@d33mobile

Copy link
Copy Markdown

M11 — One spoofed datagram aborts an entire NTP sync

All seven rejection paths in ntp_recv_cb (ntp.c:85-129) set ntp_state = NTP_STATE_FAILED and return, so ntp_sync's loop exits and tears down the pcb, discarding the still-in-flight legitimate response. The attacker must source from server_addr:123 (trivial on-path/same-LAN). Chained with H4 this is a permanent remote brick from a low-rate spoof.

  • On validation failure, return without touching ntp_state — drop the datagram and keep waiting until the deadline.

The fix

ntp_recv_cb now treats a rejected datagram as something to drop, not as a sync failure. Each validation-rejection arm frees its pbuf and returns without setting ntp_state, so ntp_sync()'s poll loop keeps waiting for the genuine reply until its deadline. A single spoofed packet from server_addr:123 can no longer abort the whole sync.

Arms changed from ntp_state = NTP_STATE_FAILED; return; to a plain drop-and-return:

  • too-short (tot_len < 48)
  • source-address / port mismatch
  • LI == 3 (server clock unsynchronised)
  • mode != 4
  • invalid stratum (0 or >15)
  • origin-timestamp / nonce mismatch (replay)
  • sane-band reject (H2/H3 bound)
  • rollback_check() reject

Only the success path (apply_time()ntp_state = NTP_STATE_SUCCESS) advances the state machine. dns_found_cb()'s NTP_STATE_FAILED assignments are genuine terminal DNS-resolution errors and are intentionally left unchanged. pbuf is still freed exactly once on every path (no leak, no double-free).

Tests

test/fuzz_ntp.c: the synced below-floor scenario now enters in NTP_STATE_WAITING (the state ntp_sync's loop actually holds) and asserts the reject leaves it WAITING, matching the new drop semantics, while the at/above-floor packet still applies and reaches SUCCESS. This also repaired pre-existing harness breakage that had kept fuzz-ntp-run from linking/running against current master: added the wifi_is_connected() stub ntp_task() now needs, fed a valid non-NULL source address matching server_addr (the source check dereferences addr), and gave the accept packet a valid stratum (master's stratum != 0 check dropped the old stratum-0 packet).

Host tests (this environment cannot build for Pico — no ARM toolchain / PICO_SDK):

  • make -C test asan — exit 0
  • make -C test valgrind — exit 0
  • make -C test coverage — exit 0
  • ./ci --action=check — exit 0
  • make -C test fuzz-ntp-run — clean, 12.4M execs

The RTC-set / on-device timeout behaviour is verify-on-hw.

Scope: M11 only.

🤖 Generated with Claude Code

Every validation-rejection arm in ntp_recv_cb() set ntp_state =
NTP_STATE_FAILED and returned, so ntp_sync()'s poll loop exited and tore
down the pcb. A single spoofed datagram from server_addr:123 (trivial
on-path/same-LAN) therefore aborted the whole sync, discarding the
in-flight legitimate response. Chained with H4 this is a permanent
remote brick from a low-rate spoof.

Each rejection arm (too-short, source mismatch, LI=3, bad mode, invalid
stratum, origin/nonce mismatch, sane-band reject, rollback reject) now
frees its pbuf and returns WITHOUT touching ntp_state, so the datagram
is dropped and the loop keeps waiting for the genuine reply until its
deadline. Only apply_time() on the success path advances the state
machine. dns_found_cb()'s FAILED assignments are genuine terminal
resolution errors and are left unchanged.

test/fuzz_ntp.c: the below-floor scenario now enters in NTP_STATE_WAITING
and asserts the reject leaves it WAITING (drop semantics) rather than
FAILED. Also repaired pre-existing harness breakage that kept it from
linking/running against current master: added the wifi_is_connected()
stub ntp_task() needs, fed a valid non-NULL source addr matching
server_addr (the source check dereferences addr), and gave the accept
packet a valid stratum (master's stratum!=0 check dropped the old
stratum-0 packet).

Host tests: make -C test asan / valgrind / coverage and ./ci
--action=check all exit 0; make -C test fuzz-ntp-run clean (12.4M execs).
RTC-set/on-hardware behaviour is verify-on-hw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ompiles

Rebasing onto current master pulled in a latent build break: cmd_status
declares `keys` inside the `if (commands_is_admin())` block but scrubs it
via `secure_wipe(keys, sizeof(keys))` at function scope, so `keys` is
undeclared there. asan_commands links serial/commands.c (not
commands_system.c) so it never compiled this TU, but `make -C test coverage`
compiles the whole first-party surface and fails here (`keys undeclared`) --
as does the real firmware build. Restore `keys` to function scope, matching
the scrub's intent of always clearing the key DB from BSS (incl. the
non-admin path where it stays zero-initialised). Same fix as PR hakierspejs#15 (M1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Tomunek
Tomunek merged commit 3ff32c5 into hakierspejs:master Aug 5, 2026
2 checks passed
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.

2 participants