Never gonna print you up!
Note: This article's release was coordinated together with Prusa, and was made public at the same time as the official security advisories. Prusa has fixed every finding in this article in Buddy firmware >7.0.0, so if your printer is on the list at the end of this post, update it now. I've also requested CVE assignments for all of the findings, which should land soon. If you know how to speed the assignments up, I'd love to hear about this!
Hey Rick, what are you doing on my printer?!
TL;DR
In August I spent a full week trying to break my MK4S, and it escalated quiiiite a bit: 14 findings, roughly 23 individual defects, and a chain that ends with the printer's main controller running code I sent it over the network. Yup, fully fledged RCE.
This post is about the findings and about the way Prusa handled the whole situation.
A little bit about me and the printer in question
I work as a freelancer in Platform Engineering, SRE and around databases - security research is my 'hobby' on the side (you know, taking a rest after paid computer-based work, with different computer-based work..). I like to break things to learn about the internals. I participated in CTFs for years (and got massively inspired by LiveOverflow to try to understand reversing and pwning), and more recently I've made a few real findings in production web applications, which I scan proactively for my customers (btw, you can hire me on a project basis!). Firmware and hardware, though, were kinda new to me. So no, before this I had never written a line of Cortex-M assembly, and never had to.
The setup was about as basic as it gets: my own MK4S and the signed upstream image it was running, which was recent back then (6.5.7, build 6.5.7+12836) - and I was armed with Opus 4.8 and a fuzzer. I started in two places, G-code parsing and Prusa Connect, on the usual theory that you begin looking for vulnerabilities where user-supplied input comes in, and that is also where I found the most serious bugs. More turned up in the USB and FAT path (user-supplied input again) and in the transfer-recovery code, plus some smaller defects on the MMU and EEPROM side.
The method
The week went like this:
- Compiling the firmware's own parser code for the host under
AddressSanitizer, so that any crash sits in Prusa's shipped code and not in my glue. - Standing up a small Cortex-M emulator that executes the real signed image with a corresponding memory map, so I could drive the chain and prove the control flow before touching the printer and giving it a poisoned USB stick, you know.. I'm lazy. :-)
- Running millions of mutated iterations against the G-code path and also millions of mutated HTTP requests against PrusaLink / Connect.
- Doing A LOT of reverse engineering against the real compiled build, and running multiple source audits in parallel, each of which had to record the files it did not open.
The hardest part by a considerable distance was the control-flow primitive, where I had a pile of candidate dispatch sites that looked usable, and exactly one of them actually worked for the RCE. The rest got ruled out one at a time, and most (not all of it though!) of that ruling-out was mechanical work I could hand off and check.
The disclosure packages I sent Prusa had the whole worktree I'd built, including the methods I'd used, scripts etc. Every trace of that method passed over to them on purpose, because even though a vendor needs the defect and the line number, I felt it might've been beneficial to give them the framework I built for this whole campaign and enable the devs to repeat my process, if they want to (and I hope they do!). I wanted to make a clear difference and build trust that what I found was actually real (more on that in a bit..) and is reproducible, hence the separation of the disclosure packages. I still got enough confidence that the findings in the second (source-verified) collection were real though, after manually reviewing them.
What I found and reported
I sent Prusa two packages. The first held four findings, three of which are confirmed on the physical printer, both from crash dumps the printer wrote itself and from a complete takeover of the controller. The fourth, an off-by-one on the MMU side, is source-verified only (more on that one below). The second package held the other ten, which are confirmed under AddressSanitizer or by source review only.
There's actually some reasoning behind this decision: so that my confidence levels between the types of findings would not "blur" into each other. A bug I've seen crash (or exploit..) my own hardware and a bug I reasoned my way to in the upstream source tree are not really on the same level. Packaging them together might have led Prusa to over-trust the weak ones or under-trust the strong ones, or discard them altogether at worst, so it made a lot of sense to draw a line between them. Every finding still came with a complete write-up (that I already cleaned up by a LOT, but the sheer amount still created pain on Prusa's side - see below), plus PoC code to exploit each one, if applicable. And with Prusa now going public, I'll be releasing all of them as well.
But let's discuss the most serious one first, while keeping it high-level. The actual advisories hold the full write-ups though:
Oops, I got RCE
Release builds drop the 104-byte path assert, so a 463-byte path makes the filename write overflow a shared 512-byte buffer onto the planner's printer pointer, confirmed on a physical MK4S.
This one is in Prusa Connect, the cloud (or self-hosted) service the printer talks to if you want it to, for managing your printer, dispatching prints and so on. When Connect sends a SEND_FILE_INFO command, the controller's handler copies the command's path into a shared 512-byte buffer, and it bounds that copy by the size of the buffer. The problem is that the buffer holds more than the path, because the filename goes into the same buffer immediately after it, and the code that reaches for the filename finds it by walking off the end of the path string. That arrangement only works if the path is capped at 104 bytes.
Yep, you might have caught the issue already: nothing caps it at 104 bytes. The only thing enforcing that assumption is an assert(), and since release builds compile asserts out under NDEBUG, a long enough path pushes the filename slot off the end of the buffer and onto whatever is next to it.
// 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;
}What is next to it, as it happens, is the pointer Connect's own command planner dispatches printer calls through, so the next virtual call walks that pointer and goes wherever I pointed it. The file does not need to exist on the printer, because the lookup hands the filename to its callback before it ever opens anything, and the bytes get copied either way.
I should mention two more details. First, the payload is self-contained: the command sits in the network receive pool, so the path's own bytes carry the fake object, the fake dispatch table and the code, and there is no heap spray and no second channel. Second, the firmware carries no stack canary and no _FORTIFY_SOURCE, which is what turned this bounds slip into actual program-counter (PC) control instead of a simple crash.
It scores a preliminary 8.1 High, and there's the precondition that the attacker has to be the Connect endpoint the printer talks to, or be able to MITM it. That's anyone who controls that endpoint or sits in its path, up to and including a compromised Connect backend, and on a LAN, where ARP spoofing is an option, it's a realistic position. I tested against my own printer with TLS off in its local config, so a MITM would still have to downgrade TLS if you have your printer talking to the official cloud-based Prusa Connect.
What confirmed it (or basically all of the Reproduced on hardware tagged disclosures) in the end was the printer's own crash dump, which it writes to USB when it crashes. The dump recorded two marker values in registers that only my shellcode writes, a program counter inside my own bytes, and a return address pointing just past the dispatch. The pool memory in the dump held the whole chain, so this validated it arrived correctly.
I then started to work on an actual PoC exploit to prove that I have control over the hardware.
This is the recording I sent Prusa:
I will release the full PoC code (for each bug where applicable) most probably in December, to allow more time for people to patch their machines - maybe during Congress / 40C3, because I might have my printer at our assembly doing some weird stuff using this exploit :-)
While I understand that an agent will be able to derive its own PoC implementation using my write-up, I still feel that this is the much more responsible way.
The rest of the first package
A crafted USB filename overflows a stack path buffer in get_SFN_path and takes the program counter
A crafted USB filename takes the program counter. The firmware rewrites a path's last component in place with the FAT short name of a directory entry, on the documented assumption that a short name is never longer than the long name it replaces, and the guard for that assumption is another assert() that release builds drop. The assumption is false on media you write by hand, because Prusa maps the FAT codepage as an identity map, so each high byte becomes two bytes of UTF-8 and lifts the ceiling for the whole name to 23. It scores 6.4 Medium, which rates the full program-counter control it gives. The hijack I showed doesn't run my own code, and that limit is structural rather than a gap in effort: the overflow bytes have to spell a FAT 8.3 short name, and that alphabet cannot name a RAM address or any useful gadget. It needs physical USB access, and that alphabet plus the stick having to be present at power-on are why the score rates the attack complexity as high.
An off-by-one out-of-bounds read in the MMU progress converter, and two EEPROM-driven memory defects
An off-by-one in the MMU progress converter. The bound is <= where it should be <, so one index past a 29-entry table is accepted, read as a pointer and walked as a string. It is source-verified only and needs a faulty or malicious unit on the MMU bus, and I rate it 3.5 Low. The off-by-one is also the finding I got wrong: I first thought genuine Prusa hardware sends the triggering code during the MMU self-test, but tracing the actual call order showed the self-test runs before the serial receive path is enabled, so nothing is on the wire while the printer is in that state. The report went to Prusa with that already corrected. (Its advisory shows 6.4, because the same write-up also covers two EEPROM defects, scored 6.4 and 3.1.)
The E2EE identity block sizes a heap buffer from one attacker field, then overruns it
An encrypted-G-code header sizes a buffer and a read from the same field, with two subtractions that differ by 11. For a narrow band of values the allocation comes out at 0 to 10 bytes while the read length underflows to roughly 4 GB, then clips to the file's remaining bytes, so the attacker picks both the length and the content of the overflow. The apparent guard never fires, because the function that should reject a short header returns zero instead of an error. It runs when the printer scans a print preview from a USB stick, with no signature check and no CRC, and without starting a print. It scores 6.1 Medium, and I confirmed it five times on hardware: it gives reliable denial of service but not reliable code execution, because the heap put the overflow in four different places across those five runs.
The second package
The other ten findings went in the second package, and each has its own full write-up, linked from its card.
A 110-char client G-code overflows the 96-byte queue slot via an unguarded strcpy
A 110-character client G-code overflows a 96-byte ring slot with an unguarded strcpy in _enqueue. The print and serial paths are bounded to 96, but the client path is not, and overflowing the last slot writes into the adjacent statics.
An unauthenticated host that can reach the printer's TCP services, usually one on the local segment, can pin its entire Ethernet receive pool. Two timeout-resetting mechanisms let it hold the pool's buffers, which drains the ring and stops PrusaLink, Connect and mDNS for as long as it keeps sending, and neither needs the password.
Prusa Connect encrypted downloads carry no integrity check over plain HTTP
The Prusa Connect encrypted download path protects incoming files with AES-128-CTR and nothing else. CTR is a stream cipher with no integrity, and the transfer adds no MAC or signature, so a host on the network path between the printer and its file server can make precise, chosen edits to the decrypted G-code without needing the key or the nonce.
M919/M920 write one byte past a 16-byte stack buffer from a scanf width off by one
M919 and M920 scan a token into a 16-byte stack buffer with %16s, which leaves no room for the terminating null byte. Any such command with a 16-character argument, whether it arrives over serial or from a print file, writes one byte past the buffer.
After a power loss the printer resumes an interrupted download from a backup file. Two defects there (an overflowing range check that cannot reject a negative offset, and a length that is dropped on the way out) turn the stored offset-and-length pair into a pointer outside the recovery buffer, and whatever the printer reads there goes out in a DNS query and an HTTP Host header.
The E2EE identity block reads a 16-bit key length straight out of the .bgcode file and passes it to mbedTLS without ever bounding it against the allocation, so a crafted block makes mbedTLS parse a key that runs past the heap block, before any signature is checked.
MeatPack G-code decoder writes two bytes past its output buffer
The MeatPack G-code decoder writes two bytes past its two-byte output buffer. One handle_rx_char call can run two decode steps, so a crafted six-byte stream pushes the output count to four and overruns the object it sits in. It fires on any MeatPack block, from a USB stick, a PrusaLink upload, or a Connect download.
Integer underflows in the E2EE decryptor reach an out-of-bounds read
Two integer underflows in the E2EE decryptor turn a wrapped PKCS#7 padding length into a pointer, and the next decrypt reads a memcpy length off that pointer, past a 16-byte buffer. One of them is AddressSanitizer-confirmed on the host, while the second is source-verified.
Five smaller source-verified defects in transfers, PrusaLink, libbgcode, and Connect
This card covers five smaller, self-contained holes across the transfer, PrusaLink, libbgcode and Connect paths. None of them is a memory-corruption primitive, and the one reachable over the network is an uninitialised path buffer on PUT /api/v1/files/usb.
Two defects in PrusaLink URL-to-filename handling (out-of-bounds read, weakened traversal guard)
Two one-character defects sit on the PrusaLink URL-to-filename path: the percent decoder guards the wrong buffer, so a trailing % reads two bytes past the input, and the traversal guard stops one window early, so a URL ending in /../ is never checked. Neither has a demonstrated consequence on the shipped firmware, so I reported them as fixes that restore what each guard was written to do.
The disclosure
I initially posted the video above on the r/prusa3d subreddit, half-jokingly asking if my printer was supposed to do this. (I would not recommend doing this with most companies out there, but here it made sense to me at the moment, to get some initial contact pointers, since I hadn't found the proper channel after some research.)
A few minutes (on a Sunday!) later Prusa's Community Manager Tommy PM'd me that my video got posted internally and rang some alarm bells - he asked if I could provide some more information and pointed me in the right direction. I then took my initial post offline and sent the email I'd already prepared.
Hi Prusa, [...] I found 23 bugs in total and a chain via Prusa Connect [1, but the most critical ones only] that ends in code execution on the printer's main controller, reachable over the local network with no physical access and no user interaction beyond the normal one-time setup. It's confirmed on real hardware, not just an emulator.
The full chain is sensitive, of course I have complete writeups, a working proof of concept (scripts needed..), and crash dumps ready to hand over - one word regarding the method: I worked closely using Claude (because I'm also verified in the Cyber Verification Program), so some of the write-ups are quite a bit messy/sloppy, I will give my best until then to clean them up and have them as easy to understand as possible.
What's the best way to get it to you? I'll work to whatever disclosure timeline suits you, and I'm keeping the actual vulnerabilities private until then, because I don't think it makes sense to open issues against the repository.
[...]
They replied exactly 24 hours later, confirming I had the right channel, and I then sent the full worktree, roughly 800 megabytes and checksummed: both packages, the test harness, every output I produced, the proof-of-concept code and the video.
Then it went quiet for a while, and that turned out to be the good kind of quiet. When they came back, they had validated and reproduced the findings themselves and said the volume had given their developers a headache (sorry!). They asked me to a) hold publication until the fix had been out long enough for people to install it, and b) coordinate the disclosure with them rather than around them, which I of course agreed to - having proper CVD behaviour in mind.
Most recently they asked how I wanted to be credited. Refreshing experience!
That is the whole story, and there is no twist. There was still a bit of a back-and-forth needed to coordinate this whole process, and I can just imagine how many people were involved in validating and fixing the findings.
What Prusa did right
- Acknowledge the report quickly. They answered within one working day, and the answer came from a real human with a name.
- Validate it quickly. They reproduced the findings on their own hardware. Given the number of findings it was still in a reasonable timeframe.
- Be honest about the timeline. "This will take longer than usual, and here is the structural reason why" was a far better answer than a date they would have missed.
- Ask for an embargo instead of demanding silence. The difference between a coordination request and an NDA is whether you are still allowed to say the work happened, and I was. Thanks!
- Treat the researcher as a collaborator. Every mail left the door open for questions in both directions. Lovely!
- Ask how to attribute the credit. They did, and they also offered to name anyone else involved, and yes, I also got a reward.
I would add a seventh, and it is the only correction I had for them at the time: a working security.txt. Because theirs was a 404, a report that was already kinda awkward to send opened with me guessing an address and hoping a privacy inbox would pass it along. It resolves today, with a contact address and a policy behind it.
Because I can see a way this becomes a topic: I won't discuss the whole Marlin GPL breach afterwards here, due to the firmware being released while the actual sources were withheld to allow more printers to be patched, before the sources were released today. I don't think I can add anything useful to an already heated discussion on GitHub. I'm sure Prusa gained quite a few learnings about this process and the community's reaction, but to me it was quite obvious that this whole situation was a huge exception to their usual development process, and we won't know the full reasoning behind the decision that ultimately followed. I can understand both sides though. Please don't get me wrong.
Why this got cheap
Thoroughness, but let me elaborate first.
I had one week, one printer and no real firmware background. A few years ago this work needed a specialist with a decade of embedded experience. This time it did not, and since I don't think I'm unusually clever but I've still got my fair share of experience in the overall sector, I think the tooling closed the gap for good.
So yeah, what got cheap is thoroughness. Enumerating every byte encoding, ruling out candidate after candidate until one works, reading the same call path three times from different directions: all of that work was - of course - always possible, but it was quite time-expensive to do properly. This whole constraint is now mostly obsolete. While I personally still embrace that programming in general got way more democratised with this massive paradigm shift, I'm sure that you still want/need someone in the 'driver's seat' able to understand what an agent is doing and guiding it in the right direction to unlock its true potential. One fitting case in point (there were many like this): during the work on the PFB-008 finding (the RCE one) I needed to push it repeatedly - using some hints - even though Opus 4.8 (which my claude steps down to once it recognises Cyber Security relevant topics) gave up multiple times, telling me that there was no way I'd be able to achieve RCE with what it found. My knowledge and a manual review of the found ROP gadgets still had me thinking otherwise, so I pushed back and finally led the model in the right direction. ("You're totally right..! blah blah blah")
One thing though:
Yes, we're also seeing a massive influx of critical CVEs (look at PHP, Next.js or the kernel just days ago), but I wonder if there will be an actual inflection point where these CVEs will reduce, once we've finished looking at existing stuff (even though generated code will suffer from the same issues - btw, if someone has seen articles about the safety of generated code compared against human-written, which are worth reading, please shoot me an email!).
But on a personal note:
I am still comfortable saying the AI part out loud (because this fact WILL be torn apart for sure, brace yourself). I'm quite confident that we now have the tool to make software and especially IoT devices much safer to use, if we do it in a considerate way. What I mean by that: stop vandalising OSS projects - look at what happened to Daniel Stenberg's cURL, which at one point simply stopped accepting issues during cURLs Summer of bliss, due to the work required to sort through the actual findings and some hallucinated nonsense, even though the quality has gradually increased as Stenberg reports.
As long as we don't have a working solution to this script AI-Kiddie problem, people are rightfully complaining about the actual strain on the broader OSS communities.
</rant>
What ships today
Prusa's fixes shipped with Buddy firmware 7.0.0 (with 8.0.0 being the most recent one, yes, I think too that the new version numbering system is a bit confusing), and every finding in this post affects all fifteen printers it covers: the 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 and MINI. If yours is on that list, update it. Prusa published its own advisory and source release on GitHub today, and says it is not aware of any real-world exploitation, except for my printer, which got rick-rolled.
That source release is the v7.0.0 release tag, which holds the 7.0.0 sources.
The full write-ups for all fourteen findings are published with the fix and linked from each finding above.
3D printers are not toys: they have network stacks, cloud integrations, USB hosts, filesystem parsers, signature-checking paths and microcontrollers talking over a serial bus, and they are bolted to heaters and stepper motors in someone's house (so there's a certain risk involved, which is why it makes a lot of sense to have the issues patched). I am not surprised that they have defects, because everything does. Let's make them as safe as we can!
What still surprises me is the response I got. I have now done this twice publicly, and once I got a response from a law firm and a criminal-code citation (lol), while the other time I got proper engineers who reproduced/validated my work and asked how I would like my name spelled for the credits and even showed their appreciation for this work with a reward (though I just did it to learn something): there are worlds in between.
Only one of those two responses makes the next researcher pick up the phone.
Thanks for the great time!
Cheers, Y