PFB-008Vendor: Prusa ResearchProduct: Buddy firmware
A long SEND_FILE_INFO path overflows the Connect task's stack and runs attacker code from a controlled or intercepted Connect endpoint
- Severity
- CVSS 8.1High
CVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:N/ AC:H/ PR:N/ UI:N/ S:U/ C:H/ I:H/ A:H 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: High (worst case)
- Integrity: High (worst case)
- Availability: High (worst case)
CWE-787 and CWE-94 describe the demonstrated outcome.
- 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
- Connect endpoint, controlled or MITM'd, or a compromised Connect backend
- Verification
- Reproduced on hardware
hardware-confirmed (the printer's own crash dump records our shellcode's marker registers)
- Analysis notes
Versions and source tree
Prusa Buddy firmware, MK4S. We confirmed the defect on the signed 6.5.7 image that the printer runs. We cite source lines against the
firmware/srctree (itsversion.txtreads 6.6.0). The defect is present in both.- Disclosed in
- Never gonna print you up!
Summary
Prusa Connect's SEND_FILE_INFO handler copies the command path into a shared 512-byte buffer.
It then writes the path's last filename component into the same buffer, just past the path.
Nothing limits the path to the length that this second write assumes.
With a 463-byte path, the write passes the end of the buffer and continues into the live object beside it.
It corrupts the pointer through which the Connect task dispatches printer calls.
The next virtual call dereferences that pointer. So an attacker in the role of the printer's Connect endpoint turns one command into code execution on the main MCU. We confirmed this on a physical MK4S.
The defect
SEND_FILE_INFO fills its path argument through the PATH_ARG macro, which caps the copy at the buffer's own size:
// src/connect/command.cpp:211-214 (PATH_ARG, used by SEND_FILE_INFO at :274)
#define PATH_ARG(TYPE) \
if (auto *cmd = get_if<TYPE>(&data); cmd != nullptr && buffer_available) { \
const size_t len = min(event.value->size() + 1, buff.size()); \
strlcpy(reinterpret_cast<char *>(buff.data()), event.value->data(), len);buff.size() is 512, so the macro copies a path of up to 512 bytes in full.
The file-transfer path caps its copy at FILE_PATH_BUFFER_LEN instead, in MarlinPrinter::renew (marlin_printer.cpp:202).
Why the buffer is too small
The path and its filename share one buffer.
SharedPath::name() returns a pointer just past the path string:
// src/common/shared_buffer.hpp:102-108
char *name() {
char *path = this->path();
size_t plen = strlen(path);
assert(plen < FILE_PATH_BUFFER_LEN); // FILE_PATH_BUFFER_LEN == 104; compiled out in release
return path + plen + 1;
}That fits only when a cap holds the path to FILE_PATH_BUFFER_LEN (104).
The buffer size assumes this cap:
// src/common/shared_buffer.hpp:80
constexpr size_t BORROW_BUF_SIZE = std::max(512, FILE_PATH_BUFFER_LEN + FILE_NAME_BUFFER_LEN);Only that assert holds the path to 104 bytes, and release builds drop it under NDEBUG.
So a 463-byte path puts name() at buff + 464.
The handler then writes the last path component there through get_LFN, up to 168 bytes:
// src/common/lfn.cpp:55-63
void get_LFN(char *lfn, size_t lfn_size, char *path){
search_file(path, [&](char *fname, struct dirent *ent) {
if (ent != nullptr) {
strlcpy(lfn, ent->lfn, lfn_size);
} else {
strlcpy(lfn, fname, lfn_size);
}
});
}render.cpp:1135 calls get_LFN(spath.name(), FILE_NAME_BUFFER_LEN, ...).
The second write therefore starts 464 bytes into a 512-byte buffer and continues past the end.
render.cpp:1144 makes the same get_LFN(spath.name(), ...) call on the FileChanged path.
Reaching it
The write needs no file on disk and no USB drive.
search_file passes the path's own last component to the callback before it opens anything.
So get_LFN copies attacker bytes whether or not the path exists:
// src/common/lfn.cpp:28-31
char *fname = last + 1;
// have a result ready for the not-found case
callback(fname, nullptr);Nothing tests the length of the incoming path.
path_allowed tests only the /usb/ prefix:
// src/connect/planner.cpp:135-141
bool path_allowed(const char *path){
constexpr const char *const usb = "/usb/";
const bool is_on_usb = strncmp(path, usb, strlen(usb)) == 0 || strcmp(path, "/usb") == 0;
const bool contains_upper = strstr(path, "/../") != nullptr;
return is_on_usb && !contains_upper;
}The firmware caps a Connect command body at 512 bytes (connect.cpp:198,366).
That limit is large enough for the 463-byte path.
The buffer, the MarlinPrinter, and the Connect object all live as locals in the Connect task's stack frame.
So the overflow lands on that stack:
// src/connect/run.cpp:11-13
SharedBuffer buffer;
MarlinPrinter printer;
Connect client(printer, buffer);The target that the overflow reaches is Planner::printer, the Printer & through which the planner dispatches calls:
// src/connect/planner.hpp:120
Printer &printer;A short write does it cleanly.
Put the last / late (path index 406), so that strlcpy copies only the final component.
Then land path[459:463] on the four pointer bytes of Planner::printer.
This write leaves the rest of the object intact.
Precondition: the attacker must be the Connect endpoint that the printer talks to. Any party in that network position can send the command:
- an attacker who controls the Connect endpoint
- an attacker who MITMs the Connect endpoint (plaintext or a downgraded TLS session)
- a compromised Connect backend
AC:H reflects that the attacker must hold one of these positions first.
We tested against our own MK4S, with tls=0 (plaintext) set in the printer's local config.
From overwrite to code execution
The next virtual call after the overwrite is is_printing(), vtable slot 12 (offset 48).
The shipped 6.5.7 image dispatches it at 0x0804dab4:
0804dab4 ldr r0, [r0] ; r0 = Planner::printer (the corrupted pointer)
0804daba ldr r3, [r0] ; vptr = *printer
0804dabc ldr r3, [r3, #0x30] ; slot 12 = is_printing
0804dabe blx r3If the overflow writes P into Planner::printer, the call does three steps:
- It loads
V = *P. - It loads
*(V + 48). - It branches to that address.
That is a controlled indirect call into memory that the attacker chose.
With attacker bytes at the target, it is arbitrary code execution on the MCU.
That MCU also drives the printer's heaters and motors.
The firmware carries no stack canary and no _FORTIFY_SOURCE.
Proof on hardware
The payload is self-contained.
The SEND_FILE_INFO frame is resident in the receive pool (rx_allocator_buffer at 0x20018148, lib/WUI/pbuf_rx.cpp:17) at record 0.
So the path's own bytes carry the counterfeit.
The overflow points Planner::printer at Pv = 0x200181e0.
The whole chain sits in that same resident path:
Planner::printer = Pv = 0x200181e0 (written by the overflow)
*Pv @ 0x200181e0 = V = 0x20018210
*(V+48) @ 0x20018240 = SC|1 = 0x20018245 (Thumb shellcode pointer)
shellcode @ 0x20018244 = 4b f6 ef 60 cd f6 ad 60 4f f2 08 01 cb f2 6b 11 ff deOn a physical MK4S, the printer's own crash dump records the result:
R0 = 0xDEADBEEF R1 = 0xB16BF008 values only our shellcode writes
R3 = 0x20018245 the blx target (our shellcode pointer)
PC = 0x20018254 the udf inside our shellcode bytes (SC + 0x10)
LR = 0x0804dac1 return just past the blx at 0x0804dabeThe dump's pool memory holds *Pv, *(V+48), and the 18 shellcode bytes, so the chain arrived byte-exact over the wire.
A second payload takes over the display.
It plays a video on the printer's ILI9488 LCD and holds the display until reset.
It shows an attacker-chosen effect beyond the marker registers.
Reliability: a shot runs our code only when the receive pool is canonical. A reboot restores that state, so a clean hit lands within a few attempts.
A non-canonical shot still crashes the printer (a FreeRTOS configASSERT at queue.c:1586, or a fault at the dispatch when Pv points into the wrong bytes).
So the same defect is, at minimum, an unauthenticated remote denial of service.
A crash dump from an earlier version of the exploit, which sprayed the payload over the network, records one such miss.
It records R0 = 0x20018184 and PC = 0x0804dabc.
Execution reached the dispatch, but the pointer aimed into the command frame's own headers.
Source release
Prusa published the 7.0.0 sources under the v7.0.0 release tag on GitHub.