PFB-007Vendor: Prusa ResearchProduct: Buddy firmware

A crafted USB filename overflows a stack path buffer in get_SFN_path and takes the program counter

•
5 min read
pfb-007.advisory
Severity
CVSS 6.4Medium

CVSS:3.1/AV:P/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H, 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: High (worst case)
  • 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
Attack surface
Prepared USB stick, present at power-on
Verification
Reproduced on hardware

hardware-confirmed (on-device crash dump). Also AddressSanitizer-confirmed and source-verified.

Analysis notes
Versions and source tree

Prusa Buddy firmware, MK4S, signed 6.5.7 (build 6.5.7+12836). The same code is present in the 6.6.0 source tree (commit e96ce2b9). get_SFN_path has nine call sites, so the range is likely wider.

Summary

get_SFN_path rewrites the last component of a caller's path in place with a directory entry's FAT short name. It assumes that a short name is never longer than the long name that it overwrites. A USB stick with a hand-written directory entry breaks that assumption. It overflows a 104-byte stack buffer in Transfer::cleanup_finalize. The printer's own crash dump records the program counter as the value that the entry spelled. The attack needs no password and no network access.

The score rates that full control of the program counter. The overflow bytes are a FAT 8.3 short name, and that alphabet cannot name a RAM address or a usable gadget. So the demonstrated hijack does not run attacker code.

The defect

get_SFN_path copies the matched entry's short name over the caller's filename, guarded only by an assert:

cpp
// src/common/lfn.cpp:67-71  (inside get_SFN_path)
if (ent != nullptr) {
    // fname is part of the path we passed in, so we can modify it.
    assert(strlen(fname) >= strlen(ent->d_name));
    strcpy(fname, ent->d_name);
}

Release builds define NDEBUG (CMakePresets.json sets CMAKE_BUILD_TYPE=Release). The assert therefore compiles to no code, and no check bounds the strcpy. The header states the premise behind the code:

cpp
// src/common/lfn.h:26-28
// converts the last part to SFN, in-place. This assumes LFN is never shorter
// than SFN, therefore the result can't overflow the buffer.

That premise is false on crafted media. Prusa maps the FatFs codepage as identity. ffconf.h reads FF_CODE_PAGE 850, but a patch in ffunicode.c:15245-15246 makes ff_oem2uni return the raw byte. Because of this mapping, an 8.3 field of bytes at or above 0x80 expands to as much as 23 UTF-8 bytes. That is longer than a one-character long name.

Reaching it

Transfer::cleanup_finalize copies a destination path into a 104-byte stack buffer and calls get_SFN_path on it:

cpp
// src/transfers/transfer.cpp:567-569
char SFN[FILE_PATH_BUFFER_LEN];                        // 104 bytes
strlcpy(SFN, transfer_path.as_destination(), sizeof(SFN));
get_SFN_path(SFN);

A USB stick carrying a staged transfer directory drives this through the Connect client's idle housekeeping, with no credential and no controlled server. That is the demonstrated attacker position: someone who can put a prepared stick into the machine. get_SFN_path has nine call sites. The other eight sit behind PrusaLink or Connect authentication, so the USB path is the one that needs nothing.

The path has to be long enough to bring its last component against the end of the buffer. It also has to be short enough to survive the bounded strlcpy, because search_file reaches the strcpy only on an exact name match.

Impact and the ceiling

cleanup_finalize saves its registers and return address on entry. It restores them with an interworking pop. The overflow that reaches them therefore controls where the function returns:

code
0804b758  push {r4, r5, r6, lr}    ; prologue: save the registers and return address
   ...
          pop  {r4, r5, r6, pc}    ; epilogue: restore R4/R5/R6 and branch to the saved PC

The stick therefore sets R4, R5, R6, and the program counter, and the processor branches to the value the entry spelled. Every four-byte value that the vector writes comes from the 8.3 short name, re-encoded as UTF-8. That re-encoding behaves as a positional grammar rather than a per-byte filter.

SRAM addresses begin 0x20. FAT pads short names with that byte, and FatFs strips it. As a result, no RAM address lands in any slot, and injected code is unreachable wherever it sits. The connect task stack is in CCMRAM (the recorded SP = 0x1000b408), which the core cannot fetch instructions from in any case.

What remains is a branch to one of 4627 flash addresses, a count that we enumerated on the host. The one shape that would escape is a memory-load gadget:

code
08022434  ldr  r3, [r4, #0x10]    ; load a pointer through the attacker-controlled r4
          mov  r0, r4
          blx  r3                  ; and call it

That gadget exists, but the 8.3 alphabet cannot spell its address 0x08022434. A scan of the reachable set found no other such gadget. There is no stack canary anywhere in the image.

The vector rates full control of the program counter, so it carries C:H, I:H, and A:H. AC:H reflects two constraints: the 8.3 alphabet and the stick that must be present at power-on. The demonstrated result is narrower. The control-flow hijack does not run attacker code, because the alphabet cannot spell a usable target. In practice this reliably crashes a machine with heaters and motion attached.

Proof on hardware

The printer wrote its own crash dump back to the stick. Decoded against the firmware's CrashCatcherDumpParser::Registers_t, the dump reads:

code
PC  = 0x1337adc2     the chosen 0x1337ADC3, bit 0 taken into EPSR.T by the interworking pop
PSR = bit 24 (T) set
R4  = R5 = 0x80c380c3
R6  = 0x2e414141     the rest of the overflow, restored off the corrupted stack by the same pop
SP  = 0x1000b408     CCMRAM

R4, R5, and R6 match the byte layout computed before the run. The chosen address sits in the unmapped gap between CCMRAM and SRAM. The fetch therefore faulted on the branch target itself rather than on anything it ran. This advisory leaves out the two-stage stick recipe. Prusa received it with the report.

Source release

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