Blog

The Key Hunter: How the Shai-Hulud Leak Turned CI/CD Secret Theft Into a Commodity Tool.

Shai-Hulud
Uncategorized

The Key Hunter: How the Shai-Hulud Leak Turned CI/CD Secret Theft Into a Commodity Tool.

The server is up, nginx is serving requests, fail2ban is blocking bots, nftables has everything locked down. GitHub has a self-hosted runner configured — one-click deploys. Convenient. And it’s exactly through that runner that someone reads your CI/CD process memory, pulls out SSH keys, tokens, and environment secrets, packs everything into an encrypted archive — and walks away. Not a single line in your logs.

This is not a hypothetical. This is what Shai-Hulud does — and now its source code is publicly available.

WHO SHOULD READ THIS

This article is relevant if your server has at least one of the following:

— a self-hosted GitHub Actions runner
— developers with direct SSH access to the server
— deployments via CI/CD with secrets in environment variables
— deploy SSH keys in ~/.ssh/ on the server

If you run a clean production server — nginx, database, fail2ban, no runner, no direct developer access — the immediate threat is minimal. But the audit section is still worth reading: SSH key exposure and secret leakage apply to any server.

WHAT THIS MEANS FOR SYSADMINS SPECIFICALLY

Shai-Hulud is primarily a tool for attacking developers. But several of its capabilities hit the server side directly.

SSH keys. The framework collects everything from ~/.ssh/ — including deploy keys that grant access to production. If a developer’s workstation is compromised and that developer has a key to your server, the server is at risk too. Rotating SSH keys across all servers after an incident is mandatory.

Persistence via systemd. On Linux, Shai-Hulud establishes persistence through a user-level systemd unit in ~/.config/systemd/user/. No root required, invisible in a plain systemctl status. If a compromised account is active on the server, the unit runs quietly and does its job.

Deadman switch. Revoking the GitHub token without first disabling persistence triggers deletion of the user’s home directory. If the runner runs under a dedicated user, losing that home directory means losing configs, keys, and history.

Self-hosted runner as the primary entry point. If the runner runs on the same server as production, compromising the runner means compromising the entire environment: variables, secrets, database access, internal services.

WHAT HAPPENED

TeamPCP — the group behind a series of attacks on npm and PyPI — published the source code of their primary tool on GitHub. The repository was taken down quickly, but forks had already spread across the network. OX Security and Datadog Security Labs saved a copy and analyzed it in detail.

The repository was deliberately crafted: all commits are dated January 1, 2099, signed by the TeamPCP_OSS account. A classic anti-forensics move — automated monitoring systems get confused by the date, git log looks like garbage. Third-party contributors had already started appearing, with one proposing FreeBSD support.

Shai-Hulud used to be one group’s tool with defined targets. Now its architecture and mechanics are available for anyone to study and copy.

SCOPE AND THREAT STATUS

May 15, 2026. The repository appeared on GitHub and vanished quickly — but the forks are already out. Code cannot be recalled.

How many npm packages are actually infected — unknown. OX Security and Datadog analyzed the framework itself, not the downstream impact. Nobody has the full picture.

The domain git-tanstack[.]com was active at the time of analysis. Current status — check yourself: host git-tanstack.com or through a threat intelligence feed.

The key shift: previously this tool belonged to one group with defined targets. Now its logic can be copied by anyone — and judging by fork activity, people already are. No patch is coming: this is not a CVE with a fix, it’s a tool. Defense is purely preventive.

WHAT IT IS

Not a collection of scripts — a full modular TypeScript/Bun framework. The architecture is split into independent components: loaders, secret collectors, a delivery dispatcher, exfiltration channels, and propagation modules. Parts can be swapped without rewriting everything — this is engineered software, not a weekend project.

What it hunts on a compromised machine:

— SSH keys (everything it finds in ~/.ssh/)
— .env files
— GitHub and npm tokens
— AWS credentials (~/.aws/credentials)
— Kubernetes configs (~/.kube/config)
— HashiCorp Vault secrets
— Docker credentials
— GCP and Azure cloud accounts
— Crypto wallets
— Messenger data
— AI development tool configs

THE CORE TECHNIQUE: READING MEMORY VIA /proc

This is the key point for understanding why Shai-Hulud is particularly dangerous on servers running a self-hosted GitHub Actions runner.

