PFB-010Vendor: Prusa ResearchProduct: Buddy firmware

Five smaller source-verified defects in transfers, PrusaLink, libbgcode, and Connect

•
8 min read
pfb-010.advisory
Severity

Defect 1: PUT /api/v1/files/usb reads an uninitialised path buffer

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

Defect 2: off-by-one reads one element past the transfer history

CVSS 3.7Low

CVSS:3.1/AV:N/AC:H/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: Network (worst case)
  • Attack Complexity: High
  • Privileges Required: None (worst case)
  • User Interaction: None (worst case)
  • Scope: Unchanged
  • Confidentiality: Low
  • Integrity: None
  • Availability: None

CWE-193 leading to CWE-125

Defect 3: dereference of a disengaged optional on the ordinary download path

CVSS 2.9Low

CVSS:3.1/AV:L/AC:H/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: Local
  • Attack Complexity: High
  • Privileges Required: None (worst case)
  • User Interaction: None (worst case)
  • Scope: Unchanged
  • Confidentiality: Low
  • Integrity: None
  • Availability: None

Defect 4: a Planner member is read before anything initialises it

CVSS 2.9Low

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

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

Defect 5: skip_block reports success when its seek did not happen

CVSS 2.0Low

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

CVSS metrics and CWE
  • Attack Vector: Physical
  • Attack Complexity: High
  • Privileges Required: None (worst case)
  • User Interaction: None (worst case)
  • Scope: Unchanged
  • Confidentiality: None
  • 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
Attack surface
Authenticated PrusaLink PUT, crafted USB file
Verification
Source review only

source-verified. No runtime trigger was reproduced and no hardware test was run.

Analysis notes
Versions and source tree

Prusa Buddy firmware, MK4S. Line citations are from the source tree in /firmware, which is version.txt 6.6.0 (commit e96ce2b9). The target image is labelled 6.5.7 (build 6.5.7+12836). These are logic defects in stable code, and they are very likely present in that build too. However, they were not line-verified against 6.5.7 source.

This report describes five smaller defects. Each one was verified against the tree. None of them is a memory-corruption primitive. Each one is small and self-contained, so one report covers all five. Each section marks its trigger as confirmed or inferred. The report starts with the defect that is reachable over the network.

Defect 1: PUT /api/v1/files/usb reads an uninitialised path buffer

The check at prusa_api_helpers.cpp:81 admits a four-character /usb with no trailing slash, while every consumer strips a five-character prefix:

cpp
// lib/WUI/link_content/prusa_api_helpers.cpp:81-84
        if (strncmp(fname_real, "/usb/", 5) != 0 && strcmp(fname_real, "/usb") != 0) {
            out.next = StatusPage(Status::Forbidden, parser);
            return false;
        }

USB_MOUNT_POINT_LENGTH is strlen("/usb/"), which is 5 (files.hpp:12-13). gcode_upload.cpp:447 adds it to the buffer, so the resulting pointer is one byte past the /usb\0 terminator:

cpp
// lib/WUI/nhttp/gcode_upload.cpp:446-447
    // remove the "/usb/" prefix
    const char *filename = upload.filepath.data() + USB_MOUNT_POINT_LENGTH;

PutParams has no default member initialisers (gcode_upload.h:36-40). As a result, prusa_link_api_v1.cpp:195 leaves the 104-byte filepath (FILE_PATH_BUFFER_LEN) indeterminate. The strlcpy at :202 writes only indices 0 to 4. check_filename then runs strlen from index 5 into indeterminate bytes.

Reachability. Confirmed. parser.check_auth(out) runs first at prusa_link_api_v1.cpp:111, so the defect is post-auth. A single authenticated PUT /api/v1/files/usb reaches it. The same expression runs again in PutTransfer::done at gcode_upload.cpp:289.

Impact. The upload filename becomes whatever the uninitialised buffer holds. As a result, the file gets an indeterminate name, or the firmware rejects the request. In the worst case, the read goes past the 104-byte array, but only if the 99 uninitialised bytes contain no zero. The firmware does not return the bytes to the caller, so there is no disclosure.

