10/25/2026
Alex

The Key Was Flagged for Revocation in May. In October It Signed a Poisoned Release.

The version that looked fine

On the morning of 21 October, the LiteLLM 1.107.0 release passed every check most teams actually run. The Git tag pointed at an ordinary signed commit. The source archive on PyPI carried no payload, though its build configuration had been quietly altered. The container images shipped with build provenance, the machine-readable record of which repository and which commit produced them.

The provenance was accurate. It named Berri-AI/litellm as the source. The real project is BerriAI/litellm. One hyphen, and nobody was comparing that field to anything.

Seven wheels went up between 11:39:51 and 11:40:13 UTC. PyPI quarantined them at 13:23. Docker Hub tags, including latest and main-stable, stayed compromised until 14:20. LiteLLM gets roughly 23 million downloads a week by Socket's count, and Wiz puts the bad version at about 49,000 downloads before it came down.

A master key, a force-push, and no revocation

The root cause is not exotic. On 27 May 2026, a configuration file was committed to the public LiteLLM-Labs/lite-harness repository. The file was opencode.json, and it contained the master key for a deployment of the LiteLLM Agent Platform, an internal alpha service the company used to orchestrate agents.

The mistake was caught quickly. The commit was force-pushed over. The exposed keys were flagged as needing revocation.

The master key was never revoked.

That is the sentence LiteLLM's own incident report lands on, and it is the one that matters: "key revocation did not occur for the master key." A force-push removes a secret from the visible history. It does not remove it from anyone who already cloned, forked, or cached the repository. The only control that closes that window is rotation, and rotation is the kind of task that gets marked done when the commit disappears from view.

On 10 October, an attacker at 69.48.229.143 called the Agent Platform API with the key. At 21:27:10, a request to GET /api/v1/managed_agents/agents?limit=100 returned the configuration of every managed agent, and one of those configurations held a GitHub CLI OAuth token belonging to a LiteLLM GitHub organization owner. It was not a service account. It was not scoped down. It carried owner rights across every repository in the organisation.

That is 136 days between the commit and the first use.

Eleven days inside the repository

What followed is the part that should reorganise how security teams think about CI access. The attacker did not need a clever exploit. They had push rights, and push rights are enough to turn a repository's own CI into a collection channel.

Socket's forensics, published on 23 October, recovered the mechanism. Create a throwaway branch from a recent legitimate commit, so the branch looks like ordinary work in progress. Push one commit adding .github/workflows/cache.yml under the subject ci: bump cache config, which reads like housekeeping. The workflow triggers on any push. Because it runs in the upstream repository's context, secrets.* resolves to real values. It writes them to a temp file, base64-encodes the file, posts it to a webhook.site endpoint, and ends the step with || true so a failed send still reports success. Delete the branch.

The 10 October run took four seconds and collected six organisation secrets, among them GH_TOKEN and the identifier and private key of a GitHub App. A second run, 24 seconds later, posted RELEASE_PLEASE_TOKEN from a different repository.

Two days later, a 97-line version with six jobs bound to five real GitHub Environments pulled 14 more secrets, none of which overlapped with the first six: ANTHROPIC_API_KEY, OPENAI_API_KEY, AWS_BEARER_TOKEN_BEDROCK, TAVILY_API_KEY, PostgreSQL and Redis credentials, Playwright browser session state for Linear, and GITGUARDIAN_API_KEY.

That last one deserves a pause. GitGuardian is a secret scanner. The attacker took the key to the system that would have raised the alarm.

Across both workflows, 20 distinct secret names. Both branches were deleted within minutes, and the workflow file does not exist on main at any later commit. What survived was the Actions performance metrics, which recorded one run, one job, four seconds, zero failures, and the GitHub Environment deployment records, which outlive branch deletion and still showed ci-tmp-4d8517 as the active deployment for integration-redis.

Socket is careful about what this proves. It establishes that credential-theft workflows ran inside LiteLLM's repository from 10 October, a compromise window of at least eleven days. It does not establish how the account access was first obtained, and it does not claim the 10 October tokens produced the 21 October release. LiteLLM's audit logs do connect the two: the hash of the GH_TOKEN stolen on 10 October matches the token that approved the production gate eleven days later.

Two hours on the registry

The payload was small and deliberately boring to look at.

LiteLLM builds its wheels with maturin because the package includes a Rust extension. The attacker added one key to the [tool.maturin] table in pyproject.toml:

data = "cook"

That directive copies a directory into the wheel's .data section. During installation, pip moves the contents of .data/purelib/ into the root of site-packages, which is not inside the litellm/ package folder. Three files landed there: cook.pth, twelve bytes, containing the single line import cook; cook.py, 22,252 bytes, which decodes and decompresses a bundled credential stealer; and sitecustomize.py, 25,509 bytes, which Python imports automatically at startup if it is on the path.

Python processes .pth files and imports sitecustomize during interpreter initialisation, before application code runs. Microsoft's analysis, published on 23 October, is blunt about the consequence: "An application did not need to import LiteLLM for the payload to run: starting Python in an affected environment could be enough." In one Windows case Microsoft documented, a routine python invocation to install an unrelated dependency fired the collector.

Wiz calls the pairing of a .pth hook with sitecustomize.py the first documented case of the technique in the wild. The stealer, which names itself privcollect, runs 16 modules across cloud metadata endpoints, browser stores, SSH and GPG keys, Kubernetes service account tokens, and the config files of a dozen AI coding tools. It also queries the LiteLLM proxy's own Postgres tables for up to 500 rows of LiteLLM_CredentialsTable, which is where a gateway stores every upstream provider key it routes with.

