PFB-003Vendor: Prusa ResearchProduct: Buddy firmware
Integer underflows in the E2EE decryptor reach an out-of-bounds read
- Severity
Defect 1: the padding length underflows into a pointer
CVSS 4.3MediumCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:N/ AC:L/ PR:H/ UI:R/ S:U/ C:L/ I:L/ A:L CVSS metrics and CWE
- Attack Vector: Network (worst case)
- Attack Complexity: Low (worst case)
- Privileges Required: High
- User Interaction: Required
- Scope: Unchanged
- Confidentiality: Low
- Integrity: Low
- Availability: Low
Defect 2: the block size underflows in setup_block
CVSS 3.5LowCVSS:3.1/, FIRST CVSS calculator (opens in a new tab)AV:N/ AC:L/ PR:H/ UI:R/ S:U/ C:N/ I:L/ A:L CVSS metrics and CWE
- Attack Vector: Network (worst case)
- Attack Complexity: Low (worst case)
- Privileges Required: High
- User Interaction: Required
- 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 (E2EE removed)
- Attack surface
- Encrypted file from the E2EE key holder
- Verification
- Reproduced on host
Defect 1 is AddressSanitizer-confirmed on the host against unmodified
src/common/e2ee/decryptor.cpp, and this review reproduced it. Defect 2 is source-verified. Neither defect is hardware-tested. - Analysis notes
Versions and source tree
Prusa Buddy firmware, MK4S. This advisory cites source lines from 6.6.0 (
version.txt6.6.0, commit e96ce2b9). Our test printer runs signed 6.5.7.decryptor.cppanddecryptor.hppare identical between the 6.6.0 tree and the tree that we built the host test from.- Disclosed in
- Never gonna print you up!
e2ee::Decryptor derives two sizes by unchecked subtraction on values that come from the file.
This report covers both defects.
It starts with Defect 1, the defect that we reproduced under AddressSanitizer.
Defect 1 turns a wrapped PKCS#7 padding length into a pointer.
The next decrypt call takes a memcpy length from that pointer.
The memcpy then reads past the end of a 16-byte buffer.
Defect 2 wraps the block size inside setup_block when the block is smaller than its HMAC area.
The only guard is an assert that tests alignment, not the underflow.
Firmware 7.0.0 does not patch these defects. It removes E2EE support, and the affected code with it. A build that restored the 6.x E2EE code would restore the defects too.
Defect 1: the padding length underflows into a pointer
pkcs7_padding_data_len reads the padding length from the last decrypted byte, so on a 16-byte Block the length ranges from 0 to 255:
// src/common/e2ee/decryptor.cpp:31-40
std::optional<size_t> pkcs7_padding_data_len(Decryptor::Block &block){
uint8_t padding_len = block.back();
for (size_t i = block.size() - 1; i > block.size() - 1 - padding_len; i--) {
if (block[i] != padding_len) {
// Invalid padding
return std::nullopt;
}
}
return block.size() - padding_len;
}When padding_len is 16 or more, the loop bound underflows in size_t, to about 4.29e9 on the 32-bit target and about 1.8e19 on the 64-bit host, so the validation loop never runs and rejects nothing.
When padding_len is 17 or more, the returned length underflows to the same magnitude.
The value 0 is the small case: the function returns 16, and the caller below sets cache_end one byte past the cache.
No read goes past the cache in that case, because remaining_encrypted_data_size still caps the reads at 16 bytes.
The caller then builds a pointer from that returned length:
// src/common/e2ee/decryptor.cpp:74-81
size_t data_size = data_size_opt.value();
if (to_return > data_size) {
return false;
}
cache_curr_pos = cache.begin();
// point one after the last valid byte
cache_end = cache.begin() + data_size + 1;
remaining_encrypted_data_size -= BlockSize - data_size;The guard bounds to_return, never data_size itself, so it passes trivially when data_size is enormous.
cache_end then lands far outside the 16-byte cache, and remaining_encrypted_data_size wraps as well.
On the next decrypt call, the code takes the copy length directly from that pointer:
// src/common/e2ee/decryptor.cpp:49-52
size_t cache_size = cache_end - cache_curr_pos;
// Take all we can from cache
size_t from_cache = std::min(size, cache_size);
memcpy(buffer, cache_curr_pos, from_cache);cache_size is enormous, so from_cache is just the caller's requested size, and the memcpy reads that many bytes from a 16-byte buffer.
Reachability. The over-read sits behind the E2EE HMAC, and that gate holds.
PrusaPackGcodeReader::stream_gcode_start (gcode_reader_binary.cpp:302) runs check_hmac_and_crc at line 360.
If check_hmac_and_crc fails, stream_gcode_start returns RESULT_CORRUPT or RESULT_ERROR.
check_hmac_and_crc feeds the block header bytes, including uncompressed_size, into the HMAC and then the position-derived IV (hmac.cpp:44-51).
Editing uncompressed_size or any ciphertext byte therefore breaks the HMAC.
The check does not always run before the decryptor, though.
Without an index, stream_gcode_start looks for the G-code block with a predicate that calls is_of_type (line 317).
For an encrypted block, is_of_type runs init_decryption and init_encrypted_block_streaming (gcode_reader_binary.cpp:1159-1160).
The second call runs setup_block and the first decrypt calls (gcode_reader_binary.cpp:564-568), all before the check at line 360.
With an index, the check at line 360 runs before init_decryption at line 367.
On both paths, the over-read itself comes after the check, in stream_getc_decrypted (gcode_reader_binary.cpp:586).
So nobody can reach the over-read by tampering with a file in transit, and PFB-002 does not chain into it.
One case remains reachable.
Any party that can produce a legitimately encrypted file for this printer controls the plaintext.
That party therefore also controls the final padding byte and uncompressed_size.
That party holds the printer's E2EE symmetric key.
In practice, that party is the file-producing service.
E2EE exists so that this service does not have to be fully trusted.
However, a compromised or malicious file backend can read the printer's heap through these underflows, despite the encryption.
We model that key possession as PR:H, a high-privilege role in the E2EE trust model. We use AV:N for a network-delivered file and UI:R for a print that the user starts. An analyst who prefers to treat "must hold key material" as an access condition can use AC:H/PR:N. That vector gives about 5.0, which is still Medium.
Impact. The over-read copies adjacent memory into buffer, which is the decrypted G-code stream that the printer executes.
In firmware, the Decryptor is a unique_ptr inside PrusaPackGcodeReader, so the bytes past cache are neighbouring heap.
The per-call length is the caller's requested read size, and cache_curr_pos advances across calls.
So the read walks forward through the heap instead of stopping at one block.
Two consequences follow, and we did not demonstrate either of them on hardware.
The first is disclosure: the over-read splices whatever sits after the cache into the executed G-code.
SymmetricCipherInfo::keys in the same subsystem holds encryption_key and sign_key, wrapped in crash_dump::Secret<Keys> because they are sensitive (e2ee.hpp:107-110).
The second is that remaining_encrypted_data_size wraps in the same branch, so the wrap corrupts the loop's own size accounting afterwards.
[!note] UNVERIFIED, disclosure channel:
src/connect/render.cppserializes G-code-derived metadata to Prusa Connect (display name, filament types, preview). We did not trace whether the specific over-read bytes reach a reported field. UNVERIFIED, adjacency: whether the key material lands within the over-read is a heap-layout question that we did not answer.
Defect 2: the block size underflows in setup_block
setup_block takes block_size, a uint32_t from the block header, and subtracts the HMAC area:
// src/common/e2ee/decryptor.cpp:26-27
remaining_encrypted_data_size = block_size - num_of_hmacs * e2ee::HMAC_SIZE;
assert(remaining_encrypted_data_size % BlockSize == 0);block_size reaches this code as block_header.uncompressed_size (gcode_reader_binary.cpp:564).
When block_size is smaller than the HMAC area, which is num_of_hmacs HMACs of 32 bytes each, the subtraction underflows.
HMAC_SIZE is size_t, so on the 64-bit host the expression evaluates in 64 bits.
The assignment then truncates the result into the uint32_t field, which leaves about 4.29e9.
On the 32-bit target, the subtraction wraps directly to the same value.
For example, 0 - 32 stores 0xFFFFFFE0.
The assert does not catch this.
It tests only 16-alignment.
A wrapped value such as 4294967264 is still a multiple of 16, so the assert passes even in a debug build.
Under NDEBUG, the assert is a no-op.
The wrapped value then passes the size guard in decrypt:
// src/common/e2ee/decryptor.cpp:45-47
if (size > remaining_encrypted_data_size) {
return false;
}Reachability. uncompressed_size is inside the HMAC, so editing it breaks the HMAC.
Without an index, though, is_of_type runs setup_block before check_hmac_and_crc, as described under Defect 1.
So someone without the key can edit uncompressed_size and still reach the underflow, although no over-read follows.
In emulation of the shipped MK4S 6.5.7 firmware, such a file reaches the first decrypt call with remaining_encrypted_data_size already wrapped, and the reader then rejects the file.
[!note] UNVERIFIED:
setup_blockhas two more callers in the size-estimation paths,get_gcode_stream_size_estimateandget_gcode_stream_size(gcode_reader_binary.cpp:842,893). We did not trace whether those are HMAC-gated, so reachability outside the streaming path is open.
Impact. The wrapped remaining_encrypted_data_size is in place before the first decrypt call reads it, so Defect 2 corrupts the loop's size accounting from setup onward.
[!note] UNVERIFIED, Defect 2 as a memory read: on its own, Defect 2 wraps the size accounting and, in a debug build, fires the
assertonly when the wrapped value is not a multiple of 16. The proof isolates Defect 1, so it does not demonstrate an independent out-of-bounds memory read from Defect 2 alone.
Proof
AddressSanitizer confirms Defect 1.
Defect 2 is source-verified, and we triggered it only in emulation, as its reachability paragraph describes.
We compiled the shipped decryptor.cpp for the host with AddressSanitizer.
A small test program drives the Decryptor through Defect 1 in three steps:
- It builds one CBC block whose plaintext ends in
0xC8(200). - It sets
num_of_hmacsto 0. - It calls
decrypttwice.
AddressSanitizer reports:
first decrypt returned 1
ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 4096
#0 memmove
#1 e2ee::Decryptor::decrypt(__sFILE*, unsigned char*, unsigned long)+0x260The 4096 is the second call's requested size, not a property of the bug.
The test program keeps the Decryptor on the stack, so AddressSanitizer reports a stack overflow.
In firmware, the object is on the heap, as described above.
Source release
Prusa published the 7.0.0 sources under the v7.0.0 release tag on GitHub.