Files
MicrOBU/obu-firmware/test/host/fuzz_gn_unwrap.c
T
Ashin Walpola 75d6d3b85c Receive signed ITS messages and forward each at its declared length
Signed packets. A GeoNetworking Basic Header NextHeader of 2 means a
TS 103 097 (IEEE 1609.2) envelope follows, with the Common Header
inside it. gn_unwrap_its rejected all of these, and most real traffic is
signed: the 2026-08-17 capture holds 157 signed frames from 15 source
MACs against 2 unsecured stations. It now opens a COER-encoded
signedData, or a bare unsecuredData, and parses the inner packet as
before. The inner packet comes first inside tbsData, so the certificate
and signature are never parsed, and the signature is not verified - the
firmware has no trust store. Such messages reach the phone with the new
V2X_RX flags bit1, signed but not verified. The app reads only bit0 and
is unaffected until it learns the flag. Encrypted payloads, nested
signing and the legacy v1.2.1 envelope are still rejected. All 157
recorded signed frames have the layout this reads, in all three COER
length forms, and asn1tools decodes every envelope to the same inner
packet.

Payload bounds. Every frame recorded through the ESP32-C5's promiscuous
RX, about 15 000 of them, ends in 8 bytes that are not part of the
802.11 frame and not a valid FCS. obu-firmware reads frames through the
same API and took the rest of the frame as the message, so it forwarded
those 8 bytes to the phone after every message. UPER decoders stop where
the message ends, so nothing visibly broke, but the bytes cost serial
bandwidth and 8 bytes of the DENM's headroom, and they stayed attached
wherever raw payloads were stored or passed on. The payload is now
exactly what the Common Header's payload-length field declares, which is
also what separates a signed message from its signature.

A frame longer than main.c's 800-byte capture buffer is now reported as
truncated instead of being forwarded cut off, and counted as an oversize
drop through the new serial_link_note_oversize_drop, as it was when the
cut-off frame failed serial_link's size check.

Host tests in obu-firmware/test/host build the firmware sources
unmodified with MSYS2 gcc; `make` runs all three.
- test_chain: frames from the firmware's TX code checked byte by byte
  against EN 302 636-4-1 and parsed back, including hand-built signed
  frames, the payload-length rule, the RX trailer, and every truncation
  length against a no-access guard page. 1731 checks, 0 failures.
- test_replay and check_replay.py: all 15 145 recorded frames through
  gn_unwrap_its, cut to 800 bytes as on the board, and re-derived
  independently in Python with the envelope decoded by asn1tools. They
  agree on every record; 15 131 accepted, 157 of them signed. 11 043 of
  the 11 106 distinct messages re-encode byte-identically. The other 63
  fail the same way with the old 8 bytes put back, so the boundary is
  not the cause: 5 are our own CAMs from before the 2026-08-20
  yawRateConfidence fix, and the rest, from other stations, are a
  follow-up in TODO.md.
- fuzz_gn_unwrap: random edits of every recorded frame, each run against
  the guard page. 50 000 000 iterations, no crash.

obu-firmware/test/pcap_gn_tally.py tallies GeoNetworking header fields
per station over captures; it is how the other stations' lifetimes were
measured. TODO.md collects what is still open, including the on-air
check for this change: it builds on IDF 6.1 but has not been flashed.
2026-09-11 20:19:40 +02:00

219 lines
7.1 KiB
C

