Repository navigation
fix(debug): validate GDB M-packet payloads before writing memory - #62
Conversation
handle_write_mem() parses an "M<addr>,<len>:<hexdata>" packet from the
remote debugger. It bounded len to 128 and located the ':' separator, but
never checked that the packet actually carried 2*len hex digits after it:
const char *data = strchr(pkt, ':');
if (!data) { send_packet(stub, "E01"); return; }
data++;
uint8_t buf[128];
for (uint32_t i = 0; i < len; i++)
buf[i] = (hex_val(data[i * 2]) << 4) | hex_val(data[i * 2 + 1]);
A peer sending "$M0,80:#xx" declares 128 bytes and supplies none. The loop
then reads 256 bytes starting at the NUL terminator - through the
uninitialised remainder of the caller's 512-byte packet buffer, and past
its end when the address field is padded with leading zeros to push the
separator far enough right. Those bytes are handed to eos_gdb_write_mem()
at an address the peer chooses.
hex_val() compounds this by mapping every non-hex byte to 0, so a
malformed payload was accepted as zero bytes rather than rejected.
Fix: require the payload to be at least 2*len characters before decoding,
and decode through a checked hex helper that rejects the packet on the
first invalid digit. Both paths reply E01 and return without touching
memory. hex_val() is left in place for the checksum path, where a lenient
parse only affects an integrity comparison that is checked separately.
Adds scripted-transport coverage to tests/test_debug.c. The stub's packet
handling had no tests at all - the existing cases only exercised init,
breakpoint bookkeeping and start/stop, never the parser.
Two related observations, deliberately left alone to keep this change
narrow:
- eos_gdb_read_mem()/eos_gdb_write_mem() take a uint32_t address and
cast it to a pointer, which truncates on a 64-bit host. The new tests
therefore cover only packets the stub must reject; exercising an
accepted M packet would dereference a truncated address.
- eos_gdb_handle_exception() breaks out of its command loop whenever
recv_packet() returns -1, so a single bad checksum ends the session
instead of letting the peer retransmit.
Signed-off-by: seedyjahateh <jahatehs@berea.edu>
There was a problem hiding this comment.
Pull request overview
This PR hardens the GDB remote stub’s M<addr>,<len>:<hexdata> write-memory packet handling by validating payload length and rejecting non-hex digits, and adds host-side tests that exercise these rejection paths to prevent the prior out-of-bounds read / attacker-controlled memory write.
Changes:
- Add
hex_val_checked()and enforce payload validation inhandle_write_mem()before decoding/writing memory. - Add a scripted
EosGdbIOtransport intests/test_debug.cand new regression tests for malformedMpackets (plus aqSupportedsanity round-trip).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
debug/src/gdb_stub.c |
Validates M packet payload length and hex digits before decoding into the write buffer. |
tests/test_debug.c |
Adds a scripted mock transport and new regression tests covering invalid M packet cases. |
Suppressed comments (1)
tests/test_debug.c:103
- gdb_push() appends to the global gdb_rx script buffer without any bounds checks. As written, adding longer packets or more scripted steps will silently overflow gdb_rx and cause UB in the test process. Add a capacity assertion (or clamp) before writing "$#".
gdb_rx[gdb_rx_len++] = '$';
for (size_t i = 0; i < n; i++) {
sum = (uint8_t)(sum + (uint8_t)body[i]);
gdb_rx[gdb_rx_len++] = body[i];
}
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| static int mock_write(void *ctx, const uint8_t *buf, int len) { | ||
| (void)ctx; | ||
| for (int i = 0; i < len && gdb_tx_len < GDB_TX_CAP - 1; i++) { | ||
| gdb_tx[gdb_tx_len++] = (char)buf[i]; | ||
| } | ||
| gdb_tx[gdb_tx_len] = '\0'; | ||
| return len; | ||
| } |
Addresses review feedback on the test harness. mock_write() truncated once gdb_tx neared capacity but still returned the requested length, which violates the EosGdbIO.write contract and could hide a failure: tx_has() would report a reply missing that the stub had actually sent. It now writes the full buffer and asserts there is room, so an undersized capture buffer fails loudly instead of silently. gdb_push() likewise appended to gdb_rx with no capacity check, so a longer scripted packet would have overflowed a static buffer inside the test process. Assert before writing. No production code changes; the rejection tests and their evidence are unaffected. Signed-off-by: seedyjahateh <jahatehs@berea.edu>
ef47f28 to
e8f4a91
Compare
|
Thanks — valid on both counts, addressed in
Worth noting the irony that a test suite for an out-of-bounds write had two unbounded buffers of its own; both are now bounded. Re-verified after the change: |
Review — verified, ready to mergeBuilt merged onto current I checked the specific concern a reviewer would have here, which is whether
The scripted transport in Your note that the accept path is not exercised because |
srpatcha
left a comment
There was a problem hiding this comment.
Verified locally against current master: merges clean, builds with no new errors, ctest 24/24.
This is a remote-triggerable write and worth having. Two distinct holes, both closed:
- The decode loop read
len * 2characters fromdatawith nothing establishing they existed, so a truncatedMpacket read past the received buffer and the bytes that happened to follow it were written to an address the peer chose. hex_val()maps every non-hex byte to0, so a malformed payload silently decoded to zeros and still got written. Splitting outhex_val_checkedrather than changinghex_valis the right call — the read path's use of it is a display concern where lenience is harmless.
Checked the buffer: buf[128] is safe because the existing len > 128 test rejects before the loop, so the new length check does not need to carry that too.
Also appreciated the note that the accept path is deliberately untested because eos_gdb_read_mem/write_mem cast a uint32_t straight to a pointer and would dereference a truncated address on a 64-bit host. That is the honest scope for this PR; the pointer-width problem is a separate change.
The problem
The GDB stub writes attacker-chosen memory from data it never validated.
handle_write_mem()indebug/src/gdb_stub.cparses anM<addr>,<len>:<hexdata>packet. It boundslento 128 and finds the:separator, but never checks that the packet actually carries2 * lenhex digits after it:A peer sending
$M0,80:#xxdeclares 128 bytes and supplies none. The loop reads 256 bytes starting at the NUL terminator — straight through the uninitialised remainder of the caller'schar pkt[512]— and then hands those bytes toeos_gdb_write_mem()at an address the peer chose. Because%xaccepts arbitrarily many leading zeros, the address field can also be padded to push the separator past index 500, making the same loop read beyondpkt[512]entirely.hex_val()compounds it by mapping every non-hex byte to0, so a malformed payload was accepted as zero bytes rather than rejected.This is not theoretical. Running the new tests against unmodified
gdb_stub.ccrashes the process with an access violation (0xC0000005), because the decoded garbage is memcpy'd to the peer-supplied address.The approach
Two checks in
handle_write_mem(), both replyingE01and returning without touching memory:strlen(data) >= 2 * lenbefore decoding.hex_val_checked()that returns-1on a non-hex byte, and reject on the first invalid digit.hex_val()is left in place for the checksum path, where a lenient parse only affects an integrity comparison that is verified separately. Nothing else in the file is changed.Testing
tests/test_debug.cgains a scripted transport — a mockEosGdbIOthat feeds crafted packets and captures replies. The stub's packet handling had no test coverage at all; the existing cases only exercised init, breakpoint bookkeeping, and start/stop, never the parser.Four rejection cases plus a
qSupportedround-trip to confirm normal packets still work:M0,80:— declares 128 bytes, supplies noneM0,4:aabb— declares 8 digits, supplies 4M0,2:zzzz— non-hex payloadM0,1000:aabb— length over the 128 limitVerification (GCC 16.2.0,
-DEOS_BUILD_TESTS=ON):test_debugagainst unfixed source0xC0000005test_debugagainst fixed sourcectestfull suitegcc -std=c11 -Wall -Wextraongdb_stub.ccmake -DEOS_PRODUCT=robot(CI-gated profile)test_packagefails with a segfault both before and after this change. It is pre-existing onmasterand already addressed by #48 and #52, so I have not touched it.Considerations and limitations
The tests cover only packets the stub must reject.
eos_gdb_read_mem()/eos_gdb_write_mem()take auint32_taddress and cast it straight to a pointer, which truncates on a 64-bit host — so exercising an acceptedMpacket from a host test would dereference a truncated address. Fixing that means changing the signatures touintptr_t, which is an API break and belongs in its own PR. I have left it alone and am flagging it rather than working around it silently.Also observed, deliberately not changed:
eos_gdb_handle_exception()doesif (n < 0) break;, so a single bad checksum ends the debug session instead of letting the peer retransmit — which is what the GDB remote protocol expects after a-. I initially fixed a related framing issue inrecv_packet()(an over-long packet body left trailing bytes in the stream), then reverted it: with the currentbreak, the desync cannot actually be reached, and the change was not covered by a failing-before test. Both are worth a follow-up.Environment. Validated on Windows with GCC 16.2.0 via a portable MinGW-w64 toolchain. I could not run the MSVC or Clang legs locally; the change is plain C11 with no platform-specific constructs, but CI is the real check for those.