Lessons from TARmageddon: What Rust Can Learn

1 Year Later: Lessons from TARmageddon

Edera builds a memory-safe hypervisor in Rust. We rewrote the Xen control plane in it and would do it again.

Last year I also filed CVE-2025-62518, a High severity remote code execution bug in Rust code that shipped inside some of the most downloaded tools in the ecosystem. The compiler never had a chance to catch it, and once we found it, the way the Rust ecosystem is organized made it far harder to fix than it should have been.

I'm giving a talk about this at RustConf (come say hi if you are attending). This is the written version, organized around what I think the ecosystem should take from it. Rust is not the problem here. Rust is doing exactly what it promised. The lessons are about everything around the language.

What Happened

While building NVIDIA support for the Edera platform, an OCI image pull failed and left a temporary directory full of files that weren't container layers. The async tar library we used had unpacked the contents of nested tarballs as if they belonged to the outer archive.

One wrong decision caused it. A tar header can carry a file's size in two places: the legacy ustar field, which can't hold large values, and a PAX extended header, which can. The library read the ustar field. For large files that field is zero. The parser advanced zero bytes, landed on the first header inside the nested archive, and carried on as if it were the next outer entry.

Every read was in bounds. The borrow checker was satisfied. The result was an archive that lists clean and extracts extra, unscanned files, including overwrites of config files and build definitions. Worst case is 8.1 (High) and remote code execution. Confirmed impact included uv, testcontainers, and wasmCloud.

The disclosure writeup covers the technical details and the response process got its own talk at Open Source SecurityCon last year. What follows is what it taught us and how we have applied those learnings to building Edera. 

Lesson 1: Memory Safety Is Not a Security Review

Rust's ownership model and borrow checker remove memory safety bugs at compile time. Buffer overflows, use-after-free, double frees, data races. In Rust these don't exist, and that covers roughly 70% of the CVEs Microsoft and Google file each year. It's why CISA and the NSA recommend memory-safe languages for critical infrastructure, and why we picked Rust for the part of our stack where one bug takes down every workload on the host.

But the compiler only sees how memory is owned and accessed. It doesn't know what your program is supposed to do. It won't stop a parser from reading the wrong field, and it won't stop you from trusting the wrong one of two ways a format encodes a value. TARmageddon was a parser reading the wrong field. The program was memory-safe and wrong.

The ecosystem lesson: "written in Rust" answers one question about a dependency. It's a good answer. When the industry starts using it as a procurement checkbox, and it has started, somebody needs to keep saying that it is not the only question.

Lesson 2: Forks Copy Bugs, Not Fixes

The bug lived in four crates:

  • async-tar, forked from the synchronous tar crate to add async support
  • tokio-tar, forked from async-tar to switch async runtimes
  • krata-tokio-tar, our fork of tokio-tar for internal behavior changes
  • astral-tokio-tar, Astral's public maintenance fork, used by uv

The original tar crate handled PAX sizes correctly the entire time. That fix never reached the async forks because they were copies, not dependencies. async-tar got it wrong once and every descendant inherited it.

There was no upstream to patch. We wrote patches for each crate ourselves. Two of the four had no SECURITY.md and no contact address, so finding the right people took real sleuthing. We ran a 60-day embargo across all of them and their major downstreams.

People fork for good reasons. Upstream is unresponsive or dead. There's a real disagreement about behavior. The original was never meant for public use, and open source doesn't mean open to contributions. I'm not against forking. But a fork of a parser is another parser, and every extra parser is another place for an already-fixed bug to keep living.

Lesson 3: Sync vs Async Should Not Require a Fork

Look at why two of those forks exist. async-tar exists because tar is synchronous. tokio-tar exists because async-tar picked async-std over tokio. Neither fork had any disagreement about how tar works.

The format never changed. But the parsing logic was written directly against std::io::Read, so making it async meant copying the whole crate and rewriting the I/O layer. The format logic came along for the ride and started drifting the day the fork was created.

This is a Rust pattern, not a tar pattern. Crates commit to one execution model, and when somebody needs the other, the crate gets forked wholesale. Rust has coalesced well on shared crates for a lot of common functionality, but the sync/async line splits that consensus for anything that touches I/O, which is most of what matters for security.

