PFB-009Vendor: Prusa ResearchProduct: Buddy firmware

The transfer-recovery backup file yields an attacker-chosen out-of-bounds read whose bytes leave the printer

•
7 min read
pfb-009.advisory
Severity

Defect 1: the range check overflows

CVSS 5.2Medium

CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:L, 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: High (worst case)
  • Integrity: None
  • Availability: Low

primary CWE-190, the worse of two defects

Defect 2: the checked length is discarded

CVSS 2.4Low

CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N, 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: Low
  • Integrity: None
  • Availability: None

primary 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
Physical USB
Verification
Source review only

Source-verified, not run on hardware. We inferred the trigger from the boot-recovery call chain.

Analysis notes
Versions and source tree

Prusa Buddy firmware, MK4S. The cited lines are from 6.6.0 (version.txt 6.6.0, commit e96ce2b9). Our test printer runs 6.5.7, which we did not diff against 6.6.0.

After a power loss, the printer resumes an interrupted download from a backup file on the USB medium. That file holds each URL string as an offset and a length. Two defects in the recovery path turn that pair into an out-of-bounds pointer. The first is an integer overflow in the bounds check: it cannot reject a negative offset, because the sum overflows before the comparison sees it. The second keeps the length while the check runs, but drops it when the check returns. The caller therefore gets a bare C string with no length attached.

In both cases, get_data_ptr returns a pointer outside the recovery buffer. strlcpy reads from that pointer into the request hostname. That hostname leaves the printer in a DNS query and an HTTP Host header. Anyone who can place a crafted backup on the medium reads memory near the recovery buffer and makes the printer transmit it. This report describes the overflow first, because it is the worse of the two.

Defect 1: the range check overflows

The two fields arrive from disk unmodified, and the offset is a signed long:

cpp
// src/transfers/transfer_recovery.cpp:23-25
struct SerializedString {
    long offset = 0;
    size_t size = 0;

deserialize passes that pair directly to get_data_ptr. That function takes both values as size_t and runs the only bounds check on them:

cpp
// src/transfers/transfer_recovery.cpp:263-279
const void *Transfer::RestoredTransfer::get_data_ptr(size_t offset, size_t size) {
    assert(data_buffer != nullptr);
    if (data_buffer == nullptr) {
        log_error(transfers, "No data buffer available");
        return nullptr;
    }

    const auto data_start = sizeof(SerializedTransfer);
    const auto data_end = data_start + data_buffer_size;
    assert(offset >= data_start && offset + size <= data_end);
    if (offset < data_start || offset + size > data_end) {
        log_error(transfers, "Data offset/size out of range");
        return nullptr;
    }

    return data_buffer.get() + offset - data_start;
}

On the 32-bit ARM target, long and size_t are both 32 bits, so a negative file offset arrives here as a large unsigned value. Give the offset the unsigned value 0xFFFFFF00, which is minus 256 reinterpreted. Give the size the value 256.

The lower-bound test does not fire, because that large value is not below the small header size. The upper-bound test adds the offset to the size. The sum wraps past the 32-bit maximum back to zero. Zero is not above the end of the data, so the upper-bound test does not fire either. Both clauses pass.

The function then adds the offset and subtracts the header size to form its result. The resulting pointer lands 256 bytes plus the header size before the start of the buffer. The attacker sets this distance with the offset. The assert on the line above holds the same expression. In any case, the compiler removes it from release builds.

Reachability. The path runs during boot recovery with no user action. This is why the vector needs no login (PR:N) and no victim step (UI:N). Delivery is a prepared USB medium (AV:P). The call chain runs in this order:

  1. src/connect/sleep.cpp:129 runs search_transfer_for_recovery. That function reads the plain-text index /usb/.prusa_transfers.txt (transfer.hpp:315). It accepts any listed directory that holds the partial file p and the backup file d (transfer_file_check.hpp:12-13).
  2. src/connect/planner.cpp:1153 then calls Transfer::recover, which reaches restart_download (transfer.cpp:161).
  3. restore checks the header (transfer.cpp:169).
  4. get_download_request deserializes the strings (transfer.cpp:176).
  5. download.emplace builds the Download that runs strlcpy and starts DNS (transfer.cpp:220).

Three integrity gates stand in the way. The attacker who writes the file satisfies all of them:

