Skip to content

Fix RAM overflow from a crafted TZRULES.BIN, plus seven smaller bounds / ISR-safety bugs#7

Draft
peterlewis wants to merge 8 commits into
mitxela:masterfrom
peterlewis:firmware-hardening
Draft

Fix RAM overflow from a crafted TZRULES.BIN, plus seven smaller bounds / ISR-safety bugs#7
peterlewis wants to merge 8 commits into
mitxela:masterfrom
peterlewis:firmware-hardening

Conversation

@peterlewis

@peterlewis peterlewis commented Jun 30, 2026

Copy link
Copy Markdown

A bundle of defensive fixes found while building the other PRs: bounds-checking of host-writable files on the CLOCK drive (the USB-MSC/FAT volume) (TZRULES.BIN length fields could overrun RAM), text/snprintf clamps, the zero-mtime config.txt first-boot bug, and related ISR/reentrancy guards.
Stack: based on astro-pack (#6).
Try it now: fwt.bin · fwd.bin — the union of this PR stack, byte-identical to the merged result.
Docs: docs/firmware-additions.md, shipped in the final (menu) PR.


Note

You can try this PR without hardware: the PCC web app runs the real clock4 firmware compiled to WebAssembly, and its clock is built from a rollup of the whole PR series together, so this PR's behaviour is live there alongside the others. The app's DEVICE → UPDATES panel shows the exact commit it was built from. Fair warning: the app around the firmware is beta and still has bugs — the firmware behaviour is the real thing, the app chrome less so.

Draft / proposal. Eight self-contained bug fixes found while reading through the Mk IV firmware. All are pre-existing issues in master. Two of them — the charger-power hard-fault (#7) and the date-board CMD_LOAD_TEXT OOB (#8) — previously sat in my $PMTXTS PR (#5); I've consolidated them here so every fix lives in one place. Independent of #6 (astro). The changes are small and localised (5 files; about half the added lines are in-place comments explaining each trigger). They've been flashed in a combined build (with #5/#6) and run without regression — see Verification. Left as a draft because they touch core paths (timezone load, USB eject, config parse, CDC); happy to split the Critical (#1) into its own PR if you'd rather take that one first.

The fixes

# Severity Where
1 Critical loadRules() RAM overflow from a crafted TZRULES.BIN
2 Major firmwareCheckOnEject() FATFS re-entered from the USB-eject ISR
3 Minor sendDate() MODE_TEXT one-byte overrun of uart2_tx_buffer[32] (time board)
4 Major latchDisplay() (date board) out-of-range dp_pos writes outside buffer_a/b[]
5 Minor rxConfigString() config-over-USB runs nextMode()/sendDate() in the ISR
6 Minor readConfigFile() a zero-timestamp config.txt is never loaded → first-boot hang
7 Major CDC_Copy_Transmit() / CDC_Transmit_FS() NULL deref → hard-fault on charger-only power
8 Minor CMD_LOAD_TEXT (date board) one-byte overrun of text[32]

1 — loadRules() RAM overflow

TZRULES.BIN lives on the FAT volume the clock exposes over USB MSC, so its bytes are host-writable. loadRules() reads rowLength and numEntries (each a uint8_t) straight from the file, then runs for (i=0; i<numEntries; i++) f_read(&rules[i], rowLength, …) into the fixed rules[MAX_RULES] (162 × 8 B) with no check. With both bytes maxed at 255 a malformed file writes ~990 bytes past the end of the array into adjacent globals — and it does so at cold boot and on every zone re-lookup, so it faults reliably.
Fix: reject the file (RULES_HEADER_ERR) when rowLength > sizeof rules[0] or numEntries > MAX_RULES, before the read loop. Valid files are unaffected.
Repro: on the mounted MSC volume, set the rowLength byte (file offset 4, normally 8) to 0xFF — or any zone's numEntries to > 162 — then trigger a zone load (power-cycle, or move position so the timezone is re-looked-up).

2 — firmwareCheckOnEject() FATFS re-entrancy

This runs from the USB MSC eject command, i.e. in the USB OTG ISR, and calls FATFS (f_open/f_read). It already guards with if (QSPI_Locked()) { delayedCheckOnEject=1; return; }, which defers the whole check to the main loop when it sees QSPI contention — so the design isn't naive. The gap is that QSPI_Locked() is only true during a QSPI transfer; in the lulls between transfers within a single main-loop FATFS call it reads false, the deferral doesn't fire, and the ISR re-enters non-reentrant FATFS anyway (corrupting FATFS state / risking a spurious reset).
Fix: a volatile fatfs_busy flag set around the main-loop FATFS regions; the guard becomes if (fatfs_busy || QSPI_Locked()). It's set/cleared only at the main-loop level, so it stays balanced and can't get stuck and block a legitimate firmware-update-on-eject.

3 — MODE_TEXT one-byte overrun (time board)

i = snprintf((char*)&uart2_tx_buffer[1], 30, "%s", textDisplay)snprintf returns the length it would have written, not the truncated count. A 31-char TEXT= (the parser caps the value at 31) leaves i == 31, so the following uart2_tx_buffer[++i] = … writes [32], one past the 32-byte buffer, and the DMA length reads one byte OOB. Self-inflicted via config and only one byte, hence Minor — but easy to close.
Fix: if (i > 29) i = 29; — clamp to the bytes the snprintf actually wrote.

4 — date-board dp_pos out-of-bounds

dp_pos comes from text_idx, which the inter-board UART stream can advance past the 10-digit display. latchDisplay() then indexes buffer_a[5]/buffer_b[5] with 9-dp_pos (goes negative when the display is inverted) and dp_pos-6 (overruns when it isn't) → OOB writes into adjacent RAM.
Fix: bail out at the top of the decimal-point block when dp_pos is out of range for the current orientation (> 9 inverted, > 10 otherwise), so neither expression can leave [0,4].

5 — config-over-USB runs nextMode()/sendDate() in the ISR

rxConfigString() runs in the USB OTG ISR (CDC). On each completed line it called postConfigCleanup(), which calls nextMode() and sendDate(). The priority-0 SysTick repaint preempts the USB ISR and calls sendDate(0) directly — so sendDate() is genuinely re-entered — and nextMode() mutates display state (displayMode, the segment buffers) that the repaint reads, so it races too. Result: a torn date row or inconsistent mode state.
Fix: defer postConfigCleanup() to the main loop via a new delayedPostConfigCleanup flag. The file-config path already runs postConfigCleanup() in thread context (because readConfigFile itself is the thing deferred to the main loop); this gives the USB-CDC path the same thread-context guarantee.

6 — zero-timestamp config.txt → first-boot hang

config is {0}-initialised, so the fdate/ftime mtime-cache key starts at 0. A config.txt written while the volume's FAT clock was unset carries a zero timestamp, which matches that initial key on the very first boot → readConfigFile() returns early → config is never applied → no display mode is enabled → the first MODE-button press spins nextMode() forever (there's no watchdog).
Fix: never treat a zero stamp as a cache hit — if ((fno.fdate || fno.ftime) && fno.fdate==config.fdate && fno.ftime==config.ftime) return;.

7 — NMEA-over-CDC NULL deref → hard-fault on charger-only power

CDC_Copy_Transmit() and stock CDC_Transmit_FS() dereference hUsbDeviceFS.pClassDataCDC unconditionally, but it is NULL until a USB host enumerates. With the default nmea_cdc_level = NMEA_ALL, every GPS sentence is forwarded to CDC, so on charger-only power — no host — the first forwarded sentence dereferences NULL in ISR context and hard-faults. This is the most common deployment (a dumb USB charger), so it faults reliably there.
Fix: a NULL guard at the top of both functions (return cleanly when not enumerated), plus a length clamp before the memcpy into the static tx buffer. mk4-time/USB_DEVICE/App/usbd_cdc_if.c.

8 — date-board CMD_LOAD_TEXT one-byte OOB

Distinct from #3 (that's uart2_tx_buffer on the time board; this is the text[] buffer on the date board). CMD_LOAD_TEXT bounds with if (text_idx > MAX_TEXT_LEN) return; then writes text[text_idx++] = x;. At text_idx == MAX_TEXT_LEN (32) the guard passes and it writes text[32], one past the 32-byte array, into the adjacent global. Reachable with a 32-plus-char text-mode string.
Fix: >>=. mk4-date/Core/Src/main.c.

Verification

  • Flashed and running, no regression — built into a combined firmware (with Add $PMTXTS PPS timestamping (opt-in host timing telemetry) #5/Astro pack: sun / moon / Maidenhead / lat-lon display modes #6) and flashed to a real STM32L476 unit; it boots and holds GPS lock with all changes in place. That verifies they don't break normal operation — but I haven't deliberately reproduced each fault's trigger on hardware (e.g. dropping a crafted TZRULES.BIN), so the per-bug case above rests on the code, not on a hardware repro of each fault.
  • Each fix was re-checked against the surrounding code for off-by-ones and for not regressing the valid case (a normal TZRULES.BIN/config.txt still loads; fatfs_busy is balanced so it can't latch on).
  • No new files and no project/linker changes — the build is structurally unchanged.

Files

  • mk4-time/Core/Src/main.c — fixes 1, 3, 5, 6, plus the fatfs_busy wraps for 2
  • mk4-time/Core/Src/chainloader.c and Core/Inc/main.h — fix 2
  • mk4-time/USB_DEVICE/App/usbd_cdc_if.c — fix 7
  • mk4-date/Core/Src/main.c — fixes 4, 8

@peterlewis peterlewis changed the title Fix RAM overflow from a crafted TZRULES.BIN, plus five smaller bounds / ISR-safety bugs Fix RAM overflow from a crafted TZRULES.BIN, plus seven smaller bounds / ISR-safety bugs Jul 4, 2026
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 6, 2026
mk4-time/main.c conflicted whole-file (EOL mismatch between the PR branches);
resolved to the validated rollup integration. The final integration-delta commit
records the exact residue of the whole union against the PR branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 6, 2026
The exact residue between the pure union of the five draft PRs (mitxela#5 $PMTXTS,
mitxela#6 astro pack, mitxela#7 hardening, mitxela#8 sidereal/solar, mitxela#9 tempcomp + significance-fade
+ tc_seed — mitxela#5 and mitxela#6 arrive as ancestors of mitxela#9 and mitxela#8) and the rollup tree that
has been emulator-verified and hardware-run:

- astro.c/astro.h/test_astro.c: rollup astro refinements not yet folded back into
  the refreshed PR mitxela#6/mitxela#8 heads (candidates for a future PR update)
- version.c: rollup version string
- qspi/output/*.bin: rollup's built firmware images
- .settings IDE noise

After this commit the megapack tree is BYTE-IDENTICAL to the previously validated
rollup (feaf970) while the merge ancestry proves containment of all five PR heads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 7, 2026
The committed date-board image predated the current tree; rebuilt so both
flash images (fwt + fwd) are products of exactly this source, including the
PR mitxela#7 date-board hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 7, 2026
The date board carries the CMD_LOAD_TEXT OOB fix (mitxela#7) over mitxela's shipped
0.0.1, so it's a new release — bump mk4-date VERSION_STRING 0.0.1 -> 0.0.2 and
rebuild fwd.bin (mirrors the time board's 0.0.4 -> 0.0.5). Also commits the
current fwt.bin (0.0.5, $PMTXTS + SOF correlation + audit fixes) so rollup's
qspi/output/*.bin both match their source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis and others added 6 commits July 12, 2026 11:45
With `pps = on` in config.txt, emit one proprietary NMEA sentence per PPS edge
over the existing CDC stream, carrying the sub-second phase captured at the edge
plus calibration / holdover / die-temperature telemetry. Time-critical fields are
snapshotted in the PPS ISR; formatting and CDC submission happen in the main loop,
serialised against the NMEA passthrough. With no enumerated host the record is
dropped before any formatting.

mk4-time/Core/Src/main.c: capturePPS(), emitPPSTimestamp(), measure_temp(), a
`pps` config key and one main-loop hook. qspi/config.txt documents the key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ver USB

Experimental attack on the ~6ms host-driven USB read jitter that swamps the
clock's true ~180us precision — without a hardware PPS wire. The OTG_FS core
rules out mitxela's last-microsecond FIFO injection, so instead we let the host
anchor each PPS to a USB Start-Of-Frame, whose own arrival time the host can
read in hardware (macOS IOKit GetBusFrameNumberWithTime).

- Enable DWT->CYCCNT (free-running 12.5ns core-cycle counter) as the timebase
  both the PPS edge and each SOF are latched against. Unaffected by tempcomp
  SysTick steering (counts raw core clock), so it's a stable monotonic ruler.
- Enable the USB SOF interrupt; PCD_SOFCallback latches (DWT, 11-bit frame from
  DSTS[13:8]) every 1ms, as its first act for minimal latency.
- capturePPS() latches DWT at the edge; emitPPSTimestamp() appends the tail
  ,dwt_pps,sof_frame,dwt_sof to $PMTXTS (read under __disable_irq, no torn read).
- Bump NMEA_BUF_SIZE 90->128 for the longer sentence.

Host places the edge at hostTime(sof_frame) + (dwt_pps-dwt_sof)/f_dwt, immune to
delivery lateness; dwt_pps deltas self-calibrate f_dwt (no core-clock assumption).
Backward-compatible: lenient $PMTXTS parsers read the first 9 fields. Bench-only;
+1kHz SOF ISR (~0.05% CPU) is the cost to watch. Builds clean (0 errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial audit of the SOF-correlation change surfaced two real issues:
- Stale first anchor: pps_sof_dwt/frame init to 0, so the first PPS emitted
  after enumeration (or with pps just toggled on, or on the emulator which has
  no USB SOF) carried a (0,0) anchor → a ~53s/−729ms host error on that record.
  Now a pps_sof_valid flag gates the tail: emit the plain 9-field sentence until
  a real SOF has latched. Backward-compatible; also makes the emulator correct.
- SOF work ran unconditionally. Gate the latch on pps_ts_enabled (the SOF IRQ
  still fires — cheap, below the priority-0 display DMA — but does nothing when
  the feature is off); clear valid when off so a re-enable can't reuse a stale
  anchor. config.txt documents that pps=on enables the SOF tail.
Builds clean (0 errors). Refuted findings (torn read, 16-bit atomicity, buffer
truncation, display preemption, scope) left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five GPS-derived astronomy read-outs, each opt-in via a MODE_* config key and
shown on the 10-char date row while the live clock keeps running on the time
row (SATVIEW-style, COUNT_NORMAL):

  MODE_SUN       sunrise / sunset / solar noon (local), auto-paged
  MODE_SUN_AZEL  sun azimuth & elevation, now
  MODE_MOON      moon phase index (0-7) + illuminated %
  MODE_GRID      Maidenhead grid locator
  MODE_LATLON    latitude / longitude, auto-paged

The astronomy maths is a self-contained Core/Src/astro.c (+ astro.h): low-
precision NOAA/Meeus sun alt-az and event times, moon phase, equation of time,
and Maidenhead. It has no firmware dependencies, so it unit-tests natively
(test/test_astro.c, 45 reference vectors) and was validated to ~1e-5 before
touching the firmware.

The L4 has only a single-precision FPU, so the double maths would be slow soft-
float. It is therefore computed in the main loop (astro_update(), gated on the
active mode exactly as measure_vbat() is for MODE_VBAT) and swapped into a cache
under a brief __disable_irq() mask; the sendDate() cases that run inside the
SysTick ISR only format the cached scalars -- no trig in the interrupt.

Modes are disabled by default; with no GPS fix they use fake_latitude/longitude
if set, else show "----". config.txt documents the keys and the moon-phase
index legend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"LAT 51.48" vs "LAT-51.48" let the minus swallow the separator. Add a sign
slot after the label space — "LAT  51.48" / "LAT -51.48" — so the digits
start in the same column either way, matching the RISE/SET layout. A 3-digit
longitude can't fit both the separator and the slot in 10 characters, so the
separator alone is dropped there ("LON-179.99").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The paged modes (SUN, LATLON) previously flipped sub-screen every 2 s hard-coded.
Make the dwell a config key (page_ms, ms; unset -> 5500, floored at 250 so a tiny
value can't flood the date-board UART), driven off uwTick, and repaint the moment a
page flips rather than waiting for the 1 Hz date-row refresh (guarded by decisec!=9
to avoid racing the SysTick sendDate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 12, 2026
The committed date-board image predated the current tree; rebuilt so both
flash images (fwt + fwd) are products of exactly this source, including the
PR mitxela#7 date-board hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 12, 2026
The date board carries the CMD_LOAD_TEXT OOB fix (mitxela#7) over mitxela's shipped
0.0.1, so it's a new release — bump mk4-date VERSION_STRING 0.0.1 -> 0.0.2 and
rebuild fwd.bin (mirrors the time board's 0.0.4 -> 0.0.5). Also commits the
current fwt.bin (0.0.5, $PMTXTS + SOF correlation + audit fixes) so rollup's
qspi/output/*.bin both match their source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@peterlewis peterlewis force-pushed the firmware-hardening branch from a2cd14b to 0be8ead Compare July 12, 2026 12:03
peterlewis and others added 2 commits July 12, 2026 14:14
- loadRules: reject untrusted rowLength/numEntries from TZRULES.BIN (RAM overflow)
- MODE_TEXT: clamp snprintf untruncated-return index (uart2_tx_buffer OOB)
- mk4-date latchDisplay: bound dp_pos per orientation (buffer_a/b OOB)
- readConfigFile: don't treat a zero FAT timestamp as a cache hit (first-boot hang)
- config-over-USB: defer postConfigCleanup out of the USB ISR (sendDate/nextMode races)
- firmwareCheckOnEject: fatfs_busy guard for FATFS reentrancy on USB eject

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two further pre-existing bugs from the same read-through.

- mk4-time NMEA-over-CDC: CDC_Copy_Transmit() / CDC_Transmit_FS() dereference
  hUsbDeviceFS.pClassDataCDC unconditionally, but it is NULL until a USB host
  enumerates. The default nmea_cdc_level = NMEA_ALL forwards every GPS sentence
  to CDC, so on charger-only power the first forwarded sentence faults in ISR
  context. NULL guard on both, plus a length clamp before the memcpy.

- mk4-date CMD_LOAD_TEXT: 'if (text_idx > MAX_TEXT_LEN) return;' passes at
  text_idx == MAX_TEXT_LEN and writes text[32], one past the 32-byte array.
  '>' -> '>='.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 12, 2026
The committed date-board image predated the current tree; rebuilt so both
flash images (fwt + fwd) are products of exactly this source, including the
PR mitxela#7 date-board hardening.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
peterlewis added a commit to peterlewis/clock4 that referenced this pull request Jul 12, 2026
The date board carries the CMD_LOAD_TEXT OOB fix (mitxela#7) over mitxela's shipped
0.0.1, so it's a new release — bump mk4-date VERSION_STRING 0.0.1 -> 0.0.2 and
rebuild fwd.bin (mirrors the time board's 0.0.4 -> 0.0.5). Also commits the
current fwt.bin (0.0.5, $PMTXTS + SOF correlation + audit fixes) so rollup's
qspi/output/*.bin both match their source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@peterlewis peterlewis force-pushed the firmware-hardening branch from 0be8ead to aff121d Compare July 12, 2026 13:28
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