Defect 2: off-by-one reads one element past the transfer history

monitor.cpp:141 guards the history index with a strict greater-than, and then :149 returns the indexed element:

cpp
// src/transfers/monitor.cpp:141-149
    if (offset > history_len) {
        // The history is continuous segment back. We may be asked for outdated
        // ID (in which case it'll be slightly larger than the length of the
        // history). We may also be asked for completely bogus ID, in wich case
        // it may even underflow ‒ and will likely be out of bounds.
        return nullopt;
    }

    return history[offset];

Valid indices are 0 to history_len - 1. The strict comparison lets offset == history_len pass, so the read goes one element too far. history is a std::array<Outcome, HISTORY_MAX_LEN> with HISTORY_MAX_LEN equal to 2 (monitor.hpp:244,247):

cpp
// src/transfers/monitor.hpp:244-247
    static constexpr size_t HISTORY_MAX_LEN = 2;
    size_t history_len = 0;
    TransferId history_latest;
    std::array<Outcome, HISTORY_MAX_LEN> history;

history is the last instance member of Monitor. The singleton Monitor::instance (:297) is in .bss, so offset == 2 reads adjacent .bss.

history_len does reach 2. On each completion, Slot::done preserves the older entries up to one below the array size and then records one more (monitor.cpp:94,98).

Separately, when history_len == 0, an offset of 0 passes the guard. Then :149 returns a never-written element. That read is in bounds, and in static storage the element is zero.

Reachability. Inferred. planner.cpp:451 calls outcome() with the printer's own transfer ID. It uses the result to build a TransferFinished or Failed event to Connect, so the firmware transmits the value. To reach offset == 2, the planner must miss two consecutive completions between polls. This is plausible, but it was not demonstrated.

Impact. The defect causes one out-of-bounds read of an Outcome-sized word of adjacent .bss. The firmware maps that value to one of a few event types and reports it to the printer's own Connect account. The address is constant and not attacker-chosen.

Defect 3: dereference of a disengaged optional on the ordinary download path

transfer.cpp:69 evaluates the optional's start and end unconditionally inside a log_info. Only the next line checks has_value():

cpp
// src/transfers/transfer.cpp:67-71
    case State::DownloadingTail: {
        auto tail = file.get_valid_tail();
        log_info(transfers, "returning offset for tail: %i, %u, %u", tail.has_value(), tail->start, tail->end);
        return tail.has_value() ? tail->end : file.final_size() - TailSize;
    }

get_valid_tail() returns std::optional<ValidPart> (partial_file.hpp:298). operator-> performs no check, so an access through an empty optional reads its uninitialised storage. That storage is two size_t, which is eight bytes on the 32-bit target (ValidPart at partial_file.hpp:53-54).

Reachability. Confirmed. PlainGcodeDownloadOrder enters DownloadingTail exactly when the file has no valid tail (transfer.cpp:34-40). Every newly started plain-gcode transfer above MinimalFileSize (512 KiB, transfer.hpp:68) is in that state. At that point valid_tail is nullopt, so has_valid_tail short-circuits and returns false (partial_file.cpp:363-366). get_next_offset runs on the ordinary download path (transfer.cpp:203), with no attacker involved.

The build keeps log_info even without _DEBUG. LOG_LOWEST_SEVERITY is 2 (log.hpp:112-116), and :189 retains log_info whenever that value is 2 or lower.

Impact. log_info formats eight uninitialised bytes from the local stack into an info-level log record. With default logging, these bytes stay on the device. Only a remote log sink (syslog over UDP), which is not the default, makes this a marginal disclosure. The defect does not cause a crash, because the optional storage has a defined size.

Defect 4: a Planner member is read before anything initialises it

planner.hpp:216 declares the recovery-state member with no initialiser:

cpp
// src/connect/planner.hpp:214-216
    // Tracks recovery of a transfer after startup.
    // We should not start any other transfer until the recovery is Finished.
    TransferRecoveryState transfer_recovery;

