VYPR
Unrated severityNVD Advisory· Published May 25, 2026

Crates in third party registries can override the cached source of other crates

CVE-2026-5223

Description

Cargo incorrectly handled symlinks inside of crate tarballs downloaded from third-party registries, allowing a malicious crate to override the source code of another crate from the same registry. The severity of the vulnerability is medium for users of third-party registries. Users of crates.io are not affected, as crates.io forbids uploading crates containing any symlink.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Cargo in Rust before 1.96.0 allows malicious crates from third-party registries to override another crate's cached source via crafted symlinks in tarballs.

Vulnerability

Cargo incorrectly handled symlinks inside crate tarballs downloaded from third-party registries. When building a crate, Cargo extracts its source code into a local cache under ~/.cargo and reuses it for future builds. A malicious crate can craft a tarball that extracts files one level below its own cache directory, using symlinks to overwrite the cached source of another crate from the same registry [1][2]. All versions of Cargo shipped before Rust 1.96.0 are affected. Users of crates.io are not impacted because crates.io forbids uploading crates containing any symlink [1][2].

Exploitation

An attacker needs to publish a malicious crate containing carefully crafted symlinks to a third-party registry that does not reject symlinks in tarballs. When a victim builds a project that depends on that malicious crate (or whose dependency tree includes it), Cargo extracts the tarball and the crafted symlinks cause files to be written into the cache directory of another crate from the same registry [1][2]. No additional user interaction beyond a normal build is required; the build process automatically fetches and extracts dependencies.

Impact

A successful attack overrides the cached source code of another crate from the same third-party registry. When the victim later builds a project that depends on the overridden crate, Cargo uses the modified source, leading to arbitrary code execution in the context of the build process [1][2]. This can compromise the integrity of the compiled software and potentially leak sensitive information or escalate privileges depending on the build environment.

Mitigation

Rust 1.96.0, released on May 28, 2026, updates Cargo to reject extracting any symlink within crate tarballs, regardless of registry origin [1][2]. Users unable to upgrade should audit the contents of their registry for the presence of symlinks and configure their registry to reject such entries if supported [1]. The fix was implemented in pull request #17031 on the cargo repository [3].

AI Insight generated on May 25, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

1

Patches

1
03cb632e2473

