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 is the payoff round. It covers 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 is a funny target because it looks tiny from the outside. Feed it GPS receiver bytes, get JSON back. Inside, though, it is a cabinet full 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. A GPS daemon is a parser zoo with a socket.
This is a public writeup of the public issue. It intentionally avoids exploit payloads and reproduction recipes. The useful 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 annoyingly 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 special world. The client parser issue is simpler: 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 one and two, 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 below (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 scary one
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;
}
That kind of bug is boring until it lands next to something interesting. Here it does. The session fuzzer found the crash path; the human work started after that, with struct layouts and debugger sessions. With the right layout, the write walks past the measurement array and into adjacent session state. In our lab chain, the corruption reached a function-pointer path used by the polling loop.
The fix shape is exactly what you want it to be: reject impossible counts before the loop. The sibling Skytraq parser already has the pattern.
if (SKY_CHANNELS < nmeas) {
return 0;
}
Demo
This is a controlled local demo of the Skytraq chain. No exploit recipe here, just the satisfying part: a GPS parser goes from receiver data to a controlled indirect call in a debugger. It is always a little surreal when a satellite protocol bug ends up looking like classic userspace pwn.
NMEA $PGRMT / $INFO: percent signs are not decoration
NMEA is mostly ASCII sentences. That makes it pleasant to read, and also 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 in the first place. 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 trophy. It came from following device identity strings through JSON notification code and asking the boring question that always pays rent: "is this string still data, or did it 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: tiny arrays, big bitfields
The AIS bugs are a good 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. |
Current layout absorbs these writes inside a larger AIS union, which is why they are not showy crash bugs and why I do not want to pretend a fuzzer neatly handed them over. They came from reading the AIS handlers after the other count/length bugs made the pattern hard to ignore. Still, they corrupt decoded state outside the intended member. 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 personality
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 even more blunt. The parser notices a too-short payload and logs a warning, but then keeps going into the loop. That is the parser equivalent of seeing the bridge is out and writing "bridge out" in the notebook before driving forward.
// defensive shape for counted records
if (data_len < header_len + ((size_t)count * record_size)) {
return 0;
}
These are not glamorous bugs, but they are exactly the kind 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 right send length is the measured in-buffer string length, not the hoped-for length.
The client request parser issue is more interesting socially than technically 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 sane, 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 sort 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 GPSD run is that a fuzzer only answers the question you actually wired up. If you only ask about clean packet framing, it will become very good at clean packet framing. If you ask whether malformed receiver data can become client-visible JSON, you get a different pile. And if fuzzing keeps pointing at count fields, you should read the neighboring code by hand.
The second lesson is that parser bugs do not care about our neat severity buckets. One issue is a clean corruption primitive. One is a format string that turns into 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 mental trap: 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.
References
| Public GPSD work item | gpsd/gpsd#397 |
| Target noted in disclosure | master @ 43a340603, post 3.27.5 |
| Disclosure status | Reported upstream; see gpsd/gpsd#397 for current fix status. |
| 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 |