Nightjar

Pointing Cargo's .cargo-ok write at a file outside the crate directory

Identifier
CVE-2022-36113
Software
Cargo
Affected
all versions of Cargo (the Rust blog's own phrasing), i.e. up to Rust
Fixed in
Rust 1.64, released 22 September 2022
Reported by
Ori Hollander, JFrog Security Research
Disclosed
14 September 2022

Go and look in your ~/.cargo/registry/src directory. Every crate Cargo has ever unpacked on that machine is sitting there as an ordinary source tree, and in the root of each one is a file called .cargo-ok. When the bug below was reported, in 2022, that file was two bytes long and contained the word ok. (Since Rust 1.71 it holds a small JSON version marker, {"v":1}, instead.)

It's a receipt. Untarring a few thousand files is not atomic. If the process dies halfway through you're left with a directory that looks like a crate but is missing whatever hadn't been written yet. Cargo would happily compile that, and the failure would be baffling. So Cargo extracts the archive, and then, once every file is on disk, writes ok into .cargo-ok. Next time it wants that crate it checks for the receipt. No receipt, no trust, extract again.

That's a good pattern. Lots of tools do a version of it. The problem is a question nobody asked out loud: what if the archive contains a file called .cargo-ok?

Ori Hollander of the JFrog Security Research Team asked it. Here's the Rust Security Response WG's description, which contains the whole bug:

Cargo allowed packages to contain a .cargo-ok symbolic link, which Cargo would extract. Then, when Cargo attempted to write "ok" into .cargo-ok, it would actually replace the first two bytes of the file the symlink pointed to with ok.

A tar archive can carry a symlink as an entry. The entry has a name and a target, and the target is just a string that the extractor passes to symlink(2). It doesn't have to point inside the archive. It doesn't have to point anywhere that exists yet. ../../../../etc/something is a perfectly legal target, so is an absolute path, and creating the link succeeds whether or not the destination is there.

So the crate ships an entry named .cargo-ok whose target is wherever you like. Cargo extracts it, creating the symlink. Cargo then finishes extracting, opens .cargo-ok for writing, and follows it.

what extraction leaves on disk archive entry name: .cargo-ok type: symlink extract crate directory .cargo-ok a link, not a file writing ok follows the link any file the user can write the archive chose the path the target does not have to exist when the link is made
The entry Cargo extracts decides where Cargo's own bookkeeping write lands.

The write primitive#

Worth being exact about the primitive here, because it's an unusually constrained one.

You choose the path. Anything the user running cargo build can write to.

You do not choose the content. It is ok. Always ok.

You do not choose the offset. Opening a file for writing and writing two bytes puts them at offset 0. Not truncating the file, not appending: overwriting the first two bytes and leaving the rest alone.

the file the symlink pointed at 0 1 2 o k every later byte is left as it was the only two bytes you get no truncation, no append, and the content is never yours to pick
An arbitrary path, a fixed offset, and two fixed bytes.

So it's an arbitrary-path, fixed-content, fixed-offset, two-byte write. That isn't nothing. Two bytes at the head of a file will break a magic number, corrupt the first entry of a config file, or damage a header in a way that's annoying to diagnose. It is also a long way from arbitrary code execution, and Rust rated it low.

If you've only ever used Cargo the normal way, "alternate registry" needs a word of explanation. Cargo can be pointed at package servers other than crates.io, and companies routinely do it. An internal registry carries proprietary crates, or a mirror carries only an approved subset. You configure it in .cargo/config.toml and then depend on foo = { version = "1", registry = "internal" }. The download and extraction path is the same code either way. What differs is who vetted the tarball before it got there.

The severity reasoning as published has two halves. The first is about who can deliver the crate. crates.io "implemented server-side checks to reject these kinds of packages years ago", so packages coming from the default registry can't contain this. The advisory says "Users relying on crates.io are not affected." The risk sits with people using alternate registries. There the recommendation is to "exercise care in which package they download, by only including trusted dependencies in their projects." The second half concerns what a dependency can already do. By design Cargo allows code execution at build time, through build scripts and procedural macros. So the working group treats a symlinked marker file as a more limited way to accomplish what a malicious build script or procedural macro can do anyway.

The zip bomb#

The other issue is the plainer of the two:

Cargo did not limit the amount of data extracted from compressed archives. An attacker could upload to an alternate registry a specially crafted package that extracts way more data than its size (also known as a "zip bomb").

Nothing clever. Cargo streamed the decompressor's output to disk and never asked how much output there had been. JFrog's entry puts it at CVSS 4.8 and calls it low, with the same alternate-registry caveat. crates.io enforces decompression size limits at upload time, and the client didn't enforce anything at download time. That's a recurring split. Registry operators build the check into the front door and the client ends up trusting the door.

Preventing it#

Put the marker somewhere the archive can't reach: a sibling directory, a lockfile store, a database row. Create it before extraction, so the archive's own entry collides with an existing regular file or gets rejected outright. Or open it with O_NOFOLLOW, so that following a symlink is an error rather than a feature.

marker written last extract every entry .cargo-ok is a link write ok into it two bytes leave the crate directory marker written first create .cargo-ok extract every entry entry collides
The second of the three fixes, drawn as an order of operations.

None of which is a criticism of the receipt. The receipt exists to make extraction safer, and the reasoning behind it holds up. Somebody thought about partial extraction, decided a marker file was the fix, and wrote it correctly. The marker just sat in the one place the archive could reach, two bytes wide, spelling ok.

The symlink write is CVE-2022-36113 and the zip bomb CVE-2022-36114, and both were present in every version of Cargo. Both were fixed in Rust 1.64, released 22 September 2022, eight days after the advisory went out. That build-script argument is also the published reason no point releases were issued. The Rust team put patches against 1.63.0 in the wg-security-response repository for anyone building their own toolchain.

Sources

  1. 1Security advisories for Cargo (CVE-2022-36113, CVE-2022-36114)blog.rust-lang.org
  2. 2Rust Cargo zip-bomb DoS (CVE-2022-36114)research.jfrog.com