PFB-002Vendor: Prusa ResearchProduct: Buddy firmware

Prusa Connect encrypted downloads carry no integrity check over plain HTTP

•
6 min read
pfb-002.advisory
Severity
CVSS 5.9Medium

CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N, FIRST CVSS calculator (opens in a new tab)

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: None
  • Integrity: High (worst case)
  • Availability: None
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 (encrypted download removed)
Attack surface
Network path MITM
Verification
Source review only

source-verified. Established by reading the shipped 6.6.0 source. Not confirmed against a live printer. No runtime or hardware testing.

Analysis notes
Versions and source tree

Prusa Buddy firmware, MK4S. Target hardware runs 6.5.7 (build 6.5.7+12836). This advisory cites lines against the 6.6.0 source tree (commit e96ce2b9). The 6.5.7 tree was not available to diff.

Summary

The START_ENCRYPTED_DOWNLOAD path fetches a file over plain HTTP and protects it with AES-128-CTR alone. CTR is a stream cipher with no integrity, and the transfer code adds no MAC, signature, or checksum over the plaintext. An attacker on the network path between the printer and the file server can apply chosen, precise edits to the decrypted G-code. The attacker does not need the key or the nonce for this.

Firmware 7.0.0 does not patch this defect. It removes the encrypted Connect download, and the affected code with it. A build that restored the 6.x encrypted download code would restore the defect too.

The defect

The whole of the transfer cryptography is one AES-128-CTR call, and its only check is the library return code:

cpp
// src/transfers/decrypt.cpp:41-48
auto ret = mbedtls_aes_crypt_ctr(&context, amnt, &stoff, nonce.data(), stream.data(), in, out);
if (ret != 0) {
    // There should be no failure mode in real life for decryption with
    // correct parameters, but we are super-careful and catch any
    // unexpected return values because handling encrypted data is
    // sensitive thing at best.
    bsod("Decryption failed");
}

decrypt.cpp:41 is that call. The guard at decrypt.cpp:42-48 fires on a library error and calls bsod. It does not fire on a content mismatch. The key is 128 bits (16 bytes, decrypt.cpp:31). There is no MAC, no AEAD, and no integrity value in the file.

The download itself is plain TCP. create_connection() opens the socket with the plain-TCP allocator, never the TLS one:

cpp
// src/transfers/download.cpp:344
conn = altcp_new_ip_type(altcp_tcp_alloc, IP_GET_TYPE(request.ip));

The file includes lwip/altcp_tcp.h (download.cpp:21) and never includes lwip/altcp_tls.h. Both request forms that it builds are HTTP:

c
// src/transfers/download.cpp:202-206
if (request.proxy_port) {
    len = snprintf(req, sizeof req, "GET http://%s:%" PRIu16 "%s HTTP/1.1\r\nHost: %s\r\nContent-Encryption-Mode: AES-CTR\r\nRange: %s\r\n\r\n", request.hostname, request.port, request.path, request.hostname, range);
} else {
    len = snprintf(req, sizeof req, "GET %s HTTP/1.1\r\nHost: %s\r\nContent-Encryption-Mode: AES-CTR\r\nRange: %s\r\n\r\n", request.path, request.hostname, range);
}

The socket is plain TCP, so the printer-to-server hop (or printer-to-proxy hop) is cleartext HTTP. The response handler checks only the status, the content length, and that the mode is AES-CTR (download.cpp:236-255). It verifies nothing about the body. Splice::write then pipes every received byte straight through the decryptor (download.cpp:115-117) and into storage.

The key and nonce arrive as hex strings in the Connect command JSON over the TLS command channel (command.cpp:401-404). ARGS_ENC_DOWN requires them (command.cpp:87). The iv field is the nonce. The name iv is a legacy from when the code used AES-CBC (command.hpp:62). make_enc_url then writes the nonce in hex into the request path /f/<iv>/raw (connect/planner.cpp:191-207,240). So the nonce travels on the HTTP wire in cleartext.

Because the key does not travel on the HTTP wire, a passive observer cannot read the file body. The exposed weakness is integrity, not confidentiality of the content.

A grep across src/transfers/ for sha256, hmac, signature, sign, or verify returns nothing. Every crc32 there is local bookkeeping:

  • transfer_recovery.cpp hashes its own state struct.
  • changed_path.cpp hashes paths.
  • transfer.cpp hashes short filenames.