GitHub masks secrets in logs: if a token appears in stdout, it gets replaced with asterisks. But masking only operates at the log level. Process memory is untouched.

Runner.Worker is a regular Linux process with a PID. Through /proc/[pid]/mem, a process running as the same UID can read its memory directly. Shai-Hulud runs under the same user as the runner — say, github-runner — finds the PID of Runner.Worker, reads memory, and extracts all secrets before any masking has a chance to fire. The token never appeared in logs, but it’s already gone.

This is not a kernel exploit. It is a legitimate Linux mechanism being abused. That is precisely why standard tooling struggles to detect it.

This technique only applies if you have a self-hosted GitHub Actions runner on your Ubuntu server. No runner — this vector doesn’t affect you.

PERSISTENCE VIA SYSTEMD

On Linux, Shai-Hulud establishes persistence through a user-level systemd unit. Not a system unit — a user unit, in ~/.config/systemd/user/. This matters: no root required to create it, invisible in plain systemctl status without the --user flag, and it activates on user login.

# Check user-level units for the current user
systemctl --user list-units --type=service

# Inspect unit files directly (no sudo needed)
ls -la ~/.config/systemd/user/

# Check timers as well
systemctl --user list-timers

# Check lingering — indicates units are configured to survive without an active session
loginctl show-user $USER | grep Linger

DEADMAN SWITCH — THE MOST DANGEROUS PART

If Shai-Hulud has entrenched itself via the gh-token-monitor component, revoking the GitHub token without preparation triggers deletion of the user’s home directory.

The mechanism is straightforward: the component monitors token validity. The moment the token stops working — a destructive command executes. The bet is that the admin noticed a compromise and went straight for the tokens.

Correct order of operations when compromise is suspected:

  1. First — find and disable persistence:
# Stop and disable suspicious user-level units
systemctl --user stop [unit-name]
systemctl --user disable [unit-name]

# Remove the unit file
rm ~/.config/systemd/user/[unit-name].service
systemctl --user daemon-reload
  1. Only after that — revoke tokens and rotate keys.

Do it the other way around — revoke the token first, find persistence second — and you risk losing the home directory along with all local keys, configs, and data.

HOW IT EXFILTRATES DATA

Three exfiltration channels — each one activates when the previous is unavailable.

Primary: data is compressed, encrypted with AES-256-GCM, the key is wrapped with RSA-4096, and everything is sent to git-tanstack[.]com. The domain is deliberately chosen to resemble legitimate GitHub infrastructure.

Fallback: if the C2 is unreachable, the framework searches GitHub for specially signed commits containing the backup server address. The address is not in a config — it’s in commit metadata. Standard monitoring doesn’t catch this.

Emergency: no primary, no fallback — stolen data goes into encrypted JSON files in publicly created GitHub repositories via a stolen token. The data sits in the open but is encrypted. The attacker retrieves it later.

HOW IT SPREADS

After compromising one repository, Shai-Hulud moves outward — infecting adjacent projects and npm packages that depend on them.

Repository infection. Shai-Hulud drops two files into repositories it has access to: .vscode/tasks.json and .claude/setup.mjs. The first runs automatically when the project is opened in VSCode; the second triggers at the start of a Claude Code session. If your CI/CD clones an infected repository and the pipeline has a step that activates these entry points — malicious code runs on the server in the runner context.

Infected npm packages with forged attestation. This is where it gets interesting. Shai-Hulud publishes infected npm package versions two ways. The simple route: publish a new version using a stolen token. The clever route: abuse the OIDC Trusted Publishing mechanism, generating valid Sigstore provenance through Fulcio and Rekor. The result is a malicious package with a confirmed digital signature that looks like a legitimate build from a trusted CI/CD source. Standard npm audit won’t catch it — the package is technically signed correctly.

If your CI/CD runs npm install against dependencies that may have been compromised, this is a live vector. Especially dangerous when versions are not pinned via package-lock.json or are specified with a range (^1.2.0).

Important: neither npm audit nor npm audit signatures will detect this vector. The package was published through npm’s legitimate mechanism and carries a valid registry signature — technically everything looks clean. Defense is purely preventive: don’t let unexpected versions land in a build in the first place.

