2026-06-09 - research - GPSD part 3
Fuzzing GPSD,
Part 3: The Bugs
The first two GPSD posts were about building and then living with the structure-aware fuzzer: the gps_lexer_t harness, mutation strategy, corpus mistakes, target-selection mistakes, and crash triage. This post covers the findings. It walks through the bugs disclosed in public GPSD work item #397: eight memory-safety issues across Skytraq, NMEA, AIS, AllyStar, u-blox, passthrough mode, and the client request parser.
GPSD looks small from the outside. Feed it GPS receiver bytes, get JSON back. Inside, it is a collection of small protocol parsers: NMEA text, binary vendor formats, RTCM, AIS, network GPS sources, proxy mode, and client commands all taking turns in the same daemon. The bugs below are not all the same shape, which is the point — the attack surface is wide because the protocol surface is wide.
This is a public writeup of a public issue. It intentionally avoids exploit payloads and reproduction recipes. The defender-facing pieces are here: what broke, why it mattered, where to patch, and what pattern to go hunting for in adjacent code.
The short version
| # | Component | Bug class | Impact |
|---|---|---|---|
| 1 | Skytraq 0xDD raw measurement | Out-of-bounds write | Critical: corruption primitive, demonstrated RCE in lab |
| 2 | NMEA $PGRMT / $INFO | Format string plus over-read | Critical: write primitive and pointer/canary disclosure |
| 3 | AIS Type 6 / Type 8 app IDs | Unbounded array writes | High: decoded-state corruption over AIS feeds |
| 4 | AllyStar NAV-SVINFO | Out-of-bounds read | Medium: short payloads read past packet data |
| 5 | u-blox RXM-RAWX | Out-of-bounds read | Medium: raw measurement count outruns payload length |
| 6 | u-blox NAV-SBAS | Non-fatal length check | Medium: logs the bad length, then keeps reading |
| 7 | PASSTHROUGH mode | One-byte over-read | Medium: proxy output can transmit one byte past the buffer |
| 8 | Client request parser | Stale-buffer disclosure | Medium: unauthenticated cross-client request leak |
Reachability is honest rather than dramatic. Some paths require a malicious serial or USB receiver. Some are reachable when GPSD is configured with a network GPS source such as tcp://, udp://, or gpsd://. AIS and correction feeds are their own world. The client parser issue is the simplest: any client that can connect to port 2947 can hit it.
What found what
Two harnesses did the automated finding: the in-process gpsd_fuzzer from Parts 1 and 2, and a separate remote PTY-to-TCP leak fuzzer that scores GPSD's JSON output for memory disclosure instead of waiting for a crash. Both are open source and linked under References (gpsd-driver-fuzzer and gpsd-disclosure-fuzzer).
| Bug | Where it came from |
|---|---|
| Skytraq 0xDD | Found by gpsd_fuzzer's session/full-pipeline mode; the RCE chain was manual exploit work after the crash. |
| AllyStar NAV-SVINFO | Found by the remote PTY-to-TCP leak fuzzer, which scored JSON output instead of only crashes. |
| u-blox RXM-RAWX | Surfaced by the remote leak-fuzzer workflow and validated by hand because the bad read reaches RAW JSON fields. |
| u-blox NAV-SBAS | Crash-style driver bug from the session/full-pipeline fuzzing side: the parser logs the short length and keeps reading. |
NMEA $PGRMT / $INFO | Manual audit plus dynamic confirmation, not a fuzzer find. |
| AIS Type 6 / Type 8 app IDs | Manual source audit. The writes land inside a larger AIS union, so a crash fuzzer would not reliably complain. |
| PASSTHROUGH one-byte over-read | Manual proxy-path audit, not found by either GPS receiver fuzzer. |
| Client request parser | Manual client-path test, confirmed dynamically across two clients; not found by the device fuzzers. |
Skytraq 0xDD
The Skytraq raw measurement handler reads a measurement count from the packet and uses it as the loop bound. The packet length is checked, but the count is not clamped against the destination array. In the tree cited by the work item, raw.meas[] has MAXCHANNELS slots, while the packet count can be larger.
// drivers/driver_skytraq.c, simplified shape
nmeas = getub(buf, 2);
for (i = 0; i < nmeas; i++) {
session->gpsdata.raw.meas[i].pseudorange = prMes;
session->gpsdata.raw.meas[i].carrierphase = cpMes;
session->gpsdata.raw.meas[i].doppler = doMes;
}
This is the most serious of the eight. The session fuzzer found the crash path; the rest was manual work with struct layouts and debugger sessions. With the right layout, the write walks past the measurement array and into adjacent session state. In the lab chain, the corruption reached a function-pointer path used by the polling loop, which is what made it a code-execution issue rather than just a crash.
The fix is the obvious one: reject impossible counts before the loop. The sibling Skytraq parser already has the pattern.
if (SKY_CHANNELS < nmeas) {
return 0;
}
Demo
A controlled local demo of the Skytraq chain. No exploit recipe here, just the end state: a GPS parser goes from receiver data to a controlled indirect call under a debugger.
NMEA $PGRMT / $INFO
NMEA is mostly ASCII sentences, which makes it easy to underestimate. In this issue, receiver-supplied identification strings flow into JSON notification text. GPSD escapes quotes, control bytes, and non-ASCII characters, but not percent signs. Later, that dynamic string reaches a notification helper as the format argument to vsnprintf().
There are two bad outcomes. First, classic format-string behavior gives a write primitive through format specifiers that should never be interpreted as formatting. Second, the notification path uses the vsnprintf() return value as the length passed to throttled_write(). Since that return value is the would-have-been length, not necessarily the number of bytes stored in the fixed buffer, a padded format can make GPSD transmit far beyond the local stack buffer.
That second point is the real leak. The work item notes a dynamic confirmation where a single device event returned data well past BUFSIZ, with live PIE/libc-class pointers visible beyond the real buffer. The fix has two halves: never pass receiver-controlled text as a format string, and clamp the output length to bytes that were actually stored.
This one was not a fuzzer find. It came from following device identity strings through the JSON notification code and asking whether the string was still data or had quietly become a format.
// shape of the defensive pattern
notify_watchers(device, changed, "%s", receiver_supplied_text);
if (len >= (int)sizeof(buf)) {
len = (int)sizeof(buf) - 1;
}
AIS Type 6 and 8
The AIS bugs are a reminder that "it did not crash" is not the same as "it is fine." Several AIS application ID handlers derive loop counts from attacker-controlled bit lengths or 5-bit count fields, then write into small fixed arrays.
| Message | Destination | Problem |
|---|---|---|
| Type 6 FID25 | cargos[28] | Loop can index beyond the 28 cargo slots. |
| Type 6 FID14 / FID32 | tidals[3] | Bit-length-derived loops exceed three tidal entries. |
| Type 6 FID28 | waypoints[16] | 5-bit waypoint count can exceed the array. |
| Type 8 FID17 | targets[4] | Target loop can run past four entries. |
| Type 8 FID27 | waypoints[16] | Same waypoint-count problem in a different message. |
The current layout absorbs these writes inside a larger AIS union, which is why they are not showy crash bugs and why I will not pretend a fuzzer handed them over. They came from reading the AIS handlers after the other count/length bugs made the pattern hard to ignore. They still corrupt decoded state outside the intended member, and for a daemon that broadcasts parsed navigation data to clients, that matters. The fix is plain: every loop needs both the protocol bit-length condition and the local array bound.
Three read bugs with the same shape
AllyStar NAV-SVINFO and u-blox RXM-RAWX share a pattern: the parser checks that a count is not too large globally, then forgets to prove the packet is long enough for that many entries. A count of ten might be legal in the abstract; it is not legal if the packet only contains two entries.
u-blox NAV-SBAS is more blunt. The parser notices a too-short payload and logs a warning, but then continues into the loop anyway. The check exists; it just is not enforced.
// defensive shape for counted records
if (data_len < header_len + ((size_t)count * record_size)) {
return 0;
}
These are the kind of bugs that different harnesses split between them. The separate remote leak harness was good at "daemon survived, but what did JSON leak?" cases such as AllyStar and RAWX. The session/full-pipeline path in gpsd_fuzzer was better for crashy driver paths such as NAV-SBAS. Keep the packet recognizable, make the count plausible, shorten the body, and see which parser trusted the count more than the bytes.
PASSTHROUGH and the client parser
PASSTHROUGH mode had a small length-accounting bug. GPSD appends \r\n with a size-bounded string operation, but sends outbuflen + 2 bytes independently of how much fit. When the buffer is full enough, one byte past the buffer can be transmitted. It is small, but it is still a boundary violation in proxy output. The correct send length is the measured in-buffer string length, not the hoped-for length.
The client request parser issue is more interesting because it crosses clients. A malformed command can put the parser onto an error path where it advances the input pointer by the length of the generated error JSON, not by the length of input consumed. Since the receive buffer is not fully cleared and the handler is passed the buffer size rather than the received length, the parser can wander into stale bytes left by a previous client and echo them back as further errors.
In testing, this leaked request text from an earlier client, not pointers or stack canaries. That keeps the severity bounded, but the boundary is still wrong: client A should not get client B's old request bytes because client A sent ?z.
Neither of these came from the two receiver fuzzers. PASSTHROUGH lives in proxy output length accounting, and the client parser bug lives on the TCP command side. They were the kind of follow-on finds you get when the fuzzing results make you distrust every nearby length variable.
What the fuzzer taught us
The biggest lesson from this run is that a fuzzer only answers the question you actually wired up. If you only ask about clean packet framing, it gets very good at clean packet framing. If you ask whether malformed receiver data can become client-visible JSON, you get a different set of results. And if fuzzing keeps pointing at count fields, you should read the neighboring code by hand.
The second lesson is that these bugs do not map cleanly onto severity buckets. One is a clean corruption primitive. One is a format string that becomes a stack disclosure because of a second length mistake. Several are small count/length mismatches. One leaks stale request text because a reply length got reused as an input cursor. They all come from the same mistake: trusting a nearby number because it was convenient.
If you maintain code like this, the checklist is short and worth doing slowly:
- Every count field gets checked against the destination array and the remaining packet length.
- Every formatted output path treats device/client strings as data, never as a format.
- Every string formatting return value is clamped before becoming a write length.
- Every parser cursor advances by consumed input, not by emitted output.
- Every "bad length" warning either returns or proves the later loop cannot read past the data.
References
| Public GPSD work item | gpsd/gpsd#397 |
| Target noted in disclosure | master @ 43a340603, post 3.27.5 |
| Disclosure status | Reported and fixed upstream; see gpsd/gpsd#397 for the patches and fix commits. |
| Series context | Part 1 covers gpsd_fuzzer. Part 2 covers the campaign lessons. |
| Driver fuzzer (Parts 1–2) | github.com/xchglabs/gpsd-driver-fuzzer |
| Memory-disclosure fuzzer | github.com/xchglabs/gpsd-disclosure-fuzzer |