PFB-013Vendor: Prusa ResearchProduct: Buddy firmware

The E2EE identity block sizes a heap buffer from one attacker field, then overruns it

•
5 min read
pfb-013.advisory
Severity
CVSS 6.1Medium

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

CVSS metrics and CWE
  • Attack Vector: Physical
  • Attack Complexity: Low (worst case)
  • Privileges Required: None (worst case)
  • User Interaction: None (worst case)
  • Scope: Unchanged
  • Confidentiality: None
  • Integrity: High (worst case)
  • Availability: High (worst case)
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
USB stick
Verification
Reproduced on hardware

hardware-confirmed (crash dumps from our own MK4S) and source-verified.

Analysis notes
Versions and source tree

Prusa Buddy firmware, MK4S, 6.5.7 (build 6.5.7+12836). The source is unchanged through 6.6.0 (commit e96ce2b9).

Summary

e2ee::read_and_verify_identity_block sizes a heap buffer and the read that fills it from the same 32-bit field of a .bgcode file. The two sizes come from two subtractions that differ by 11. For an uncompressed_size of 245 to 255, the buffer is 0 to 10 bytes. In the same range, the read length underflows in size_t, so the read writes the file's own bytes past the end of the buffer.

The path runs from a USB stick when the firmware scans a file's print preview. It runs with no signature check and no CRC.

On hardware, the overflow reaches a function-pointer dispatch with attacker-controlled operands. A non-deterministic heap blocks reliable code execution. The overflow still gives a reliable denial of service.

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

The function does these steps:

  1. It takes the raw 32-bit size from the file.
  2. It sizes the buffer with one subtraction.
  3. It computes the read length with a second subtraction.
cpp
// src/common/e2ee/e2ee.cpp:174-185
size_t block_size = block_header.uncompressed_size;   // raw uint32 straight from the file
size_t signed_bytes_size = block_header.get_size() + block_parameters_size(EBlockType::IdentityBlock) + block_size - SIGN_SIZE;
unique_ptr<uint8_t[]> bytes(new uint8_t[signed_bytes_size]);
...
read_from_file(&bytes.get()[pos], block_size - SIGN_SIZE, file);

get_size() is 8 and block_parameters_size(IdentityBlock) is 3, so signed_bytes_size is block_size - 245, while the read length at :185 is block_size - 256. Both subtractions are unsigned, and nothing bounds block_size from below. read_from_file passes the length straight to fread with no clamp:

cpp
// src/common/e2ee/e2ee.cpp:59-62
bool read_from_file(void *data, size_t data_size, FILE *file){
    const size_t rsize = fread(data, 1, data_size, file);
    return !ferror(file) && rsize == data_size;
}

The apparent guard never fires. get_block_header_bytes returns 0 on a short buffer instead of an error, so pos stays 0 and control reaches the read:

cpp
// src/common/e2ee/e2ee.cpp:40-44
size_t get_block_header_bytes(BlockHeader header, uint8_t *buffer, size_t buffer_size){
    if (buffer_size < header.get_size()) {
        // TODO
        return 0;
    }

The source tree fixes these three constants:

  • 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).

The 245 is therefore 256 - 8 - 3. The behaviour by uncompressed_size follows:

code
< 245     allocation wraps to ~4 GiB, new[] fails -> abort()   (denial of service)
  245..255  buffer 0..10 bytes, read length underflows           (heap overflow)
  >= 256    read is 11 bytes below the buffer                     (no overflow)

fread clips the underflowed length to the file's remaining bytes. The actual write is therefore min(block_size - 256, bytes left in file). The attacker controls both the length and the content.

Reaching it

The attacker position is a .bgcode file on a USB stick. The attack needs no network access and no credential. No print has to start.

The reader calls the function with verify_signature set to full_check:

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); ...

The preview scan passes false for that check:

cpp
// src/common/gcode/gcode_info.cpp:72
is_printable_ = reader.valid_for_print(false);

As a result, the overflow at e2ee.cpp:185 runs before any signature check or CRC check. The scan job runs this check when it starts and again every 500 ms (gcode_info_scan.cpp:30,51). The print-preview screen starts the scan job. Opening a file's preview is therefore enough to reach the bug.

The minimal file has three parts, in this order:

  1. A bgcode FileHeader.
  2. One IdentityBlock (type = 6, compression = 0, uncompressed_size = 250).
  3. More bytes than the 5-byte chunk holds.

Impact

On hardware, from a USB stick and with no signature check, the overflow reaches a function-pointer dispatch. The object that the overflow corrupts is a libsysbase file handle. The next _read on that handle runs devoptab_list[handle->device]->read_r(...). At that call, device and fileStruct both hold attacker bytes. In the run 1 dump, the fault is at that dispatch:

code
080b8e52  ldr.w r3, [r3, r2, lsl #2]    ; r3 = devoptab_list[r2]
                                          ;   r3 = 0x20000aac (devoptab_list), r2 = 0x41414141 (payload byte)

r2 is a payload byte used as the device index, so it indexes far outside the device table and faults on unmapped memory.

Reliable code execution is not achieved. Across five runs, the overflow chunk lands at four distinct addresses spread over about 5.4 KiB. The structure that the overflow corrupts first also varies. Three runs reached the dispatch. Two runs hit a FreeRTOS assert (udf #0 at 0x0803887c) before any _read.

The attacker does not control the heap layout, so a fixed-offset payload is not viable. The overflow also does not reach the dispatch on every open.

The first confirmed impact is a reliable denial of service: every open crashes or hangs the printer. The second confirmed impact, in three of the five runs, is that the overflow reached a control-flow-influencing dispatch with attacker-controlled device and fileStruct.

Proof on hardware

We put the minimal file on a USB stick and opened its preview on our MK4S, in five separate runs. The crash dump from run 1 records the dispatch fault:

code
PC = 0x080b8e52          the dispatch
R2 = 0x41414141          the attacker device index
R3 = 0x20000aac          devoptab_list
R7 = R10 = 0xfffffffa    the underflowed read length, 250 - 256

In the same dump, devoptab_list holds real device pointers ([3] = 0x081d21b8, [4] = 0x081d6920, [5] = 0x081d1e88). These pointers confirm the table base at 0x20000aac.

Source release

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