The only size field is orig_size (command.hpp:65, download.cpp:468). It is a length, not an integrity value.

The same codebase authenticates the analogous paths. The .bgcode end-to-end encryption uses AES128_CBC_SHA256_HMAC (gcode_reader_binary.cpp:556). check_hmac_and_crc feeds the block header, a position-derived IV, and the payload into an HMAC-SHA256 (hmac.cpp:39-53,63-76). stream_gcode_start verifies that HMAC before it starts streaming the decrypted G-code:

cpp
// src/common/gcode/gcode_reader_binary.cpp:360-367
if (auto res = check_hmac_and_crc(file, start_block, symmetric_info, false); res != e2ee::CheckResult::OK) {
    if (res == e2ee::CheckResult::CORRUPTED) {
        return Result_t::RESULT_CORRUPT;
    } else if (res == e2ee::CheckResult::ERROR) {
        return Result_t::RESULT_ERROR;
    }
}
init_decryption();

The check returns on CORRUPTED or ERROR. Execution reaches init_decryption() at :367 only after the check passes. The Connect command channel itself runs over TLS 1.2 with MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 and MBEDTLS_SSL_VERIFY_REQUIRED (tls/tls.cpp:181,178). So the command channel is authenticated and encrypted, and the file format authenticates each encrypted block before the reader streams G-code from it. The file transfer between them is neither authenticated nor encrypted.

Reaching it

The attacker needs one of these positions on the network path:

  • the same LAN as the printer
  • a compromised router upstream
  • the proxy that the printer's configurable connect_proxy_host setting makes available (download.cpp:389-393)

The attacker needs no credential on the printer or on Connect. The download fires when Connect issues a START_ENCRYPTED_DOWNLOAD command (command.cpp:194). START_CONNECT_DOWNLOAD does not take this path. The parser maps that command to the inline download (command.cpp:193), which carries the file over the TLS command channel.

Two preconditions are open. A maintainer can answer both without touching Prusa infrastructure:

  1. Whether Connect issues START_ENCRYPTED_DOWNLOAD in production. The printer sends that download as plain HTTP to its configured Connect host or proxy (connect/planner.cpp:176-189,234, download.cpp:389-393). The whole connection from the printer to that server is cleartext.
  2. How far the firmware's thermal limits and runaway protection bound the physical consequence of modified G-code.

The first precondition is answerable by observing one's own printer on one's own network.

Impact

CTR mode XORs the plaintext with a keystream. Flipping a ciphertext bit flips exactly the corresponding plaintext bit and nothing else. So an on-path attacker can apply a chosen delta to the decrypted file without needing the key or the nonce. The payload is G-code, which is structured and predictable ASCII with commands like M104 S210. An attacker who knows the plaintext at an offset can therefore rewrite it to a chosen value. Where the plaintext is unknown, the same primitive still flips chosen bits.

The attacker gains control over the instructions that the printer executes: target temperatures, motion, feed rates. This is content manipulation, not code execution on the firmware. Thermal runaway protection bounds the physical ceiling (see open question 2).

The integrity gap matters most for plain .gcode and non-E2EE files, where nothing downstream re-checks the bytes. An end-to-end-encrypted .bgcode file is a partial exception. The firmware verifies its inner AES-CBC-plus-HMAC layer at print time. That check catches tampering with the outer CTR stream. The transfer layer that this finding concerns still verifies nothing itself.

Severity rationale: the demonstrated capability is chosen modification of executed G-code (I:H). AV:N/AC:H reflects an on-path attacker who must both reach the path and know the G-code structure to make useful edits. C:N reflects that the encrypted body is not readable, and scores the request and response metadata (host, path, byte ranges, sizes) that cleartext HTTP exposes as non-sensitive. A:N discounts the availability effect of ruining or aborting the running print and provoking a fault-stop. A reviewer who scores that metadata (C:L) or that availability effect (A:L) reaches 6.5 Medium, and with both reaches 7.0 High. The reachability caveat in open question 1 applies to all of these scores.

Proof

This finding is source-verified, and we did not capture a run. This advisory leaves out the exact request that demonstrates the finding and the CTR malleability computation. Prusa received both with the report. These code paths carry the finding:

  • decrypt.cpp:38-49: decrypt writes plaintext with no verification.
  • download.cpp:236-255: the response handler checks only status, length, and mode.
  • download.cpp:115-117: Splice::write pipes bytes through the decryptor with no downstream check.

Source release

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