Environment Variable and Runtime Hook Hijacking
- PATH hijacking on shared build agents
- LD_PRELOAD, BASH_ENV and $GITHUB_ENV injection
- NODE_OPTIONS, JAVA_TOOL_OPTIONS and Python .pth hooks
- DYLD_* and GIT_CONFIG_* on developer machines
Scenario
A platform team runs a handful of long-lived Jenkins agents shared by every project. The agents put /opt/build-tools/bin on PATH so teams can drop in helper scripts. That directory is writable by the jenkins user.
One team's build pulls a compromised package. Its install script runs as jenkins, like everything else on the agent.
- It writes a wrapper called
npminto/opt/build-tools/bin, ahead of/usr/binonPATH. The wrapper posts the environment to the attacker, then runs the real/usr/bin/npmwith the same arguments. - It drops a
.pthfile into the agent's shared Pythonsite-packages. Python runs it every time any interpreter starts. - The next day another team's release job runs
npm publishon the same agent. The wrapper capturesNPM_TOKEN,AWS_ACCESS_KEY_IDand everything else that job was given.
The same package lands on a developer's laptop. There it appends three lines to ~/.zshrc:
export NODE_OPTIONS="--require $HOME/.cache/node/telemetry.js"
export JAVA_TOOL_OPTIONS="-javaagent:$HOME/.m2/agent/metrics.jar"
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0="$HOME/.cache/git/fsm.sh"
Now every Node process loads telemetry.js first. Every JVM started from that shell loads a Java agent. Every git status runs fsm.sh. None of this needs root or touches a system binary. Builds pass and nothing looks wrong.
On GitHub Actions the door is different. Any step can write NAME=value lines to the file at $GITHUB_ENV, and every later step in the job inherits them. A step that writes LD_PRELOAD or BASH_ENV there, or writes untrusted input there, poisons the rest of the job.
Real-world precedent: On 24 March 2026 attackers published LiteLLM 1.82.7 and 1.82.8 to PyPI. Version 1.82.8 added litellm_init.pth, so the credential stealer ran at every Python start-up, whether or not LiteLLM was imported. From January to April 2021 a modified Codecov Bash Uploader sent the environment variables of customers' CI jobs to an attacker's server. GitHub disabled the set-env workflow command in 2020 and blocked NODE_OPTIONS from $GITHUB_ENV in 2023 for exactly these reasons.
Reconnaissance
Explanation
An attacker who lands code on a build agent or laptop wants code that runs often, runs as you, and survives. They look at what runs next, as which user, and with what environment.
They run env, walk every PATH entry for writable directories, and check /etc/ld.so.preload. They read shell start-up files, workflow files and Jenkinsfiles. They note which runtimes you use (node, java, python, git) and whether your build scripts call tools by bare name.
Insight
Shared, long-lived agents let one job's leftovers affect the next. Developer machines run more interpreters than almost any other computer, and each runtime has its own documented way to pre-load code. Few developers know them all.
Practical
Do the attacker's survey yourself, as the build user or as you:
echo "$PATH" | tr ':' '\n' | while read -r d; do
[ -d "$d" ] && [ -w "$d" ] && echo "WRITABLE: $d"
done
env | grep -E '^(LD_PRELOAD|LD_LIBRARY_PATH|BASH_ENV|ENV|NODE_OPTIONS|JAVA_TOOL_OPTIONS|_JAVA_OPTIONS|JDK_JAVA_OPTIONS|PYTHONPATH|DYLD_|GIT_SSH_COMMAND|GIT_CONFIG_)'
cat /etc/ld.so.preload 2>/dev/null
grep -nE 'NODE_OPTIONS|JAVA_TOOL_OPTIONS|PYTHONPATH|DYLD_|GIT_CONFIG_|GIT_SSH' \
~/.zshrc ~/.zprofile ~/.bashrc ~/.bash_profile ~/.profile 2>/dev/null
Also check workflow files for steps that write to $GITHUB_ENV or $GITHUB_PATH using issue titles, branch names, pull request bodies or artifact contents.
Tools/Techniques
env,find,ls -ldand the loop above.git config --show-origin --show-scope --listto see every Git setting and where it came from.- zizmor and actionlint to flag risky
$GITHUB_ENVwrites and template injection.
Metrics/Signal
PATHentries writable by the build user on each agent.- Workflows that write user-controlled data to
$GITHUB_ENV. - Runtime-hook variables set in developers' shells that nobody can explain.
Evaluate
Explanation
Work out which variables and files can change what your tools run, and who can set them.
Insight
The dangerous hooks fall into a few groups:
- Command lookup:
PATH. Bare command names resolve to the first match. - Dynamic linker:
LD_PRELOADandLD_LIBRARY_PATHload extra code into dynamically linked programs. Statically linked Go binaries ignore them, butgit,curl,pythonandnodeare usually dynamic. - Shell start-up: non-interactive
bashsources the file named inBASH_ENV. It does not read.bashrc. - Node.js:
NODE_OPTIONSaccepts--requireand--import, which load a module before your code. - JVM:
JAVA_TOOL_OPTIONSandJDK_JAVA_OPTIONSaccept-javaagent:. The JVM printsPicked up JAVA_TOOL_OPTIONS: ...to stderr. - Python:
PYTHONPATHcan shadow standard modules.sitecustomize.pyruns at start-up. Any line starting withimportin a.pthfile insite-packagesis executed, which is how the LiteLLM payload ran. - macOS:
DYLD_INSERT_LIBRARIESloads a library into a process. System Integrity Protection strips it only for Apple-protected binaries, and the hardened runtime ignores it unless entitled. Many Homebrew-built tools have neither. - Git:
GIT_SSH_COMMANDreplaces the SSH client.GIT_CONFIG_COUNTwithGIT_CONFIG_KEY_nandGIT_CONFIG_VALUE_ninjects any config key, includingcore.fsmonitor,core.sshCommandandcore.hooksPath.
Practical
- List every place the build environment is set: agent launch config, Jenkins node properties, systemd units, container image
ENV, workflowenv:blocks. Note who can change each. - Confirm whether agents are reused between jobs from different repositories or teams.
- Look for
.pthfiles withimportlines in everysite-packagesyou use:
python3 - <<'EOF'
import site, pathlib
for d in site.getsitepackages() + [site.getusersitepackages()]:
for p in pathlib.Path(d).glob("*.pth"):
for line in p.read_text(errors="ignore").splitlines():
if line.startswith(("import ", "import\t")):
print(f"{p}: {line[:120]}")
EOF
Tools/Techniques
systemctl show <agent-unit> -p Environmentanddocker inspect --format '{{.Config.Env}}' <image>.codesign -dv --verbose=4 "$(command -v node)" 2>&1 | grep -i runtimeto check for the hardened runtime on macOS.- osquery's
process_envstable to see the variables of running processes.
Metrics/Signal
- Agents that run jobs from more than one trust boundary.
.pthfiles withimportlines that do not belong to a package you recognise.
Fortify
Explanation
Start every build step from a known, minimal environment. Remove the writable directories and install scripts that let an attacker plant hooks in the first place.
Insight
Clearing variables inside your script is too late. By the time build.sh runs unset LD_PRELOAD, the shell has already sourced BASH_ENV and loaded any preload library. Clean the environment before the shell starts.
Practical
Launch builds with an empty environment and only the variables you name:
pipeline {
agent { label 'linux' }
stages {
stage('Build') {
steps {
withCredentials([string(credentialsId: 'npm-token', variable: 'NPM_TOKEN')]) {
sh '''
exec env -i \
PATH=/usr/bin:/bin \
HOME="$WORKSPACE" \
NPM_TOKEN="$NPM_TOKEN" \
/bin/bash --noprofile --norc ./build.sh
'''
}
}
}
}
}
env -i drops LD_PRELOAD, BASH_ENV, NODE_OPTIONS and JAVA_TOOL_OPTIONS. --noprofile --norc stops bash reading start-up files. It does not help with a .pth file already in site-packages, so keep that directory clean too.
On build agents:
- Make every
PATHdirectory owned by root and not writable by the build user. - Use absolute paths in release scripts, or check
command -v npmagainst an expected path. - Run each job on a fresh agent or container, so nothing one job writes survives to the next.
- In GitHub Actions, never write untrusted input to
$GITHUB_ENVor$GITHUB_PATH. Pass it through an intermediateenv:variable and quote it.
On developer machines:
- Keep install scripts off. npm 12 and pnpm 10 and later block dependency install scripts by default; approve the few you need with npm
allowScriptsor pnpmallowBuilds. On older npm, setignore-scripts=truein~/.npmrc. - Keep shell start-up files in a dotfiles repository and diff them against what is on disk.
- Use a virtual environment per project, so a poisoned
site-packagesreaches less. - Treat any
GIT_CONFIG_*variable in your shell as suspicious.
Tools/Techniques
env -iandbash --noprofile --norc.- Ephemeral agents: Kubernetes pod agents for Jenkins, GitHub-hosted runners, or self-hosted runners with
--ephemeral. - npm
allowScripts, pnpmallowBuilds, BuntrustedDependencies. uvorvenvfor isolated Python environments; chezmoi or a plain dotfiles repository.
Metrics/Signal
- Release jobs that start from
env -ior a fresh container. - Writable
PATHdirectories on agents (target: zero). - Developers with install scripts blocked by default.
Limit
Explanation
If a hook does run, make sure it finds as little as possible, valid for as short a time as possible.
Insight
Codecov's attackers and the LiteLLM payload both harvested whatever the environment and home directory held. A job or laptop holding only short-lived, narrowly scoped tokens gives them very little.
Practical
- Give each CI job only the secrets it uses. Do not load the whole credential set into every stage.
- Prefer OIDC federation to cloud providers over static
AWS_ACCESS_KEY_IDvalues. - Use npm and PyPI trusted publishing, so no long-lived publish token exists to steal.
- Keep release jobs on dedicated agents that never run untrusted pull request builds.
- Restrict agent egress to the registries and services the build needs.
- On laptops, do not export secrets in shell start-up files. Inject them per command with a secrets manager CLI.
- Use a hardware security key for SSH and Git signing, so a hook cannot copy the key.
Tools/Techniques
- GitHub Actions OIDC with AWS, Azure or GCP.
op run,doppler runoraws-vault execto give secrets only to the process that needs them.- FIDO2 keys for SSH (
ed25519-sk).
Metrics/Signal
- Static cloud keys present in CI environments.
- Long-lived tokens stored in developer home directories.
Expose
Explanation
Detect new files in tool and site-packages directories, and unexpected hook variables, before or during a build.
Insight
A wrapper must be written somewhere on PATH. A hook must appear in the environment, a start-up file or site-packages. All are easy to see if you look.
Practical
Run a preflight step that fails the build on surprises. It cannot stop code that BASH_ENV already ran, so treat it as detection and keep env -i as prevention.
#!/bin/bash
set -euo pipefail
for v in LD_PRELOAD LD_LIBRARY_PATH BASH_ENV ENV NODE_OPTIONS JAVA_TOOL_OPTIONS; do
if [ -n "${!v:-}" ]; then
echo "Unexpected $v=${!v}" >&2
exit 1
fi
done
if [ -s /etc/ld.so.preload ]; then
echo "/etc/ld.so.preload is not empty" >&2
exit 1
fi
for tool in npm git python3; do
echo "$tool -> $(command -v "$tool")"
done
On agents with Falco, alert on writes into the tool directories:
- list: build_tool_dirs
items: [/opt/build-tools/bin, /usr/local/bin]
- rule: Write to build tool directory
desc: A process wrote into a directory on the build agent's PATH
condition: >
open_write and fd.name pmatch (build_tool_dirs)
and not proc.name in (dpkg, apt, apt-get, rpm, dnf, yum)
output: "Write to PATH dir (file=%fd.name cmd=%proc.cmdline user=%user.name)"
priority: WARNING
Also grep CI logs for Picked up JAVA_TOOL_OPTIONS; it should only show values you set. On laptops, alert when shell start-up files change outside a dotfiles sync, and flag node processes with --require pointing into hidden directories.
Tools/Techniques
- Falco or auditd on
PATHdirectories,site-packagesand/etc/ld.so.preload. - osquery
process_envsandfile_events, or your EDR's process telemetry. - Egress logs showing agents posting to unknown hosts.
Metrics/Signal
- Writes to tool directories or
site-packagesoutside package-manager runs. - Builds failed by the preflight check, and their root cause.
Exercise
Explanation
Plant harmless hooks on a disposable agent and in a throwaway account, then see what notices.
Insight
A wrapper that passes its arguments through keeps builds green. Teams believe their agents are clean because nothing failed.
Practical
-
On an isolated test agent, create
/opt/build-tools/bin/npmthat writes variable names (never values) to a local file, then runs the realnpm:#!/bin/sh env | cut -d= -f1 > /tmp/npm-wrapper-canary.txt exec /usr/bin/npm "$@" -
Run a normal pipeline. If the canary file appears, your
PATHhardening has a gap. Repeat with theenv -ilaunch and confirm the wrapper is not reached. -
In a throwaway user account, create
~/.cache/canary.jscontainingconsole.error("CANARY: preload ran"). SetNODE_OPTIONS="--require $HOME/.cache/canary.js"and runnode -e 1. -
In a fresh virtual environment, add
canary.pthcontainingimport sys; print("CANARY: pth ran", file=sys.stderr)and startpython. -
Check whether Falco, file integrity monitoring or your preflight raised an alert for each. Then remove everything.
Tools/Techniques
- A disposable agent VM, container or user account.
- Canary messages and variable names only. No network calls, no real secrets.
Metrics/Signal
- Which hooks were reached before and after hardening.
- Time from planting each hook to the first alert.