astropod sign in

Borrowed Compute: How Ten Packagist Libraries Turned GitHub Actions Into an Attack Botnet

Published on gastropod.io on 07-23-2026

Borrowed Compute: How Ten Packagist Libraries Turned GitHub Actions Into an Attack Botnet


Tags: vulnerability intelligence, software supply chain security, composer, github-actions, ci-cd-security, SBOM


A researcher publishing findings on July 22 traced a strange pattern back to ten small but legitimate Packagist packages: a Sri Lankan mobile number validator, a UK postcode lookup, a list of nationalities, a university directory, and six others, all maintained by the same developer. Between July 12 and 13, every one of them started shipping development versions with something new: between 55 and 62 GitHub Actions workflow files apiece, 583 in total, none of which had ever been part of these projects before.

The PHP itself was left alone. No install hooks, no obfuscated payload dropped in, and no code that runs when a project pulls the dependency in. The library files still just return static lookup data, same as always. The attack is entirely in a folder most people never think to check: .github/workflows.

What the workflows actually do

Each one triggers on a push to any branch, or on manual dispatch. Once triggered, it spins up a GitHub-hosted Ubuntu runner, detects the runner's CPU architecture, and pulls a matching Linux binary from an attacker-controlled server. That binary then runs as an internet scanner, targeting cPanel and WHM hosting control panels and attempting to exploit CVE-2026-41940, the authentication bypass patched back in April with a CVSS score of 9.8. Anything it finds, AWS keys, GitHub and GitLab tokens, database credentials, SSH material, Stripe and SendGrid keys, gets chunked and shipped back to the attacker's server over HTTP. A background process loop phones home every 30 seconds so the operator can watch each hijacked runner work in something close to real time.

None of this touches the machine of anyone who installs the package through Composer. composer install puts the code in the vendor directory, and GitHub does not execute workflow files that aren't sitting at a repository's own root. The exploitation only fires when the compromised repository itself receives a push, meaning the attacker effectively uses GitHub's own compute as disposable scanning infrastructure. Ten packages were confirmed entry points. A search on the campaign's callback domain turned up roughly 6,100 matching workflow files scattered across unrelated repositories, and broader searches on the C2 address and exfiltration logic returned 15,000 to 16,000 matches. Most of those are unconfirmed, but the code reuse makes clear this was never a single maintainer's problem.

Sources

Why this type of attack was able to get past the usual checks

Most package security tooling is built to catch a specific pattern: an install script or an import-time hook that runs unwanted code on the consumer's machine. This campaign has neither. The payload sits in files that aren't code in the traditional sense, aren't executed by the dependency resolution process at all, and only matter if you happen to own the repository they're sitting in. A scanner tuned to catch malicious postinstall hooks has no reason to flag a YAML file.

There's a second, more subtle but devious action that was triggered. Packagist auto-synced these malicious versions because they were published as dev-main, a floating branch reference rather than a tagged release. Anyone resolving a dependency against that branch got whatever the attacker had pushed, the moment they pushed it, with no review gate in between. Branch-tracking dependencies in a lockfile are a convenience that quietly removes the one checkpoint that would have caught this.

What actually catches a version that looks nothing like malware

Gastropod doesn't rely on recognizing this specific payload type to flag it. Every version pulled through the registry is diffed against that package's own history: file count, directory structure, and composition, not just known-bad signatures. A package that has shipped static PHP data for years and suddenly gains 59 new files in a directory it's never had is an anomaly on its face, independent of what those files claim to be.

The floating-branch problem gets closed the same way. Gastropod resolves and pins dependencies to immutable, hash-verified versions rather than a live branch reference, so a dependency graph can't silently pick up whatever a compromised upstream repository pushes next. And because every pull is logged against a principal, if a variant of this campaign ever did reach something in your build, you'd know exactly which projects and which builds touched it, not because you went looking after the fact, but because the record was there from the first time it came through.

If your current setup resolves dependencies against branches instead of pinned releases, it's worth seeing what that looks like locked down.

← news