PFB-014Vendor: Prusa ResearchProduct: Buddy firmware

A 110-char client G-code overflows the 96-byte queue slot via an unguarded strcpy

•
5 min read
pfb-014.advisory
Severity
CVSS 7.7High

CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:H, FIRST CVSS calculator (opens in a new tab)

CVSS metrics and CWE
  • Attack Vector: Network (worst case)
  • Attack Complexity: High
  • Privileges Required: None (worst case)
  • User Interaction: None (worst case)
  • Scope: Unchanged
  • Confidentiality: Low
  • Integrity: High (worst case)
  • Availability: High (worst case)

downstream CWE-125

CVE
Not yet assigned
Affected models
  • CORE One L+
  • CORE One L
  • CORE One+ (Gen 2) INDX
  • CORE One INDX
  • CORE One+ (Gen 2)
  • CORE One
  • XL+
  • XL
  • MK4S
  • MK4
  • MK3.9S
  • MK3.9
  • MK3.5S
  • MK3.5
  • MINI
Fixed in
7.0.0
Attack surface
Trusted Connect backend
Verification
Reproduced on host

source-verified. Host AddressSanitizer confirms the out-of-bounds write class. Not hardware-tested: we did not demonstrate the on-device landing target, any control-flow effect, or RCE.

Analysis notes
Versions and source tree

Prusa Buddy firmware, MK4S. We verified the defect in the 6.6.0 source tree (commit e96ce2b9). The reference printer runs 6.5.7.

Summary

GCodeQueue::_enqueue copies a client command into a fixed 96-byte ring slot with strcpy and no length check (queue.cpp:148). The marlin_client G-code request carries up to 110 characters in a 111-byte buffer. A command of 96 to 110 characters therefore overruns the slot by 1 to 15 bytes. Only the client path can reach it, because the print and serial paths have a bound of 96. When a command overflows the last slot, it writes past command_buffer into the adjacent GCodeQueue statics.

The defect

command_buffer is a static array of BUFSIZE slots, with MAX_CMD_SIZE bytes in each slot:

cpp
// lib/Marlin/Marlin/src/gcode/queue.cpp:63
char GCodeQueue::command_buffer[BUFSIZE][MAX_CMD_SIZE];

Before _enqueue copies the line, it checks that the line is not a comment and that the ring is not full. Neither check bounds the length:

cpp
// lib/Marlin/Marlin/src/gcode/queue.cpp:147-148
if (*cmd == ';' || length >= BUFSIZE) return false;
strcpy(command_buffer[index_w], cmd);   // no bound on strlen(cmd)

The configuration headers set those two sizes to 96 and 8 on every board (include/marlin/Configuration_MK4_adv.h:600-601 and siblings):

cpp
// include/marlin/Configuration_MK4_adv.h:600-601
#define MAX_CMD_SIZE 96
#define BUFSIZE 8

command_buffer is a static (.bss) array, so this is an out-of-bounds write into static storage: CWE-787 and CWE-120. CWE-121 (a stack-based overflow) does not apply, because the destination is not a stack buffer.

Reaching it

The over-length source is a Request::Type::Gcode from a marlin_client. Its transport buffer holds one byte more than MARLIN_MAX_REQUEST:

cpp
// src/common/marlin_server_request.hpp:46
char gcode[MARLIN_MAX_REQUEST + 1]; // Type::Gcode
cpp
// src/common/config.h:12
MARLIN_MAX_REQUEST = 110, // maximum request length in chars

The buffer is therefore 111 bytes. gcode_request fills it with strlcpy. It rejects only a line that does not fit in 111 bytes (marlin_client.cpp:179). Up to 110 characters therefore pass.

The server passes the request directly to the queue:

cpp
// src/common/marlin_server.cpp:3627-3629
case Request::Type::Gcode:
    //@TODO return value depending on success of enqueueing gcode
    return enqueue_gcode_try(request.gcode);

