PFB-004Vendor: Prusa ResearchProduct: Buddy firmware

E2EE identity block parses an unbounded 16-bit key length, over-reading the heap before any signature check

•
5 min read
pfb-004.advisory
Severity
CVSS 4.4Medium

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

CVSS metrics and CWE
  • Attack Vector: Local
  • Attack Complexity: Low (worst case)
  • Privileges Required: None (worst case)
  • User Interaction: Required
  • Scope: Unchanged
  • Confidentiality: Low
  • Integrity: None
  • Availability: Low
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 (E2EE removed)
Attack surface
Crafted .bgcode file, opened on the printer
Verification
Reproduced on host

AddressSanitizer-confirmed on the host, reproduced this pass from a single .bgcode file with no other state. Source-verified in 6.6.0. Not hardware-tested.

Analysis notes
Versions and source tree

Prusa Buddy firmware, MK4S. The cited lines come from the 6.6.0 tree (version.txt 6.6.0, commit e96ce2b9). Our test printer runs 6.5.7 (build 6.5.7+12836).

Summary

e2ee::read_and_verify_identity_block reads a 16-bit key_len from the file. It passes this value directly to mbedtls_pk_parse_public_key as the length of the key buffer. The function never bounds key_len against the heap block that received the file data. A crafted identity block makes mbedTLS parse a DER key that extends past the allocation. This heap out-of-bounds read is reachable from valid_for_print while the firmware scans a print preview. The parse happens before the code checks the block's signature, so no cryptographic gate stands in front of it.

Firmware 7.0.0 does not patch this defect. It removes E2EE support, and the affected code with it. A build that restored the 6.x E2EE code would restore the defect too.

The defect

At e2ee.cpp:174-178, the function reads the declared block size. It then allocates the heap block bytes with the size signed_bytes_size. It calculates this size from block header fields only. The size is the sum of these three values, less SIGN_SIZE:

  • the block header size
  • the parameter size of the identity block
  • the declared block size

Line 189 copies a uint16_t key_len from that block. The bytes of that block came from the file. Line 191 builds a view of key_len bytes over the block. The code never compares this view against the allocation:

cpp
// src/common/e2ee/e2ee.cpp:188-192
uint16_t key_len;
memcpy(&key_len, &bytes.get()[pos], sizeof(key_len));   // key_len read straight from the file
pos += sizeof(key_len);
string_view_u8 key(&bytes.get()[pos], key_len);         // length never bounded against the allocation
if (mbedtls_pk_parse_public_key(&info.identity_pk->pk, key.data(), key.length()) != 0) {

mbedTLS parses to key_len bytes past the start of the view. As a result, its own ASN.1 bounds checks run against the attacker's end, not the real end of the block. The signature check is at :195, after the parse, so it cannot gate the read. The code must parse the key first, because it checks the signature against that key.

cpp
// src/common/e2ee/e2ee.cpp:195-200
if (verify_signature) {
    uint8_t sign[SIGN_SIZE];
    if (!read_from_file(sign, SIGN_SIZE, file)) {
        return file_error;
    }
    auto res = rsa_sha256_sign_verify(info.identity_pk->pk, bytes.get(), signed_bytes_size, sign, SIGN_SIZE);

A grep for key_len in the file returns :188, :189, :190, :191, and :205. None of these lines is a bounds check. :205 uses the same key_len as an offset into the later name and hash reads. We verified these constants in the tree:

  • SIGN_SIZE = 256 (e2ee.hpp:26)
  • get_size() = 8 for an uncompressed block (core.cpp:236, 2+2+4)
  • block_parameters_size(IdentityBlock) = 3 (core.cpp:584, 2+1)

Reaching it

The attacker position is a .bgcode file that reaches the printer. PrusaPackGcodeReader::valid_for_print iterates the file's blocks and calls this function on the identity block:

cpp
// src/common/gcode/gcode_reader_binary.cpp:1033
if (const char *err = e2ee::read_and_verify_identity_block(file.get(), block_header,
        valid_context.full_check ? intro_hash : nullptr, identity_block_info, valid_context.full_check); ...

media_prefetch and the gcode_info scan run valid_for_print while the firmware prepares a file, so opening a print preview reaches it. verify_signature is the full_check argument of that call. The over-read fires whether the caller sets this argument or not. The reproduction passed true, and the read still ran before the code consulted the signature.

The file can arrive on a USB stick, by PrusaLink upload, or by Connect download. All three routes reach the same scan. The demonstrated delivery is a USB stick. The vector carries AV:L because the printer processes the file locally. It carries UI:R because a user action must open the file, for example in a print preview.

Impact

This is a heap out-of-bounds read that reaches mbedTLS RSA parsing. The attacker steers the extent of the read through key_len. The bytes that mbedTLS reads become a candidate RSA public key, which the following signature check rejects. The vector carries C:L for this heap over-read. This analysis does not trace whether any of these bytes leave the device (Connect telemetry, an error path).

The second consequence is a crash, which the vector carries as A:L. key_len is an unchecked uint16_t, so a large value points mbedTLS's parse window well past the allocation. On a Cortex-M without the host's guard pages, a read into unmapped SRAM faults. This batch is host-only. The demonstrated instance over-reads a 299-byte block by a few bytes. We argue the hardware fault for a large key_len from that primitive. We did not test it.

Proof

We compiled the shipped reader for the host with AddressSanitizer. A small replay program opens one .bgcode file and runs the same validation path as the printer (valid_for_print(true)), with no other state. The test file declares an identity block with uncompressed_size = 544 (so the block is 299 bytes) and key_len = 294. The block carries a DER key whose modulus INTEGER is 257 bytes. As a result, the 257-byte modulus read of mbedTLS crosses the end of the block:

code
ERROR: AddressSanitizer: heap-buffer-overflow on address 0x61200000076b
READ of size 257 at 0x61200000076b thread T0
    #1 mbedtls_mpi_read_binary
    #5 mbedtls_pk_parse_public_key
    #6 e2ee::read_and_verify_identity_block(...)
    #12 PrusaPackGcodeReader::valid_for_print(bool)
0x61200000076b is located 0 bytes after 299-byte region [0x612000000640,0x61200000076b)

The 299-byte region is this function's signed_bytes_size allocation, and the flagged address sits exactly at its end.

Source release

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