PFB-011Vendor: Prusa ResearchProduct: Buddy firmware
An off-by-one out-of-bounds read in the MMU progress converter, and two EEPROM-driven memory defects
- Severity
Defect 1: ProgressCodeToText reads one entry past its table
CVSS 3.5LowCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:P/ AC:L/ PR:N/ UI:N/ S:U/ C:L/ I:N/ A:L 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: Low
CWE-193 leading to CWE-125
Defect 2: migration copies a stored length into a fixed destination with no bound
CVSS 6.4MediumCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:P/ AC:H/ PR:N/ UI:N/ S:U/ C:H/ I:H/ A:H CVSS metrics and CWE
- Attack Vector: Physical
- Attack Complexity: High
- Privileges Required: None (worst case)
- User Interaction: None (worst case)
- Scope: Unchanged
- Confidentiality: High (worst case)
- Integrity: High (worst case)
- Availability: High (worst case)
Defect 3: bank free-space arithmetic underflows and writes leave the bank
CVSS 3.1LowCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:P/ AC:H/ PR:N/ UI:N/ S:U/ C:N/ I:L/ 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: Low
- 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 (defects 1 and 2; defect 3 partly)
- Attack surface
- Faulty or malicious MMU, crafted EEPROM journal
- Verification
- Source review only
source-verified. No trigger was reproduced on hardware. Defect 1's original "genuine hardware triggers it" premise did not survive review (see its trigger note).
- 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. These are logic defects in stable code and are very likely present there too, but they were not line-verified against 6.5.7 source.- Disclosed in
- Never gonna print you up!
This report covers three defects. The first, an out-of-bounds read in the MMU progress path, is in the shipped Buddy image. The other two are memory-safety defects in the EEPROM journal. They are reachable only from a crafted persistent store.
Defect 1: ProgressCodeToText reads one entry past its table
The bound is <= where it should be <:
// src/mmu2/mmu2_progress_converter.cpp:68-73
const char *ProgressCodeToText(ProgressCode pc){
return ((uint16_t)pc <= (sizeof(progressTexts) / sizeof(progressTexts[0])))
? progressTexts[(uint16_t)pc]
: progressTexts[0];
}progressTexts holds 29 entries (:36-66), so valid indices are 0 to 28.
sizeof/sizeof is 29, so <= 29 accepts index 29 and reads one const char * past the array.
Every value of 30 or more takes the else branch and returns progressTexts[0].
So 29 is the only index that the bound accepts and that is out of bounds.
src/mmu2/mmu2_progress.cpp:71 already uses the correct < bound.
Reachability. LogEchoEvent_P (src/mmu2/mmu2_log.cpp:11-14) walks the returned pointer as a string.
The MMU state machine in Buddy reaches LogEchoEvent_P at mmu2_mk4.cpp:1295 -> :1506 -> :1471.
On that path, the progress code comes from the MMU reply, which the code at protocol_logic.cpp:511 and :638 parses.
The value 29 is ProgressCode::HWTestBegin (progress_codes.h:47, // P29), and it is set during the MMU self-test.
The original report expected the MMU to send P29 during that self-test.
In this firmware, the MMU does not send it.
The self-test runs at main.cpp:112-116, before usart1.rx_enable() at :136.
So nothing goes on the wire while state == HWTestBegin.
The firmware never installs HWSanity as currentCommand.
application.cpp:93 is the only place where a progress code goes on the wire, and it always sends currentCommand->State().
So the read is reachable only if a device on the MMU UART emits an anomalous P29 progress reply.
Such a reply means a faulty or malicious MMU, not ordinary operation.
Impact. The function reads the one fixed word after the table.
LogEchoEvent_P treats that word as a pointer and walks it until a zero byte.
Depending on what the linker placed there, the result is a garbled log line or a fault when the pointer does not resolve.
The address is not attacker-controlled, so there is no useful information disclosure beyond that one fixed location.
Defect 2: migration copies a stored length into a fixed destination with no bound
The migration path copies a stored length into a destination whose real size it does not know:
// src/persistent_stores/store_instances/config_store/migrations.cpp:13-16
void read_old_item_value_impl(journal::Backend &backend, uint16_t item_hash, void *old_value){
auto callback = [&](journal::Backend::ItemHeader header, std::array<std::byte, journal::Backend::MAX_ITEM_SIZE> &buffer) -> void {
if (header.id == item_hash) {
memcpy(old_value, buffer.data(), header.len);header.len is a 9-bit field read from EEPROM (journal/backend.hpp:72), so its value is 0 to 511.
old_value is a void *.
Nine sibling call sites have the same shape, with destinations as small as a bool (:95, :265) and a uint8_t (:230, :232).
The worst case is a 510-byte overwrite of a one-byte stack local with chosen bytes.
The build sets no -fstack-protector.
Reachability. This defect needs all of these conditions:
- A crafted journal record has an id that matches a migrated item.
- The stored length of that record exceeds the destination.
- The store version is old enough that a migration runs at boot.
These conditions mean physical I2C access to the ST25DV64K or an already-corrupted store.
The normal write path cannot produce such a record, because it always stores sizeof(value_type).
The normal load path rejects a size mismatch in the store-item init():
// src/persistent_stores/journal/store_item.hpp:65-67
if (raw_data.size() != sizeof(value_type)) {
std::terminate();
}Only the migration path skips that check.
NFC cannot reach the journal: st25dv64k_init marks the journal area unavailable over RF (src/common/st25dv64k.cpp:389-392).
Impact. An attacker can choose the bytes of a stack overwrite at boot. There is no stack canary, so control-flow corruption is possible. It requires physical write access to the EEPROM.
Defect 3: bank free-space arithmetic underflows and writes leave the bank
The code computes the free-space budget from the wrong header size:
// src/persistent_stores/journal/backend.cpp:331
uint16_t free_space = bank_size - BANK_HEADER_SIZE; // 6, but the region starts at +10The transaction region starts at bank_start + BANK_HEADER_SIZE_WITH_CRC (10, since CRC_SIZE is 4, used at :246).
So the walk over-counts by four, and end_of_last_transaction can end as much as four bytes past the bank.
The code adopts end_of_last_transaction as current_address at :247 with no range check.
get_free_space_in_bank then underflows:
// src/persistent_stores/journal/backend.cpp:532-535
uint16_t Backend::get_free_space_in_bank(Address address_in_bank) const{
uint16_t used_space = current_address - get_bank_start_address(address_in_bank);
return bank_size - used_space - 1;
}Once used_space >= bank_size, this value wraps to about 65531, so fits_in_current_bank returns true for any size.
Reachability. As for defect 2, this defect needs a crafted or corrupted transaction layout in EEPROM.
Impact. write_item (:544) then places a fixed four-byte CRC at an offset derived from the data length.
The writes leave the bank.
EEPROMStorage::write_bytes (eeprom_storage.cpp:11) is a pass-through to the I2C driver with no RAM mirror.
So an out-of-range address goes onto the bus, into the second bank's header or past the journal.
The result is persistent EEPROM corruption, not code execution.
Status in 7.0.0. This release corrects the budget and keeps the free-space value from wrapping. In emulation of the shipped MK4S and INDX 7.0.0 images, a layout that overruns a bank no longer causes writes outside it. The boot code still adopts the end of the last transaction with no range check. So in emulation, a crafted layout that ends exactly at the end of the first bank still causes a write into the second bank's header.
Proof
All three defects are source-verified. This advisory leaves out the exact triggering value and state for each defect. Prusa received them with the report. No crash was captured on hardware. For defect 1, none is expected on genuine hardware, for the reason given above.
Corrections and unverified claims
- UNVERIFIED: "genuine MMU hardware sends P29 and triggers the read, no attacker needed." A trace through the source shows that the MMU does not send
HWTestBegin. The self-test runs beforerx_enable.HWSanityis nevercurrentCommand.application.cpp:93only ever reportscurrentCommand->State(). The read is real, but it is reachable only through an anomalous MMU-side message. - Version: the source tree is 6.6.0 (e96ce2b9), not the 6.5.7 named for the target image. The citations are from 6.6.0, and they were not line-verified against 6.5.7 source.
- The source audit also flagged five lower-severity items (
BootloaderProtocol::write_commandand the tworead_*_cmdnullptr-length reads, a liblightmodbus FIFO count,otp.cpp:125, andmmu2_bootloader.cpp:211's% 1). Prusa received them separately. This advisory does not score them.
Source release
Prusa published the 7.0.0 sources under the v7.0.0 release tag on GitHub.