Data leaves as a tar archive encrypted with AES-256-CBC under an RSA-4096 key, POSTed to www.litellm.dev/healthcheck. The real project lives on litellm.ai. The lookalike domain was registered on 13 October, eight days before the release.

The second path is worse in principle. sitecustomize.py forks a detached process that locks /tmp/remote_code_loader.lock, then polls a file in a GitHub repository every hour for a hostname, fetches Python from that host at /codepy, and executes it. No signature check. The pointer file never held anything but test values before GitHub removed the repository, so no second stage was served that anyone has found. The mechanism was live anyway.

Microsoft's remediation advice is the part most incident checklists will get wrong. Removing the package is not enough, because the startup files sit outside the package directory. "Removing the package or a release tag is not sufficient recovery." Neither is starting Python to check, since that runs the hook again.

What the stolen credentials were for

Wiz observed credential use about four and a half hours after exfiltration. Between 16:20 and 17:01 UTC on 21 October, an attacker checked AWS instance-role credentials taken from compromised CI runners and Kubernetes nodes, then called bedrock:ListFoundationModels and ListModels. That sequence is discovery followed by model enumeration, which is what LLMjacking looks like in its first minutes: find out what you can run, and on whose bill.

The GitHub access was not the objective. It was the means. The target was inference capacity and the keys that pay for it, and a gateway is the densest concentration of those keys in most AI stacks: provider keys, database strings, workload identities, routing configuration, tenant policy. LiteLLM is also installed on developer laptops and build agents, not just on the proxy host. The blast radius was never limited to the gateway.

The control that was relaxed that week

LiteLLM had hardened this pipeline after a March incident. Releases moved to a private repository, Docker tags became immutable, images were signed, and publishing required an account locked to a specific laptop physically inside the office.

That last control was relaxed earlier in the week of the attack, to let two engineers ship daily bugfix backports for customers. Any two engineers could now trigger and approve a release. The attacker used one stolen token to trigger the build and another to approve the production gate. LiteLLM notes, fairly, that an organisation owner's token could have removed the restriction outright; the two-token approach looks chosen to keep the audit log quiet.

The rest of the timeline shows how far owner rights reach. At 10:08:29 on 21 October, branch protection rejected a push to the release workflow. Five seconds later the owner's token overrode it. The staging build was dispatched against the lookalike repository at 10:46. The production gate was approved at 11:11 from 199.91.221.39. At 11:53, a third stolen token deleted the production run record, then the staging one, taking the workflow logs with them. By 12:24 the change and its revert had both been force-pushed out of main.

Every one of those actions was recorded. The commits are still retrievable by SHA, because a fork's objects stay reachable through the parent repository. The organisation audit log logged each override. The attack was quiet in the repository view and loud in the audit log, which only helps if someone is reading it.

The delay had a second cost. The version customers were waiting for was there to remove the eMudhra certificate authority from the trust store, and it could not ship until the pipeline that built it could be trusted again.

An opt-in fix, shipped the same day

On 21 October, the same day the wheels went out, GitHub shipped expiring, auto-refreshing OAuth tokens for its CLI. A leaked gh token becomes useless within hours instead of lasting until somebody remembers to revoke it.

It is opt-in. You get it with gh auth login --expiring or by enabling expiring_credentials. GitHub says it plans to make expiring credentials the default for new sign-ins "in a future release." A detail from the same changelog matters more than it looks: "Tokens supplied through GH_TOKEN or GITHUB_TOKEN are never refreshed." GH_TOKEN was the secret taken on 10 October and used to approve the release on 21 October.

Detection came from outside. Socket's scanner flagged the wheels minutes after publication. A researcher, LidorMachluf, filed issue #47826 at 12:06:41, roughly 27 minutes after the first wheel landed, and kept the writeup current while the project was still working out what had happened. LiteLLM began incident response at 13:02. The controls that had been hardened in March did not catch it.

Two structural lessons come out of the reports, and both are cheap:

The release workflow accepted a source repository as an input. Hard-code it, or check github.repository and the provenance source.uri against an allowlist before the build gets publishing rights. The provenance on these images was accurate and named the wrong repository to anyone who looked.

Gate on the shape of the artifact, not the reputation of the tag. Three new top-level files in a wheel's RECORD, one of them a .pth, is the entire signature. Comparing that against the previous release costs a diff.

What a gateway is actually worth

There is a version of this story that ends with "rotate your secrets," which is true and close to useless, because the failure that mattered happened six months earlier when a secret was exposed, removed from view, and marked done.

The uncomfortable parallel for anyone running infrastructure is that the artifact is the trust boundary now. LiteLLM's tag was clean. Its source archive was clean. Its build system was the real one, using real signing credentials, and the only thing wrong was a field in the provenance pointing at a fork with one extra hyphen. If your release process can build from a source you did not hard-code, you have the same hole, and the two hours between publication and takedown will not save you.

DMC works with hardware companies on the other end of that problem, where the artifact arrives as firmware, a board support package, or a vendor image and the build pipeline belongs to somebody else. The questions do not change: what exactly produced this, who could have altered it, and what happens to every credential it can reach if the answer is wrong. If you are mapping that exposure across a 2027 production ramp, let's talk.