PFB-001Vendor: Prusa ResearchProduct: Buddy firmware

MeatPack G-code decoder writes two bytes past its output buffer

•
5 min read
pfb-001.advisory
Severity
CVSS 4.3Medium

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

CVSS metrics and CWE
  • Attack Vector: Network (worst case)
  • Attack Complexity: Low (worst case)
  • Privileges Required: Low
  • User Interaction: None (worst case)
  • Scope: Unchanged
  • Confidentiality: None
  • Integrity: Low
  • Availability: None
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
USB stick, PrusaLink upload, Connect download
Verification
Reproduced on host

AddressSanitizer-confirmed against unmodified decoder source on the host, trigger executed. Reachability source-verified. Not tested on hardware.

Analysis notes
Versions and source tree

Prusa Buddy firmware, MK4S, 6.5.7 (build 6.5.7+12836). The cited lines refer to the public 6.6.0 tree (commit e96ce2b9). The defect is very likely present in 6.5.7, but it was not line-checked there.

Summary

MeatPack::handle_output_char appends to the two-byte char_out_buf and increments char_out_count with no bound (meatpack.cpp:130). One handle_rx_char call can run two decode steps, and each step can emit up to two characters. A crafted six-byte stream therefore drives the count to four. The decoder then writes two bytes past the end of the MeatPack object. The decoder runs on any MeatPack-encoded .bgcode block. A USB stick, a PrusaLink upload, or a Prusa Connect download can deliver such a block.

The defect

The output buffer holds two bytes, and no code limits the index that fills it.

cpp
// src/common/gcode/meatpack.h:93-94
        char_out_count; // Stores number of characters to be read out.
    uint8_t char_out_buf[2]; // Output buffer for caching up to 2 characters

Every call to handle_output_char stores one byte and advances char_out_count. No code compares the count with the two-byte size.

cpp
// src/common/gcode/meatpack.cpp:129-131
void MeatPack::handle_output_char(const uint8_t c){
    char_out_buf[char_out_count++] = c;
}

The design intends the bound to hold by construction. The consumer feeds one byte only after it drains the buffer. The design also assumed that one decode step emits at most two characters. The lone-0xFF path, the only one that runs two decode steps per call, breaks that assumption. With a single command byte pending, handle_rx_char runs handle_rx_char_inner twice in one call.

cpp
// src/common/gcode/meatpack.cpp:184-189
    if (cmd_count) { // Only a single 0xFF was received
        handle_rx_char_inner(kCommandByte); // A single 0xFF is passed on literally so it can be interpreted as kFirstNotPacked|kSecondNotPacked
        cmd_count = 0;
    }

    handle_rx_char_inner(c); // Other characters are passed on for MeatPack decoding

Both the literal-passthrough branch and the unpack branch can emit two characters, so four writes reach the two-byte array.

Reaching it

The trigger is six bytes in the decoded stream, and it starts from a reset decoder state. The attacker controls the whole stream, so a leading ResetAll guarantees that start.

code
FF FF FB 0F FF 00

The decoder processes the six bytes in this order:

  1. The bytes FF FF FB are the EnablePacking command, so active becomes true.
  2. 0F stages one literal. full_char_count becomes 1, and second_char holds '0'.
  3. A lone FF sets cmd_count to 1.
  4. On 00, handle_rx_char runs both decode steps. The first step emits 0xFF and second_char, which fills the buffer to two. The second step unpacks 0x00 into two characters and writes char_out_buf[2] and [3].

handle_rx_char has one caller, PrusaPackGcodeReader::stream_getc_decode_meatpacked, which feeds it each byte taken from the decompressed file stream.

cpp
// src/common/gcode/gcode_reader_binary.cpp:657
        stream.meatpack.handle_rx_char(mp_char);

The reader selects that decoder for any block whose header declares MeatPack or MeatPackComments encoding.

cpp
// src/common/gcode/gcode_reader_binary.cpp:944-945
    if (encoding == EGCodeEncodingType::MeatPack || encoding == EGCodeEncodingType::MeatPackComments) {
        set_ptr_stream_getc(&PrusaPackGcodeReader::stream_getc_decode_meatpacked);

The bytes come directly from the file, so three delivery paths reach the printer:

  • a file on a USB stick (no credential)
  • a PrusaLink upload (authenticated)
  • a Prusa Connect download

PFB-002 makes the encrypted Connect download reachable without the key. That download (START_ENCRYPTED_DOWNLOAD) arrives over plain HTTP under malleable AES-CTR. An on-path attacker can therefore flip bits to put the six bytes in place.

The separate MeatPack code in lib/libbgcode does not have this defect.

Impact

The write is two bytes wide at a fixed location on every trigger, not a linear overflow. The consumer enters only when char_out_count is zero and drains fully afterward, so each event writes exactly char_out_buf[2] and [3]. A libFuzzer width search over the state machine, at 2,000,000 runs, found no path that emits more than four characters per call. Four is therefore the ceiling. The fifteen-entry lookup alphabet constrains the values. The one exception is the literal path, which can write one nearly arbitrary byte.

The two bytes fall just past the decoder, inside the reader's stream_t.

cpp
// src/common/gcode/gcode_reader_binary.hpp:68-90
    struct stream_t {
        ...
        MeatPack meatpack;
#if HAS_E2EE_SUPPORT()
        bool last_block = false;
        bgcode::core::BlockHeader current_encrypted_block_header;
        std::unique_ptr<e2ee::Decryptor> decryptor;
#endif
        ...
        std::unique_ptr<heatshrink_decoder, HSDecoderDeleter> hs_decoder;

On an E2EE build the first byte overwrites the adjacent bool last_block, and the second lands in alignment padding before a BlockHeader. Without E2EE both fall in padding before a 4-aligned unique_ptr. This layout is inferred from the declarations, and it needs offsetof on a target build to confirm it.

So this is a reachable out-of-bounds write and undefined behaviour on every build. It has no demonstrated path to control flow or denial of service. The score reflects that demonstrated ceiling, not a proof that the write is harmless. PFB-007 does reach a controlled program counter on hardware. This primitive alone is not in that class. It writes two fixed bytes of constrained value into padding and one bool.

Proof

We compiled the unmodified meatpack.cpp for the host with AddressSanitizer, put a MeatPack object on the stack, and fed it the six-byte trigger from a reset state, one byte at a time through handle_rx_char. AddressSanitizer reports a one-byte write at handle_output_char, reached through handle_rx_char_inner and handle_rx_char. The write overflows the nine-byte object at offset 41.

code
sizeof(MeatPack) = 9
==...==ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 1 ...
    #0 MeatPack::handle_output_char(unsigned char) meatpack.cpp:130
    #1 MeatPack::handle_rx_char_inner(unsigned char) meatpack.cpp:103
    #2 MeatPack::handle_rx_char(unsigned char) meatpack.cpp:189
  [32, 41) 'mp' <== Memory access at offset 41 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow meatpack.cpp:130

Source release

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