  • a CRC32 over the partial-file state (transfer_recovery.cpp:169)
  • an exact match against the running firmware version string (transfer_recovery.cpp:184-197)
  • a CRC32 over the header-and-data range (transfer_recovery.cpp:313-329)

The firmware version is not secret, and the CRCs use no key.

One added precondition follows from where the leak fires. The over-read happens at transfer.cpp:220, after restart_download re-opens p and checks its size (transfer.cpp:193-196). The attacker therefore ships a matching p beside the crafted d.

Impact. The result is an out-of-bounds read at an attacker-chosen offset. The bytes leave the device over the network by the exit path shown below. This defect gives control of the read location. The attacker points before the buffer and moves the offset from one reboot to the next. The heap here holds Connect and encryption material, which is why the confidentiality rating is high. It only reads memory, so it corrupts nothing (I:N). A:L rates a possible fault when the chosen offset points at unmapped memory. We did not observe such a fault.

We did not run it on hardware. The useful range of the offset depends on the memory map, which we did not examine. For that reason, we did not establish what a given offset leaks. We did establish the primitive and the exit path.

Defect 2: the checked length is discarded

The check reads the length while it runs, but deserialize returns only the pointer:

cpp
// src/transfers/transfer_recovery.cpp:35-38
    template <typename T>
    const char *deserialize(T get_data_ptr) const{
        return static_cast<const char *>(get_data_ptr(offset, size));
    }

The size reaches get_data_ptr and bounds the check. However, the return value is a bare pointer, and every consumer reads it as a C string. As a result, the size bounds nothing after the check passes.

That opens a second route that needs no integer overflow. Point the offset at the last byte of the data area. Set the size to one. The check's sum then reaches the end of the data exactly, and the check passes. The firmware reads the whole data area from the file in a single call at transfer_recovery.cpp:307:

cpp
// src/transfers/transfer_recovery.cpp:307
        if (fseek(file, data_offset, SEEK_SET) != 0 || fread(data_buffer.get(), data_length, 1, file) != 1) {

An attacker who fills every position with a non-zero byte leaves no terminator in the block. The version string is ASCII, so its bytes are already non-zero. The C-string read at the sink then finds no NUL at the last byte. It runs past the end of the heap block into whatever follows it.

Reachability. The boot-recovery chain, the three integrity gates, and the matching p beside the crafted d are the same as for Defect 1. This route adds one precondition of its own. The attacker writes a non-zero byte to every position of the data area. As a result, no terminator sits between the chosen offset and the end of the block.

Impact. The result is a read at a fixed location just past the buffer. It leaks whatever sits next to the block. The attacker cannot move the read location, which is why the rating is low. The bytes leave by the same exit path as Defect 1, shown below. Like Defect 1, it only reads memory and does not write it.

Where the bytes leave the printer

Both defects return the out-of-bounds pointer as the host of an encrypted request, and restart_download builds the Download from it (transfer.cpp:220). Two copies carry the bytes off the printer:

cpp
// src/transfers/download.cpp:146-147
        strlcpy(request.hostname, hostname, sizeof request.hostname);
        strlcpy(request.path, path, sizeof request.path);

strlcpy walks the whole source to compute its return value. The read is therefore unbounded, even though the 36-byte destination has a fixed size. Up to 35 over-read bytes land in request.hostname. request.path gets the same treatment from url_path_or_hash and leaks up to 40 more bytes in the request line. The firmware then resolves request.hostname by name:

cpp
// src/transfers/download.cpp:393
        err_t dns_result = dns_gethostbyname(has_proxy ? proxy_host.data() : request.hostname, &request.ip, dns_found_wrap, this);

and writes it into the Host header of the outgoing request:

cpp
// src/transfers/download.cpp:203-205
            len = snprintf(req, sizeof req, "GET http://%s:%" PRIu16 "%s HTTP/1.1\r\nHost: %s\r\nContent-Encryption-Mode: AES-CTR\r\nRange: %s\r\n\r\n", request.hostname, request.port, request.path, request.hostname, range);
        } else {
            len = snprintf(req, sizeof req, "GET %s HTTP/1.1\r\nHost: %s\r\nContent-Encryption-Mode: AES-CTR\r\nRange: %s\r\n\r\n", request.path, request.hostname, range);

The DNS query fires first and carries the bytes in clear text to the configured resolver. The Host header carries them to whatever server the request reaches.

Proof

Source-verified only. There is no hardware run and no crash dump. The evidence is the cited source. Prusa also received a crafted-file layout with the header fields that trigger each defect. This advisory leaves that layout out.

Source release

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