PFB-006Vendor: Prusa ResearchProduct: Buddy firmware
Two defects in PrusaLink URL-to-filename handling (out-of-bounds read, weakened traversal guard)
- Severity
Defect 1: url_decode bounds the input against the output buffer's size
CVSS 0.0NoneCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:N/ AC:L/ PR:N/ UI:N/ S:U/ C:N/ I:N/ A:N CVSS metrics and CWE
- Attack Vector: Network (worst case)
- Attack Complexity: Low (worst case)
- Privileges Required: None (worst case)
- User Interaction: None (worst case)
- Scope: Unchanged
- Confidentiality: None
- Integrity: None
- Availability: None
correctness / defense-in-depth
Defect 2: the traversal guard skips its last window
CVSS 0.0NoneCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:N/ AC:L/ PR:L/ UI:N/ S:U/ C:N/ I:N/ 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: None
- Availability: None
defective guard, no demonstrated bypass
- 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
- PrusaLink
- Verification
- Reproduced on host
runtime-confirmed on a host harness (defect 1 also under AddressSanitizer), source-verified against 6.6.0. Not hardware-tested.
- Analysis notes
Versions and source tree
Prusa Buddy firmware, MK4S. The cited lines come from the 6.6.0 tree (commit e96ce2b9). Our test printer runs 6.5.7 (build 6.5.7+12836).
- Disclosed in
- Never gonna print you up!
Two small defects sit on the path from a PrusaLink request URL to a filename.
One defect is in url_decode.
The other defect is in the traversal guard that runs right after it.
The first defect reads past the end of the input.
The percent-decode length check tests the output buffer's size against an index into the input.
As a result, a trailing % reads two bytes past the input.
The second defect is an off-by-one.
The interior traversal scan stops one window early.
If the last four characters of a URL are /../, the scan never checks the URL against that window.
Neither defect has a demonstrated security consequence on the shipped firmware. Both defects are one character from correct, so we report them together.
Defect 1: url_decode bounds the input against the output buffer's size
Inside the % branch of url_decode, the length check guards the wrong buffer:
// src/common/http/url_decode.cpp:17-22
if (decoded_url_len < i + 3) {
return false;
}
int ascii_value;
auto curr_iter = url.begin() + i;
auto result = from_chars_light(curr_iter + 1, curr_iter + 3, ascii_value, 16);decoded_url_len is the size of the output buffer, the function's third parameter, while i indexes the input url.
The purpose of the guard is to keep the two hex digits after % inside the input.
But the guard compares the output buffer's length against an input index.
There is no relation between these two values.
The guard passes when the input's last character is % and the output buffer can hold the decoded result.
For the real callers, the output buffer is always large enough.
from_chars_light then reads the two bytes after the %, which now sit past the end of the input view.
Reachability. url_decode has exactly one caller, RequestParser::uri_filename (lib/WUI/nhttp/req_parser.cpp:287).
The url_decode input is always a view into the parser's own std::array<char, 168> url buffer (src/common/http/types.h:154).
Five HTTP handlers reach uri_filename.
static_file.cpp is the catch-all last selector (lib/WUI/http_lifetime.cpp:36).
It calls uri_filename with no check_auth.
Thus, an unauthenticated client can reach the code over PrusaLink HTTP.
A well-formed GET /a% passes ValidateRequest.
ValidateRequest only rejects parse errors and unknown methods (lib/WUI/nhttp/common_selectors.cpp:10).
The request then reaches static_file, which decodes the URL before it serves the request from /internal/res/web.
Impact. The input is always the 168-byte parser array.
Thus, the two bytes read past the view land inside that array, or at most a couple of bytes past it.
Bytes past the array land in the adjacent url_size field or in the boundary buffer reused just behind the URL (req_parser.h:121-123).
The read stays within, or immediately adjacent to, the RequestParser object.
The read does not reach unmapped memory and does not crash the printer.
It also does not disclose anything to the attacker.
url_decode consumes the stale bytes into one decoded path character, and the firmware never returns these bytes.
On the shipped call path, the demonstrated impact is none.
This is undefined behavior and a correctness defect, not a usable read.
A mechanical rubric that scores any unauthenticated, network-reachable out-of-bounds read would rate this defect about 5.3 (Medium, via C:L). That score overstates the defect, because nothing crosses a trust boundary here. The guard does not do its intended job. Thus, the safety of the call depends entirely on one fact: the input from the one caller is always the 168-byte array.
Defect 2: the traversal guard skips its last window
Right after the decode, uri_filename scans the decoded path for a directory escape:
// lib/WUI/nhttp/req_parser.cpp:294-304
if (url_len >= 3 && (fname_decoded.substr(0, 3) == "../" || fname_decoded.substr(url_len - 3) == "/..")) {
return false;
}
// It seems looking for a substring in a non-null-terminated string in C++
// is kind of painful.
for (size_t i = 0; i + 4 < url_len; i++) {
if (fname_decoded.substr(i, 4) == "/../") {
return false;
}
}The loop compares a four-character window at each position, so it must keep running while at least four characters remain.
The bound i + 4 < url_len stops the loop one position early.
As a result, the loop never compares the final window, which is the four characters that end the string.
The check above the loop catches a trailing /...
But a trailing /../ ends in ../, not /...
So the check above the loop also misses that window.
Reachability. The guard is in the same uri_filename function.
The handlers where the guard protects user data require authentication:
usb_files.cppcallscheck_auth(:26) beforeuri_filename. Then it GETs the path withfopen(:37).- The delete and upload handlers of the v1 API (
prusa_link_api_v1.cpp, behindcheck_authat:111) operate on the same decoded path.
Therefore, a client needs PrusaLink authentication to reach the guard with data that matters.
The unauthenticated static_file path also reaches the guard.
But static_file prepends /internal/res/web, so a trailing /../ on that path resolves inside the web-asset tree.
Impact. The guard misses only a /../ in the final four characters.
By construction, nothing follows that /../.
So the decoded path always ends in a directory reference and never names a file or object outside the tree.
/usb/../ resolves to the parent of the USB mount point.
fopen on a directory does not return a readable file.
The delete and upload handlers act on a directory path, not on a file outside the tree.
We did not find a way to turn the missed window into a read, write, or delete outside the intended directory. This defect is a weakened guard, not a demonstrated bypass. CWE-22 is the check that the off-by-one degrades, not an achieved traversal.
Proof
We reproduced each defect on the host.
For defect 1, a host program passes the six-byte input /usb/% to the shipped http::url_decode, in a heap buffer allocated at exactly its length.
Thus, the trailing-percent read lands in an AddressSanitizer redzone, not in whatever the caller has nearby.
AddressSanitizer reports heap-buffer-overflow READ of size 2 inside from_chars_light, reached from http::url_decode.
For defect 2, a host program passes a table of URLs to the shipped RequestParser::uri_filename and prints whether it accepts each one.
uri_filename rejects the interior /../, the leading ../ and the trailing /.. cases, as intended.
It accepts /usb/a/../ and /usb/../ (accepted=1).
That is the window that the loop skips.
Unverified
- Whether the two cited lines are byte-identical in the 6.5.7 source that our test printer runs. We verified them in the 6.6.0 tree only.
Source release
Prusa published the 7.0.0 sources under the v7.0.0 release tag on GitHub.