Planner::Planner initialises only printer before it calls reset() (planner.cpp:323-325). reset() never touches this member (:335-353). The only assignment anywhere is in transfer_recovery_finished (:1143-1145), and it sets the member to Finished. Before recovery completes, the member holds whatever the storage held.

Connect keeps the Planner in a std::variant with Registrator (connect.hpp:55) and re-runs the Planner emplacement (connect.cpp:895). That emplacement placement-constructs over the prior bytes and does not zero them, so on reconnect the value is indeterminate. On the first static construction, the value is likely zero, which is WaitingForUSB.

Reachability. Confirmed. The code reads the member at four sites before anything sets it:

  • :398 (whether recovery still runs).
  • :424 (which gates set_can_start_download). The firmware reports that value to the server.
  • :850 and :872, during command handling.

Impact. Mild. The enum has two states (planner.hpp:115-118). The recovery pass is idempotent. The next telemetry round corrects set_can_start_download. The defect matters more if the enum gains a third state.

Defect 5: skip_block reports success when its seek did not happen

core.cpp:599 discards the fseek return value. Then :600 reports success unless the stream error indicator is set:

cpp
// lib/libbgcode/src/LibBGCode/core/core.cpp:597-601
BGCODE_CORE_EXPORT EResult skip_block(FILE& file, const FileHeader& file_header, const BlockHeader& block_header){
    fseek(&file, block_header.get_position() + (long)block_header.get_size() + (long)block_content_size(file_header, block_header), SEEK_SET);
    return ferror(&file) ? EResult::ReadError : EResult::Success;
}

A rejected seek does not set the stream error indicator. As a result, skip_block reports a failed seek as success, and the position does not move. The same file checks the fseek return correctly at :44, :528 and :558, so this is an inconsistency, not house style:

cpp
// lib/libbgcode/src/LibBGCode/core/core.cpp:44-45
    if (fseek(&file, block_header.get_position() + (long)block_header.get_size(), SEEK_SET) != 0)
        return EResult::ReadError;

A negative resulting offset makes the seek fail. A file-controlled uint32_t (block_payload_size, :603-609) dominates block_content_size (:621-623). A cast of a value at or above 0x80000000 to a 32-bit long produces a negative number.

Reachability. Inferred. The firmware calls skip_block at gcode_reader_binary.cpp:168 and :416. The preceding range_valid at :106 is a no-op for a complete file. For such a file the validity is nullopt, so range_valid returns true (gcode_reader_interface.cpp:102-112). As a result, nothing bounds a crafted USB file at that point.

The negative-offset premise assumes that long is 32 bits. It also assumes that newlib leaves the position unmoved with ferror clear. This case was not cross-compiled or run.

Impact. When the seek fails, the position stays where it was, and the next header read starts there. The reader then parses the bytes that follow as block headers. The defect corrupts nothing in memory.

Corrections and unverified claims

  • UNVERIFIED: defect 5's effect after a failed seek. The firmware callers are iterate_blocks (gcode_reader_binary.cpp:147-172) and switch_to_next_block (:403-424). The libbgcode callers are at core.cpp:486-508 and :541-571. Each of them reads the next block header after skip_block returns. When the seek fails, that read starts from the position before the seek, so the reader parses the bytes that follow as block headers. This advisory scores and describes defect 5 accordingly.
  • UNVERIFIED: defect 5's negative-offset trigger. The trigger is an inference from the target's type widths and from newlib fseek semantics. It was not cross-compiled or run. As a result, it is not established that the seek fails on hardware.
  • UNVERIFIED: defect 2's offset == 2 path. To reach it, the planner must miss two consecutive completions between polls. This is plausible, but it was not demonstrated.
  • None of the five was reproduced at runtime or on hardware. The observable consequence of each one depends on runtime memory contents or on log configuration.
  • Version: the citations are from 6.6.0 (e96ce2b9), not from the 6.5.7 named for the target image. They were not line-checked against 6.5.7 source.

Source release

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