PFB-010Vendor: Prusa ResearchProduct: Buddy firmware
Five smaller source-verified defects in transfers, PrusaLink, libbgcode, and Connect
- Severity
Defect 1:
PUT /api/v1/files/usbreads an uninitialised path bufferCVSS 4.3MediumCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:N/ AC:L/ PR:L/ UI:N/ S:U/ C:N/ I:L/ A:N 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.7LowCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:N/ AC:H/ PR:N/ UI:N/ S:U/ C:L/ I:N/ A:N 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.9LowCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:L/ AC:H/ PR:N/ UI:N/ S:U/ C:L/ I:N/ A:N 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
Plannermember is read before anything initialises itCVSS 2.9LowCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:L/ AC:H/ PR:N/ UI:N/ S:U/ C:N/ I:L/ A:N 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_blockreports success when its seek did not happenCVSS 2.0LowCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:P/ AC:H/ PR:N/ UI:N/ S:U/ C:N/ I:N/ A:L 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 isversion.txt6.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.- Disclosed in
- Never gonna print you up!
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:
// 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:
// 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:
// 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):
// 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():
// 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:
// 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 gatesset_can_start_download). The firmware reports that value to the server.:850and: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:
// 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:
// 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) andswitch_to_next_block(:403-424). The libbgcode callers are atcore.cpp:486-508and:541-571. Each of them reads the next block header afterskip_blockreturns. 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
fseeksemantics. 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 == 2path. 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.