Skip to content

CommandExt::exec writes to the global environment pointer while holding only a read lock #156951

Description

@qwaz

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::execCommand::execcapture_envenv_read_lockdo_execenviron replacement → execvp

  • CommandExt::exec: public Unix API.

    fn exec(&mut self) -> io::Error {
    // NOTE: This may *not* be safe to call after `libc::fork`, because it
    // may allocate. That may be worth fixing at some point in the future.
    self.as_inner_mut().exec(sys::process::Stdio::Inherit)
    }

  • Command::exec: captures the command environment, takes only env_read_lock(), then calls do_exec.

    pub fn exec(&mut self, default: Stdio) -> io::Error {
    let envp = self.capture_env();
    if self.saw_nul() {
    return io::const_error!(ErrorKind::InvalidInput, "nul byte found in provided data");
    }
    match self.setup_io(default, true) {
    Ok((_, theirs)) => {
    unsafe {
    // Similar to when forking, we want to ensure that access to
    // the environment is synchronized, so make sure to grab the
    // environment lock before we try to exec.
    let _lock = sys::env::env_read_lock();
    let Err(e) = self.do_exec(theirs, envp.as_ref());
    e
    }
    }
    Err(e) => e,
    }
    }

  • capture_env: returns Some(CStringArray) when the command has environment changes.

    pub fn capture_env(&mut self) -> Option<CStringArray> {
    let maybe_env = self.env.capture_if_changed();
    maybe_env.map(|env| construct_envp(env, &mut self.saw_nul))
    }

  • do_exec: saves the old environ, overwrites it with envp.as_ptr(), calls execvp, and restores on error.

    // Although we're performing an exec here we may also return with an
    // error from this function (without actually exec'ing) in which case we
    // want to be sure to restore the global environment back to what it
    // once was, ensuring that our temporary override, when free'd, doesn't
    // corrupt our process's environment.
    let mut _reset = None;
    if let Some(envp) = maybe_envp {
    struct Reset(*const *const libc::c_char);
    impl Drop for Reset {
    fn drop(&mut self) {
    unsafe {
    *sys::env::environ() = self.0;
    }
    }
    }
    _reset = Some(Reset(*sys::env::environ()));
    *sys::env::environ() = envp.as_ptr();
    }
    libc::execvp(self.get_program_cstr().as_ptr(), self.get_argv().as_ptr());
    Err(io::Error::last_os_error())

  • Environment reads also take the shared read lock, so they can run during exec's global pointer replacement.

    pub fn env() -> Env {
    unsafe {
    let _guard = env_read_lock();
    let mut environ = *environ();
    let mut result = Vec::new();
    if !environ.is_null() {
    while !(*environ).is_null() {
    if let Some(key_value) = parse(CStr::from_ptr(*environ).to_bytes()) {
    result.push(key_value);
    }
    environ = environ.add(1);
    }
    }
    return Env::new(result);
    }

    pub fn getenv(k: &OsStr) -> Option<OsString> {
    // environment variables with a nul byte can't be set, so their value is
    // always None as well
    run_with_cstr(k.as_bytes(), &|k| {
    let _guard = env_read_lock();
    let v = unsafe { libc::getenv(k.as_ptr()) } as *const libc::c_char;
    if v.is_null() {
    Ok(None)
    } else {
    // SAFETY: `v` cannot be mutated while executing this line since we've a read lock
    let bytes = unsafe { CStr::from_ptr(v) }.to_bytes().to_vec();
    Ok(Some(OsStringExt::from_vec(bytes)))
    }
    })
    .ok()
    .flatten()
    }

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.

#46775
#55359
#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.

#81850
55ca27f

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:

  1. Unix std::os::unix::process::CommandExt::exec.
  2. Explicit command environment changes, so capture_env() returns Some(envp).
  3. Another live thread during the exec call.
  4. For environment disclosure: a sibling thread reads the environment during the temporary environ replacement.
  5. For memory unsafety: concurrent failed exec calls interleave so one reset guard restores another thread's temporary envp after 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

A-processArea: `std::process` and `std::env`C-bugCategory: This is a bug.I-unsoundIssue: A soundness hole (worst kind of bug), see: https://en.wikipedia.org/wiki/SoundnessP-highHigh priorityT-libsRelevant to the library team, which will review and decide on the PR/issue.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions