`CommandExt::exec` writes to the global environment pointer while holding only a read lock
@asder8215 is already working on this.
Since May 27, 2026.
- Dominant language
- Rust
- Stars
- 119k
- Forks
- 16.1k
- PR merge metrics
- PR metrics pending
Description
Report history and disclosure status
We initially reported this to the Rust Security Response Team. After internal discussion, the team decided not to treat it as a security issue and asked us to open a public issue instead. We respect that decision and are filing this issue at their request.
For context, the team shared the following rationale:
For the segfault that happens when racing two concurrent exec() calls, that is indeed happening and it is unsound, but we do not think it warrants a security announcement. Racing exec() calls itself is arguably broken behavior in the user's code, as it is not deterministic which call will override the current process.
For the environment variables being observable by other threads, that is actually not limited to environment variables, but rather all of the process state configured by the exec call. The documentation currently states this:
The process may be in a “broken state” if this function returns in error. For example the working directory, environment variables, signal handling settings, various user/group information, or aspects of stdio file descriptors may have changed. If a “transactional spawn” is required to gracefully handle errors it is recommended to use the cross-platform spawn instead.
We will update the documentation to state the process will be in a broken state from the moment the exec call beings, as that is that more accurate.
Accordingly, please treat this as a regular soundness/correctness bug report rather than a security advisory.
Bug description
Command lets callers set command-specific environment variables with env, envs, env_remove, and env_clear. On Unix, CommandExt::exec handles this by building an envp array and temporarily replacing the current process's global environ pointer before calling execvp. This pointer update is a global write, but exec only holds Rust's environment read lock.
That allows other threads to run while environ points at the temporary command environment. A sibling thread can read environment variables meant only for the command being executed. Concurrent exec calls can also race with each other, so one command may run with another command's environment, or a failed exec may restore environ to a temporary envp that has already been freed. The PoCs below demonstrate environment disclosure and a safe-Rust-triggered segmentation fault.
Data flow trace
CommandExt::exec → Command::exec → capture_env → env_read_lock → do_exec → environ replacement → execvp
-
CommandExt::exec: public Unix API. -
Command::exec: captures the command environment, takes onlyenv_read_lock(), then callsdo_exec. -
capture_env: returnsSome(CStringArray)when the command has environment changes. -
do_exec: saves the oldenviron, overwrites it withenvp.as_ptr(), callsexecvp, and restores on error. -
Environment reads also take the shared read lock, so they can run during
exec's global pointer replacement.https://github.com/rust-lang/rust/blob/59807616e1fa2540724bfbac14d7976d7e4a3860/library/std/src/sys/env/unix.rs#L58-L72
https://github.com/rust-lang/rust/blob/59807616e1fa2540724bfbac14d7976d7e4a3860/library/std/src/sys/env/unix.rs#L92-L110
Prior issues
This appears related to an older fix that became incomplete after the environment lock changed.
rust-lang/rust#46775 reported that CommandExt::exec was unsafe because it assigned to environ. rust-lang/rust#55359 fixed this by avoiding global environment mutation. rust-lang/rust#55939 replaced that with a restore guard and an environment lock. At that time, the lock was an exclusive mutex (sys::os::env_lock()), so exec excluded environment readers.
https://github.com/rust-lang/rust/issues/46775
https://github.com/rust-lang/rust/pull/55359
https://github.com/rust-lang/rust/pull/55939
The environment lock was later changed to an RwLock to optimize the common read case. rust-lang/rust#81850 introduced the read/write split for environment access. After that change, exec uses the read side of the lock, while still writing the global environ pointer. This re-opens the race for CommandExt::exec.
https://github.com/rust-lang/rust/pull/81850
https://github.com/rust-lang/rust/commit/55ca27faa78434d81d3f59535c96bbb2a08f0d5c
Demonstration
The PoCs below use only safe Rust (#![deny(unsafe_code)]).
exec_race_leak.rs races failed CommandExt::exec with std::env::var_os. The reader thread observes a command-specific secret environment variable.
exec_race_segfault.rs races two failed CommandExt::exec calls. A later safe std::env::vars_os() traversal can crash because environ points at freed storage.
// exec_race_leak.rs
#![deny(unsafe_code)]
use std::env;
use std::fs::File;
use std::io::{self, Read};
use std::os::unix::process::CommandExt;
use std::process::Command;
use std::thread;
const MISSING_PROGRAM: &str = "HIGH_PRIVILEGE_PROGRAM_WITH_SECRET";
const SECRET_KEY: &str = "RUST_ENV_SECRET";
fn main() {
println!("racing failed CommandExt::exec against std::env::var_os");
thread::scope(|scope| {
scope.spawn(|| exec_loop());
scope.spawn(|| read_loop());
});
}
fn exec_loop() {
let secret = random_secret_value().expect("failed to read random secret");
loop {
let mut command = Command::new(MISSING_PROGRAM);
// The program intentionally does not exist. exec temporarily installs
// this command-specific env before execvp fails and restores environ.
let error = command.env(SECRET_KEY, &secret).exec();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
}
fn read_loop() {
loop {
// This should not see the command-specific env, but it can race with
// exec's transient process-global environ replacement.
if let Some(secret) = env::var_os(SECRET_KEY) {
println!("leaked {SECRET_KEY}={}", secret.display());
std::process::exit(0);
}
}
}
fn random_secret_value() -> io::Result<String> {
let mut bytes = [0; 16];
File::open("/dev/urandom")?.read_exact(&mut bytes)?;
let mut secret = String::from("secret-");
for byte in bytes {
use std::fmt::Write as _;
write!(&mut secret, "{byte:02x}").expect("writing to String cannot fail");
}
Ok(secret)
}
// exec_race_segfault.rs
#![deny(unsafe_code)]
use std::env;
use std::io;
use std::os::unix::process::CommandExt;
use std::process::Command;
use std::thread;
const MISSING_PROGRAM: &str = "missing_program";
fn main() {
println!("racing two failed CommandExt::exec calls");
println!("a later safe std::env read should crash if environ points at freed storage");
loop {
thread::scope(|scope| {
scope.spawn(|| failed_exec());
scope.spawn(|| failed_exec());
});
// If the race left environ pointing at freed storage, this safe env
// read may traverse allocator junk and crash before returning.
println!(
"let's read the number of env variables: {}",
env::vars_os().count()
);
}
}
fn failed_exec() {
let mut command = Command::new(MISSING_PROGRAM);
let error = command.env_clear().env("foo", "bar").exec();
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
Output
$ cargo run --bin exec_race_leak
racing failed CommandExt::exec against std::env::var_os
leaked RUST_ENV_SECRET=secret-1b4d19815ef01a846010c85835465507
$ cargo run --bin exec_race_segfault
racing two failed CommandExt::exec calls
a later safe std::env read should crash if environ points at freed storage
Segmentation fault (core dumped)
Environment
$ rustc --version --verbose
rustc 1.95.0 (59807616e 2026-04-14)
commit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860
host: x86_64-unknown-linux-gnu
release: 1.95.0
$ lsb_release -a
Distributor ID: Ubuntu
Description: Ubuntu 26.04 LTS
Release: 26.04
Codename: resolute
I also verified that both PoCs trigger on nightly:
$ rustc +nightly --version --verbose
rustc 1.97.0-nightly (8b03437a8 2026-05-12)
binary: rustc
commit-hash: 8b03437a8ffc8f8b01e62ef5fce82a37ada09b12
commit-date: 2026-05-12
host: x86_64-unknown-linux-gnu
Impact analysis
The exact trigger pattern should be rare. Most Rust programs use Command::spawn, which is not affected by this issue. This bug matters when a Unix process calls exec() directly while other threads are still alive. That should be uncommon, but it is not impossible in async runtimes, telemetry/logging setups, plugin hosts, shells, or process supervisors.
Triggering the demonstrated behavior requires:
- Unix
std::os::unix::process::CommandExt::exec. - Explicit command environment changes, so
capture_env()returnsSome(envp). - Another live thread during the
execcall. - For environment disclosure: a sibling thread reads the environment during the temporary
environreplacement. - For memory unsafety: concurrent failed
execcalls interleave so one reset guard restores another thread's temporaryenvpafter it is dropped.
Downstream review
I did a light, non-exhaustive downstream search to see whether this pattern appears in real code. Most reviewed hits did not look affected because they do not use exec in threaded environments.
For example, sudo-rs does call exec with a custom command environment, but it forks before exec_command, and the code documents that there are no other threads at those fork points.
https://github.com/trifectatechfoundation/sudo-rs/blob/c120e768b4d513174493ec180a4587d16f58d57c/src/exec/mod.rs#L102-L103
https://github.com/trifectatechfoundation/sudo-rs/blob/c120e768b4d513174493ec180a4587d16f58d57c/src/exec/no_pty.rs#L53
https://github.com/trifectatechfoundation/sudo-rs/blob/c120e768b4d513174493ec180a4587d16f58d57c/src/exec/use_pty/monitor.rs#L91
https://github.com/trifectatechfoundation/sudo-rs/blob/c120e768b4d513174493ec180a4587d16f58d57c/src/exec/mod.rs#L258-L268
The potentially interesting affected targets were Nushell and Tangram. The analysis below is based on code reading; the full downstream impact has not been verified.
Nushell's exec command builds a command-specific environment with env_clear() / envs(...) and then calls command.exec(). It also has a job spawn feature that starts a background thread evaluating a Nushell closure, which may race with the foreground exec. If the Nushell closure reads the process environment while exec is executing, it may hit the Rust standard library race.
https://github.com/nushell/nushell/blob/7f4d8321256a5143d5d8f5415e4ed67a5d484e7c/crates/nu-command/src/system/exec.rs#L80-L105
https://github.com/nushell/nushell/blob/7f4d8321256a5143d5d8f5415e4ed67a5d484e7c/crates/nu-command/src/experimental/job_spawn.rs#L95-L117
Tangram is another potentially impacted candidate from this review. It prepares a command environment, calls env_clear() / envs(&prepared.env), then calls command.exec(). The CLI also initializes telemetry/tracing before command execution. A possible risk is that telemetry or logging running in another thread could observe and record the temporary exec environment.
https://github.com/tangramdotdev/tangram/blob/223d98fad4c3f13803638aecb4ba89aa0f707cb3/packages/clients/rust/src/process/exec.rs#L33-L49
https://github.com/tangramdotdev/tangram/blob/223d98fad4c3f13803638aecb4ba89aa0f707cb3/packages/cli/src/main.rs#L525-L534
The initial discovery was made by AI. All technical claims have been reviewed and revised by human experts.
Reporting on behalf of Autonomous Code Security (ACS) team at Microsoft.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.