It doesn't have to be this way.

Lesson 4: Design Format Crates Sans-IO

The Python ecosystem has a name for the alternative: sans-io. Write the format or protocol logic as a pure state machine with no I/O in it, then put thin I/O layers on top.

For tar, that means a core layer that models the format as a state machine (bytes in, events out, no Read, no AsyncRead, no runtime), a thin synchronous wrapper that feeds it from a blocking reader, a thin async wrapper that feeds it from a stream, and feature flags so consumers compile out what they don't use.

pub enum TarEvent {
    Header(TarHeader), // file header
    Data(usize),       // number of bytes
    End,               // end of tar
}

pub struct TarParser { /* ... */ }

impl TarParser {
    pub fn push(&mut self, bytes: &[u8]);       // same for sync AND async
    pub fn pull(&mut self) -> Option<TarEvent>;
}

push and pull don't know where the bytes came from. There is one PAX size parser. Fix it and every consumer gets the fix.

The state machine has a cost, but for format parsing it's small next to the I/O. Ergonomics suffer if you ship only the state machine, so the crate should ship the sync and async wrappers itself instead of leaving that to downstreams, who will fork you to get them.

This is how we rebuilt our image pipeline after dropping tokio-tar. One parser, one place for PAX handling to be right.

Lesson 5: Make Disclosure Possible Before You Need It

Two of the four affected crates had no SECURITY.md. One of them had over five million downloads. Finding a human to send a patch to took longer than writing the patch.

A SECURITY.md can be one line with an email address. Add it to everything you publish, including the projects you think nobody uses. Somebody does.

At the ecosystem level, cargo could record fork provenance in package metadata. If crates.io knew that tokio-tar derives from async-tar, a responder could enumerate the fork tree in seconds instead of reconstructing it from commit history and guesswork.

The Lesson Under the Lessons

Rust's memory safety works because it's structural. You don't get it by being careful. The compiler makes the unsafe program not exist. That's why it scaled where "write careful C" never did.

The bugs Rust doesn't catch need the same treatment. You don't secure four parsers by auditing them harder. You arrange things so there's one. You don't fix fork sprawl by asking maintainers to backport diligently. You design crates so nobody needs to fork.

We say the same thing about infrastructure. A shared kernel doesn't get safer because you watch it more closely. Each workload gets its own. Same argument, different layer: cut down the number of places a bug can exist, and whatever vigilance is left becomes possible.

Two things I still don't know: whether Rust is actually more fork-happy than other ecosystems or just feels that way from inside a disclosure, and whether over-forking does measurable damage. If you have data or opinions, I want them. 

If you are attending RustConf in Montreal this week, please come by my talk on Thursday. I’d love to meet you and hear what you are working on or interesting problems you are trying to solve. If you won’t be there, but are interested in learning more, reach out. 

FAQ

What is TARmageddon?

A boundary-parsing bug (CVE-2025-62518) in tokio-tar, async-tar, and their forks. The parser read the legacy ustar size field instead of the PAX size field for large files and treated nested archive contents as top-level entries. Edera discovered and disclosed it in 2025.

Does Rust prevent all security vulnerabilities?

No. Rust prevents memory safety bugs like buffer overflows and use-after-free at compile time. It does not prevent logic bugs, specification misreads, or supply chain problems like unmaintained forks.

Why did TARmageddon affect multiple Rust crates?

Each async tar crate was a full fork carrying its own copy of the parsing logic. Fixes made in one crate never propagated to the others.

Why does the Rust ecosystem fork crates for sync and async?

Most crates write format logic directly against one I/O model. Supporting the other means copying the crate and rewriting the I/O layer, which duplicates the format logic and lets the copies drift.

What is sans-io in Rust?

A design pattern where format or protocol logic is a pure state machine with no I/O dependencies. Sync and async are thin layers on top, so one implementation serves both and bugs get fixed once.

Should you still use Rust for security-critical software?

Yes. Memory safety bugs are the majority of historical CVEs in systems software and Rust removes them structurally. Logic bugs, dependency hygiene, and crate design still need attention.

References

Cute cartoon axolotl with a light blue segmented body, big eyes, and dark gray external gills.

You know you wanna

Let’s solve this together