# Pin versions — remove ^ and ~ from package.json
# "express": "4.18.2"  — correct
# "express": "^4.18.2" — dangerous, can pull in an infected 4.18.3

# Use npm ci instead of npm install in CI/CD
# npm ci installs strictly from package-lock.json, never updates dependencies
npm ci

# Verify package-lock.json is committed to the repository
git status package-lock.json

HOW TO AUDIT YOUR SERVER

Indicators of compromise you can check right now:

# Framework-specific files
find ~ -name "setup.mjs" -path "*/.claude/*" 2>/dev/null
find ~ -name "setup.mjs" -path "*/.vscode/*" 2>/dev/null

# Suspicious commits in server-side repositories
find /var/www /opt /srv -name ".git" -type d 2>/dev/null | while read gitdir; do
    git -C "$(dirname $gitdir)" log --all --format="%ae %s" 2>/dev/null | \
    grep "[email protected]"
done

# User-level systemd units across all users
find /home -path "*/.config/systemd/user/*.service" 2>/dev/null
find /home -path "*/.config/systemd/user/*.timer" 2>/dev/null

# Lingering check — without it, user units don't survive without an active session
ls /var/lib/systemd/linger/
loginctl show-user USERNAME | grep Linger

# Network connections to the suspicious domain
host git-tanstack.com
ss -tnp | grep "SUSPICIOUS_IP"
grep "git-tanstack" /var/log/syslog 2>/dev/null

If a self-hosted runner is present:

# Check tasks.json in project directories
find /opt /var/www /srv -name "tasks.json" -path "*/.vscode/*" 2>/dev/null | \
    xargs -r grep -l "setup\|mjs" 2>/dev/null

# Runner logs
journalctl -u actions.runner.* --since "7 days ago" | grep -i "proc\|mem\|secret"

WHAT TO DO RIGHT NOW

If a self-hosted runner is on the server — close the /proc attack vector:

# Check the current value
cat /proc/sys/kernel/yama/ptrace_scope
# Ubuntu defaults to 1 since version 10.10
# If the value is 0 — the server is unprotected, set it to 1:
echo 1 | sudo tee /proc/sys/kernel/yama/ptrace_scope

# Make it permanent via sysctl.d
echo "kernel.yama.ptrace_scope = 1" | sudo tee /etc/sysctl.d/99-ptrace.conf
sudo sysctl -p /etc/sysctl.d/99-ptrace.conf

Values: 0 — unprotected, 1 — protected, 2 — only root can use ptrace, 3 — ptrace fully disabled.

For self-hosted runners, a minimum of 1 is recommended. Value 2 may break legitimate debugging tools — test on staging first.

General measures regardless of runner presence:

# Check SSH authorized_keys for anomalous entries
for f in /home/*/.ssh/authorized_keys /root/.ssh/authorized_keys; do
    [ -f "$f" ] && echo "=== $f ===" && cat "$f"
done

Lock down npm dependencies. If CI/CD uses npm — make sure package-lock.json is committed to the repository and the pipeline uses npm ci instead of npm install.

Key and token rotation when compromise is suspected — strictly in this order:

  1. Disable persistence (user systemd units)
  2. Rotate deploy SSH keys on all servers — add the new key to authorized_keys first, then remove the old one
  3. Revoke GitHub and npm tokens
  4. Rotate CI/CD environment secrets (GitHub Secrets, runner variables)
  5. Check AWS/cloud credentials and rotate as needed
# Add new deploy key before removing the old one
echo "new-public-key" >> ~/.ssh/authorized_keys

# Confirm the new key works — only then remove the old one
# Remove by matching the key comment (last field in the authorized_keys line)
sed -i '/old-key-comment$/d' ~/.ssh/authorized_keys

BOTTOM LINE

Shai-Hulud is not a kernel vulnerability with a CVE and a patch. It is a tool that abuses legitimate mechanisms: /proc for memory access, systemd for persistence, Sigstore to disguise malicious npm packages as legitimate builds.

For an Ubuntu server without a self-hosted runner, the threat is minimal. If a runner is present — ptrace_scope = 1 closes the primary attack vector, but doesn’t replace going through the checklist above.

The main takeaway: a source code leak means these techniques will now be replicated by less skilled attackers. Supply chain attacks via CI/CD are no longer exotic.

Leave your thought here

Your email address will not be published. Required fields are marked *