enqueue_gcode_try calls queue.enqueue_one(gcode) (marlin_server.cpp:1229), which calls _enqueue. Neither wrapper checks the length (queue.cpp:162-178).

The concrete remote route is Prusa Connect. MarlinPrinter::submit_gcode forwards a server-sent command through marlin_client::gcode_try (connect/marlin_printer.cpp:561-562). The attacker position is control of the Connect backend that the printer trusts. This is the PFB-008 position, either self-hosted on the owner's own printer or reached by the custom-CA route.

The other two callers of _enqueue both have the slot size as their bound. The print path fills an array of MAX_CMD_SIZE bytes and sets a cropped flag when a line does not fit (media_prefetch.hpp:51, media_prefetch.cpp:573-574). The server enqueues that 96-byte-bounded buffer and raises WarningType::GcodeCropped (marlin_server.cpp:1771,1782). The serial path caps serial_line_buffer at MAX_CMD_SIZE per port and truncates the line before _enqueue (queue.cpp:352). Only the client path sends a 111-byte buffer into a 96-byte slot with nothing in between.

Impact

strcpy writes strlen + 1 bytes. A command of 96 to 110 characters therefore writes 97 to 111 bytes into the 96-byte slot. The overrun is 1 to 15 bytes, and its content is the tail of the command. For write index 0 to 6, the overrun lands in the next slot, which is still inside command_buffer. For the last slot (index_w 7, which ends at the end of the array), the overrun exits the array into the adjacent statics. A count of submissions is enough to reach that slot.

The STM32 link map sets where the 15 bytes land. In one build that we examined, the first neighbour is index_r (the ring read index), and the next is executed_commmand_count. In the shipped MK4S 6.5.7 image, the machine code of GCodeQueue::_enqueue, GCodeQueue::clear() and ok_to_send() shows that index_r is the first byte after command_buffer, so the overflow overwrites index_r. We did not check which variable follows index_r in that image. In emulation of that image, a 110-character test command in the last slot changes index_r from 7 to 65, the value of its first overrun byte. The next dispatch then reads the slot at index 65, which starts 5472 bytes past the end of the 768-byte ring, and passes the bytes to the parser:

cpp
// lib/Marlin/Marlin/src/gcode/gcode.cpp:789,799
char * const current_command = queue.command_buffer[queue.index_r];
...
parser.parse(current_command);

With index_r out of range, that read runs past the ring, so the bounded write becomes an out-of-bounds parser read (CWE-125).

The score rates the worst credible impact of this out-of-bounds write. Up to 15 bytes of the command tail land in the statics next to the ring. The vector scores it as follows:

  • AV:N for the Connect path.
  • AC:H for the trusted-backend precondition (a party that already controls a self-hosted backend sends the line at will).
  • I:H/A:H for attacker-chosen bytes in the queue state, which the next dispatch reads.
  • C:L for the out-of-bounds parser read that follows.

The demonstrated result is narrower. We demonstrated the write class on the host. In emulation of the shipped MK4S 6.5.7 image, we also showed that the overflow overwrites index_r and that the parser then reads past the ring. We did not test any of this on hardware, and we did not demonstrate a control-flow effect or RCE. PFB-013 reaches a function-pointer dispatch on hardware. This finding does not reach a function-pointer dispatch.

Proof

A small host program, compiled with AddressSanitizer, declares the same static command_buffer[8][96] and issues the same strcpy of a 110-character command into slot 7. AddressSanitizer reports the out-of-bounds write:

code
global-buffer-overflow
WRITE of size 111
0 bytes after global variable 'command_buffer' ... of size 768   (768 = 8 * 96)

The host places command_buffer at the end of its own .bss, so the host cannot show the on-device neighbour. The neighbour on the shipped MK4S 6.5.7 image comes from its machine code and from emulation (see Impact).

Source release

Prusa published the 7.0.0 sources under the v7.0.0 release tag on GitHub.