Fix CVE-2026-5222 and CVE-2026-5223 (#17031)

https://github.com/rust-lang/cargoWeihang LoMay 25, 2026via nvd-ref
4 files changed · +57 43
  • src/cargo/sources/git/source.rs+7 0 modified
    @@ -501,6 +501,13 @@ mod test {
             assert_eq!(ident1, ident2);
         }
     
    +    #[test]
    +    fn test_canonicalize_idents_does_not_strip_dot_git_for_sparse() {
    +        let ident1 = ident(&src("sparse+https://crates.io/fake-registry"));
    +        let ident2 = ident(&src("sparse+https://crates.io/fake-registry.git"));
    +        assert_ne!(ident1, ident2);
    +    }
    +
         fn src(s: &str) -> SourceId {
             SourceId::for_git(&s.into_url().unwrap(), GitReference::DefaultBranch).unwrap()
         }
    
  • src/cargo/sources/registry/mod.rs+9 1 modified
    @@ -198,7 +198,7 @@ use flate2::read::GzDecoder;
     use futures::FutureExt as _;
     use serde::Deserialize;
     use serde::Serialize;
    -use tar::Archive;
    +use tar::{Archive, EntryType};
     use tracing::debug;
     
     use crate::core::dependency::Dependency;
    @@ -1019,6 +1019,14 @@ fn unpack(
                 )
             }
     
    +        // Prevent unpacking symlinks and other unexpected entry types
    +        match entry.header().entry_type() {
    +            EntryType::Regular | EntryType::Directory => {}
    +            t => anyhow::bail!(
    +                "invalid tarball downloaded, contains an entry at {entry_path:?} with invalid type {t:?}",
    +            ),
    +        }
    +
             // Prevent unpacking the lockfile from the crate itself.
             if entry_path
                 .file_name()
    
  • src/cargo/util/canonical_url.rs+24 20 modified
    @@ -33,27 +33,31 @@ impl CanonicalUrl {
                 url.path_segments_mut().unwrap().pop_if_empty();
             }
     
    -        // For GitHub URLs specifically, just lower-case everything. GitHub
    -        // treats both the same, but they hash differently, and we're gonna be
    -        // hashing them. This wants a more general solution, and also we're
    -        // almost certainly not using the same case conversion rules that GitHub
    -        // does. (See issue #84)
    -        if url.host_str() == Some("github.com") {
    -            url = format!("https{}", &url[url::Position::AfterScheme..])
    -                .parse()
    -                .unwrap();
    -            let path = url.path().to_lowercase();
    -            url.set_path(&path);
    -        }
    +        // Perform further canonicalization specific to git registries, which
    +        // do not contain a `+` specifier.
    +        if !url.scheme().contains('+') {
    +            // For GitHub URLs specifically, just lower-case everything. GitHub
    +            // treats both the same, but they hash differently, and we're gonna be
    +            // hashing them. This wants a more general solution, and also we're
    +            // almost certainly not using the same case conversion rules that GitHub
    +            // does. (See issue #84)
    +            if url.host_str() == Some("github.com") {
    +                url = format!("https{}", &url[url::Position::AfterScheme..])
    +                    .parse()
    +                    .unwrap();
    +                let path = url.path().to_lowercase();
    +                url.set_path(&path);
    +            }
     
    -        // Repos can generally be accessed with or without `.git` extension.
    -        let needs_chopping = url.path().ends_with(".git");
    -        if needs_chopping {
    -            let last = {
    -                let last = url.path_segments().unwrap().next_back().unwrap();
    -                last[..last.len() - 4].to_owned()
    -            };
    -            url.path_segments_mut().unwrap().pop().push(&last);
    +            // Repos can generally be accessed with or without `.git` extension.
    +            let needs_chopping = url.path().ends_with(".git");
    +            if needs_chopping {
    +                let last = {
    +                    let last = url.path_segments().unwrap().next_back().unwrap();
    +                    last[..last.len() - 4].to_owned()
    +                };
    +                url.path_segments_mut().unwrap().pop().push(&last);
    +            }
             }
     
             Ok(CanonicalUrl(url))
    
  • tests/testsuite/registry.rs+17 22 modified
    @@ -3276,8 +3276,7 @@ fn package_lock_inside_package_is_overwritten() {
     }
     
     #[cargo_test]
    -fn package_lock_as_a_symlink_inside_package_is_overwritten() {
    -    let registry = registry::init();
    +fn package_lock_as_a_symlink_inside_package_is_invalid() {
         let p = project()
             .file(
                 "Cargo.toml",
    @@ -3300,21 +3299,23 @@ fn package_lock_as_a_symlink_inside_package_is_overwritten() {
             .symlink(".cargo-ok", "src/lib.rs")
             .publish();
     
    -    p.cargo("check").run();
    +    p.cargo("check")
    +        .with_status(101)
    +        .with_stderr_data(str![[r#"
    +[UPDATING] `dummy-registry` index
    +[LOCKING] 1 package to latest compatible version
    +[DOWNLOADING] crates ...
    +[DOWNLOADED] bar v0.0.1 (registry `dummy-registry`)
    +[ERROR] failed to download replaced source registry `crates-io`
     
    -    let id = SourceId::for_registry(registry.index_url()).unwrap();
    -    let hash = cargo::util::hex::short_hash(&id);
    -    let pkg_root = paths::cargo_home()
    -        .join("registry")
    -        .join("src")
    -        .join(format!("-{}", hash))
    -        .join("bar-0.0.1");
    -    let ok = pkg_root.join(".cargo-ok");
    -    let librs = pkg_root.join("src/lib.rs");
    +Caused by:
    +  failed to unpack package `bar v0.0.1 (registry `dummy-registry`)`
     
    -    // Is correctly overwritten and doesn't affect the file linked to
    -    assert_eq!(ok.metadata().unwrap().len(), 7);
    -    assert_eq!(fs::read_to_string(librs).unwrap(), "pub fn f() {}");
    +Caused by:
    +  invalid tarball downloaded, contains an entry at "bar-0.0.1/.cargo-ok" with invalid type Symlink
    +
    +"#]])
    +        .run();
     }
     
     #[cargo_test]
    @@ -4770,13 +4771,7 @@ Caused by:
       failed to unpack package `bar v1.0.0 (registry `dummy-registry`)`
     
     Caused by:
    -  failed to unpack entry at `bar-1.0.0/smuggled`
    -
    -Caused by:
    -  failed to unpack `[ROOT]/home/.cargo/registry/src/-[HASH]/bar-1.0.0/smuggled`
    -
    -Caused by:
    -  [..] when creating dir [ROOT]/home/.cargo/registry/src/-[HASH]/bar-1.0.0/smuggled
    +  invalid tarball downloaded, contains an entry at "bar-1.0.0/smuggled" with invalid type Symlink
     
     "#]])
             .run();
    

Vulnerability mechanics

Root cause

"Missing validation of tarball entry types allows symlinks to be unpacked and followed during crate extraction."

Attack vector

An attacker publishes a crate to a third-party registry containing a symlink entry in the tarball. When Cargo downloads and extracts the crate, the symlink is followed, allowing the attacker to overwrite files outside the intended extraction directory — specifically, the source code of another crate from the same registry. The attacker must have the ability to publish a crate containing symlinks to a registry that does not forbid them (unlike crates.io, which does). The payload is a crafted tarball where a symlink entry points to a path belonging to a different crate.

Affected code

The vulnerability is in the `unpack` function in `src/cargo/sources/registry/mod.rs`. The code previously did not check the entry type when extracting tarball entries, allowing symlinks to be unpacked. The fix adds a check using `entry.header().entry_type()` that rejects any entry that is not a regular file or directory [patch_id=2473736].

What the fix does

The patch adds an entry type check in the `unpack` function that rejects any tarball entry that is not a regular file or directory [patch_id=2473736]. Before extraction, the code now calls `entry.header().entry_type()` and bails with an error if the type is a symlink or any other non-regular, non-directory type. This prevents malicious symlinks from being followed during extraction. The test `package_lock_as_a_symlink_inside_package_is_invalid` was updated to expect this new error message and a non-zero exit code, confirming the fix.

Preconditions

  • configThe attacker must be able to publish a crate containing symlinks to a third-party registry that does not forbid symlinks.
  • inputThe victim must run `cargo build` or `cargo check` on a project that depends on the malicious crate.

Generated on May 25, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.