// Mutation fuzzer for gn_unwrap_its, the one function in this firmware that parses bytes from the
// air. See README.md.
//
// Seeds are every recorded frame in the pcaps given, plus frames built by the firmware's own TX
// code. Each iteration takes a seed, applies 1-4 random edits - bit flips, random bytes, boundary
// bytes such as COER length markers, 16-bit length fields, truncation, insertion, deletion,
// appended bytes - mostly within the first 128 bytes where the headers are, and runs gn_unwrap_its
// on the result placed against the guard page. A read past the end crashes and prints the input;
// an accepted frame whose payload is not inside the input is reported the same way. MinGW has
// neither libFuzzer nor AddressSanitizer, hence this rather than coverage guidance. The same seed
// gives the same run.
//
// Usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "dot11p.h"
#include "geonet.h"
#include "gn_unwrap.h"
#include "test_util.h"
#define MAX_FRAME 2048 // within the guard page's one page, and above any 802.11 frame
static uint8_t **s_seeds;
static int *s_seed_len;
static int s_nseeds;
static int s_seed_cap;
static void add_seed(const uint8_t *f, int len)
{
if (len <= 0 || len > MAX_FRAME) {
return;
}
if (s_nseeds == s_seed_cap) {
s_seed_cap = s_seed_cap ? 2 * s_seed_cap : 1024;
s_seeds = realloc(s_seeds, (size_t)s_seed_cap * sizeof *s_seeds);
s_seed_len = realloc(s_seed_len, (size_t)s_seed_cap * sizeof *s_seed_len);
if (!s_seeds || !s_seed_len) {
fprintf(stderr, "out of memory\n");
exit(2);
}
}
s_seeds[s_nseeds] = malloc((size_t)len);
if (!s_seeds[s_nseeds]) {
fprintf(stderr, "out of memory\n");
exit(2);
}
memcpy(s_seeds[s_nseeds], f, (size_t)len);
s_seed_len[s_nseeds++] = len;
}
static void on_frame(const uint8_t *f, int len, int index, void *ctx)
{
(void)index;
(void)ctx;
add_seed(f, len);
}
static void add_own_seeds(void)
{
static const uint8_t cam[] = {0x02, 0x02, 0x00, 0x0f, 0x42, 0x3f, 0x37, 0x00, 0x40, 0x2a, 0xb2};
gn_lpv_t lpv;
memset(&lpv, 0, sizeof lpv);
lpv.mac[0] = 0x02;
lpv.station_type = 2;
uint8_t gn[256];
uint8_t f[512];
const int gn_len = geonet_wrap_shb(cam, (int)sizeof cam, &lpv, 2001, gn, sizeof gn);
for (int qos = 0; qos <= 1; qos++) {
add_seed(f, dot11p_build_frame(gn, gn_len, lpv.mac, f, sizeof f, qos));
}
}
static uint64_t s_rng;
static uint64_t rnd(void)
{
s_rng ^= s_rng << 13;
s_rng ^= s_rng >> 7;
s_rng ^= s_rng << 17;
return s_rng;
}
static int below(int n)
{
return n <= 0 ? 0 : (int)(rnd() % (uint64_t)n);
}
// Three times in four inside the headers, otherwise anywhere.
static int pick_pos(int len)
{
return below(4) ? below(len < 128 ? len : 128) : below(len);
}
static const uint8_t k_bytes[] = {0x00, 0x01, 0x02, 0x03, 0x05, 0x10, 0x12, 0x20,
0x40, 0x50, 0x7F, 0x80, 0x81, 0x82, 0x83, 0xFF};
static const uint16_t k_words[] = {0x0000, 0x0001, 0x0003, 0x0004, 0x0005, 0x007F,
0x0080, 0x00FF, 0x0100, 0x7FFF, 0x8000, 0xFFFF};
static void mutate(uint8_t *b, int *len)
{
const int edits = 1 + below(4);
for (int e = 0; e < edits; e++) {
const int n = *len;
switch (below(8)) {
case 0: // flip a bit
if (n) {
b[pick_pos(n)] ^= (uint8_t)(1u << below(8));
}
break;
case 1: // random byte
if (n) {
b[pick_pos(n)] = (uint8_t)rnd();
}
break;
case 2: // boundary byte
if (n) {
b[pick_pos(n)] = k_bytes[below((int)sizeof k_bytes)];
}
break;
case 3: // truncate
*len = below(n + 1);
break;
case 4: { // append
const int add = 1 + below(16);
if (n + add <= MAX_FRAME) {
for (int i = 0; i < add; i++) {
b[n + i] = (uint8_t)rnd();
}
*len = n + add;
}
break;
}
case 5: // insert a byte
if (n < MAX_FRAME) {
const int p = below(n + 1);
memmove(b + p + 1, b + p, (size_t)(n - p));
b[p] = (uint8_t)rnd();
*len = n + 1;
}
break;
case 6: // delete a byte
if (n) {
const int p = below(n);
memmove(b + p, b + p + 1, (size_t)(n - p - 1));
*len = n - 1;
}
break;
default: // boundary 16-bit big-endian value, e.g. a length field
if (n >= 2) {
const int p = pick_pos(n - 1);
const uint16_t v = k_words[below((int)(sizeof k_words / sizeof k_words[0]))];
b[p] = (uint8_t)(v >> 8);
b[p + 1] = (uint8_t)v;
}
break;
}
}
}
int main(int argc, char **argv)
{
if (argc < 3) {
fprintf(stderr, "usage: fuzz_gn_unwrap iterations seed [capture.pcap ...]\n");
return 2;
}
const unsigned long long iterations = strtoull(argv[1], NULL, 10);
s_rng = (strtoull(argv[2], NULL, 10) * 0x9E3779B97F4A7C15ull) | 1;
tu_install_crash_handler();
tu_guard_init();
for (int i = 3; i < argc; i++) {
if (tu_pcap_foreach(argv[i], on_frame, NULL) < 0) {
fprintf(stderr, "cannot read %s as a pcap\n", argv[i]);
return 2;
}
}
add_own_seeds();
static uint8_t buf[MAX_FRAME];
int len = 0;
tu_set_crash_input(buf, &len);
unsigned long long accepted = 0, signed_frames = 0, truncated = 0;
for (unsigned long long it = 0; it < iterations; it++) {
const int s = below(s_nseeds);
len = s_seed_len[s];
memcpy(buf, s_seeds[s], (size_t)len);
mutate(buf, &len);
tu_set_context("fuzz iteration %llu (seed %s), mutated from seed frame %d", it, argv[2], s);
const uint8_t *g = tu_guarded(buf, len);
gn_rx_t rx;
if (gn_unwrap_its(g, len, &rx)) {
accepted++;
signed_frames += rx.signed_unverified;
truncated += rx.truncated;
const uintptr_t lo = (uintptr_t)g;
const uintptr_t p = (uintptr_t)rx.payload;
if (p < lo || rx.payload_len <= 0 || p + (uintptr_t)rx.payload_len > lo + (uintptr_t)len) {
fprintf(stderr, "FAIL during %s: accepted payload lies outside the input\ninput (%d bytes): ",
tu_context(), len);
for (int i = 0; i < len; i++) {
fprintf(stderr, "%02x", buf[i]);
}
fputc('\n', stderr);
return 1;
}
}
}
printf("fuzz_gn_unwrap: %llu iterations from %d seed frames, %llu accepted (%llu signed, "
"%llu truncated), no crash\n",
iterations, s_nseeds, accepted, signed_frames, truncated);
return 0;
}