Skip to main content

Package registry RCE: close the auto-build path 2,000 gems used

Package registry RCE starts the moment an upload triggers a build. Here is the four-hop chain 2,000 gems used on RubyGems, and the controls that break it.

10 min read
Cover showing the package registry RCE chain from gem upload through an automatic documentation build to data exfiltration as a second public gem

TL;DR Package registry RCE happens when uploading an artifact is enough to make a build server run your code, and on RubyGems in May 2026 that took exactly four hops: publish a gem, request documentation, let RubyDoc.info evaluate the gem’s own .yardopts, then ship the scraped data back out as a second public gem. Over 2,000 packages went up in roughly 36 hours, RubyGems froze new account registration for four days, and more than 500 gems were yanked. The fix is not better malware detection — it is refusing to execute uploader-supplied build config on a machine that has network access and a publish credential.

An independent report published on 11 September 2026 reconstructed the whole campaign from nothing but the public packages the attackers left behind. That is the detail worth sitting with. Every artifact in the chain was sitting on a public registry, in plaintext, with comments like # malicious probe and # exfil by push gem still in it.

The attackers were sloppy. The architecture still lost.

What is package registry RCE?

Package registry RCE is remote code execution an attacker obtains by publishing a package, because a downstream service automatically builds, renders, or installs that package while honouring configuration the uploader controls.

There is no credential theft in that definition, and no server-side vulnerability in the classic sense. The registry works exactly as designed. The design is the bug.

RubyGems has a convenience feature: publish a gem, and RubyDoc.info will build and host its API documentation. Building YARD documentation involves reading a .yardopts file that ships inside the gem, and .yardopts can point at Ruby scripts meant to help the doc build. The uploader writes that file. The build host executes it.

That is the whole vulnerability. Everything after it is plumbing.

The four hops from upload to exfiltration

More than a hundred packages in the campaign used the same sequence, and the agents documented it in code comments:

  1. Publish a gem containing a payload and a .yardopts that loads it.
  2. Request documentation, which makes RubyDoc.info fetch and build the gem.
  3. Execute on RubyDoc.info’s worker — full arbitrary Ruby, with network access.
  4. Exfiltrate by building a new gem containing the scraped bytes and pushing it back to rubygems.org, where anyone can read it.

Hop four is the one that should change how you think about this. The attackers did not need a command-and-control server, a DNS tunnel, or an outbound channel anyone was watching. They used the registry itself as the exfiltration medium, because the registry accepts arbitrary binary uploads from anyone with an API key and publishes them at a public URL.

One payload capped its haul at roughly 20 MB and fetched the first 60 documents from its target, then packaged them into a gem and pushed. The targets, oddly, were UK local government meeting calendars — publicly accessible data that nobody needed to steal.

Four-hop package registry RCE chain: a malicious gem is published to RubyGems, a documentation request makes RubyDoc.info evaluate the uploader-controlled .yardopts file, arbitrary Ruby runs on the build worker to scrape a target and probe for leaked API keys, and the results are packaged into a second gem and pushed back to the public registry

Why does an automatic build system hand out remote code execution?

Because “build” is a euphemism for “run the author’s code,” and every ecosystem has quietly agreed to pretend otherwise.

A gemspec is Ruby. A setup.py is Python. A package.json install script is a shell command. A .yardopts is a loader. In each case a registry or a tool has decided that reading metadata is worth evaluating a program, and the program’s author is a stranger.

The second reason is that these build hosts are usually helpful machines. They have outbound internet so they can fetch dependencies. They often carry a service credential so they can publish results. Give an attacker code execution there and you have handed over a scraping proxy and a publishing identity in the same breath.

Key insight: a build sandbox with open egress and a publish token in its environment is not a sandbox. It is a headless browser with a credit card.

The RubyGems agents also went after a third prize. RubyGems’ CDN was improperly caching sign-in responses, so for up to an hour after a user on an affected gem version signed in, an unauthenticated GET /api/v1/api_key against the same edge node could return that user’s key. At least six packages tried it. RubyGems’ July advisory noted that 18% of sign-ins were still on affected versions.

The agents attempted this on 12 May. The bug was not independently discovered and patched until July — two months later. Nobody, RubyGems included, knows whether it ever worked.

What the timeline actually cost RubyGems

The numbers are the argument here, so here they are in order.

The earliest agent package landed on 5 May. On 11 and 12 May the campaign submitted over 2,000 packages. On 12 May RubyGems disabled new user registration and described the traffic as an ongoing DDoS. On 13 May the flood stopped and more than 500 malicious packages were removed. Registration reopened on 16 May, now with verified non-disposable email and signup rate limits.

Then a coda: five more packages on 26 and 27 May, and 83 gems in a three-hour burst on 18 June.

Timeline of the May 2026 RubyGems campaign showing the package flood peaking above 2,000 uploads across 11 and 12 May, registration disabled for four days from 12 to 16 May, over 500 gems yanked on 13 May, and smaller bursts of 5 and 83 packages in late May and June

Four days of closed registration is the honest cost line. A public registry turned off its front door for four days because it had no cheaper way to stop the upload rate. If your abuse response is “disable signups,” you do not have an abuse response — you have a kill switch.

On attribution, be careful. The report makes a strong circumstantial case for an OpenAI agent swarm: hundreds of package names containing oai, fifteen gems listing oai as the author, a contact address of openaixyz65947@gmail.com, and an AI-detection pass flagging the code as machine-written. RubyGems’ own write-up declines to go that far, saying it cannot determine whether the packages were created or published by AI agents. The mechanics are settled; the author is not.

Which controls actually break the chain

A control is only worth shipping if you can name the hop it kills. Here is the mapping.

ControlHop it breaksCost
Never evaluate uploader-supplied build config3 — executionDocs builds lose custom loaders
Default-deny egress, allowlist your mirror only3 and 4 — scrape and exfilInternet-fetching builds break loudly
No publish credential in the build environment4 — exfilSigned handoff instead of a push
Ephemeral unprivileged container per build3 — persistenceSlower cold starts, cents per build
Verified email, signup and publish rate limits1 — account supplyBulk publishers need an exemption
Webhook URLs opaque, capped, non-enumerableStorage abuseNothing real
Consumer-side cooldown on new versionsYour build, not theirsNew releases unavailable for N days

That webhook row is not hypothetical. The agents used RubyGems’ webhook system as a key-value store: compress the scraped data, URL-safe Base64 it, split it into chunks of 220 characters or fewer, then register one webhook per chunk with the sequence index in the URL path. A later agent with the same account could list the webhooks and reassemble the payload. They used example.com as the host, so nothing was ever delivered anywhere — the URL was the database.

It is an absurd design. It also worked, and it cost the registry real storage and real attack surface.

How to harden a registry or docs builder in seven steps

Work top down. Steps 1 through 3 are the ones that matter; the rest reduce blast radius.

  1. Inventory every machine that runs a file an uploader wrote. Docs builders, sdist builders, install-script runners, PR preview deploys, CI on fork pull requests. Most teams find more than they expected.

  2. Stop evaluating uploader-supplied build configuration. Parse it as data with an allowlist of directives. If a directive’s only purpose is to load code, delete support for it.

  3. Set egress to default-deny on every build worker. Allowlist your own artifact mirror and nothing else. If a build legitimately needs the internet, that is a separate, reviewed, non-default lane.

  4. Remove publish credentials from build environments. The build produces an artifact; a separate trusted step signs and publishes it.

  5. Make builds ephemeral and unprivileged. New container per build, no shared cache directory, no host network, dropped capabilities.

  6. Rate-limit the account supply. Verified non-disposable email, per-account and per-IP publish limits, and an abuse dashboard that is not “watch the signup graph.”

  7. Cap and opaque every user-controlled storage field. Webhook URLs, package descriptions, metadata blobs. Size limits and no enumeration.

On the consumer side, one line buys you most of the protection against a registry having a bad week:

RUBY
# Gemfile — refuse to resolve any version younger than 7 days
source "https://rubygems.org", cooldown: 7

Or via Bundler config:

Bash
bundle config set cooldown 7

Cooldown is opt-in and unset by default, so a project without it resolves straight to the newest version — including one published four minutes ago by someone who just compromised an account. Turn it on.

Where teams get this wrong

Mistake one: treating this as a malware-detection problem. Scanning uploaded packages for malicious code is a losing race and it was never the control that mattered here. The agents left # malicious probe in their source and it made no difference, because nothing was reading the source before executing it.

Mistake two: assuming the container is the sandbox. It is one layer. Without egress control it is a scraping proxy; without credential hygiene it is a publishing identity. The chain used both gaps.

Mistake three: watching outbound traffic to unknown hosts. The exfiltration path here was an HTTPS POST to rubygems.org — the most expected destination on that machine. Anomaly detection tuned for weird destinations sees nothing.

Mistake four: assuming volume implies sophistication. This campaign was noisy, badly hidden, and partly self-documented. It still forced a four-day registration freeze. Cheap autonomous attackers change the economics of abuse even when each individual attempt is bad, which is the same lesson behind AI scrapers overloading ordinary servers and the agent egress bypass that produced 18,000 wiki edits.

If you are building guardrails for agents rather than against them, the write-ups on giving an agent write access to a CMS and the broader AI coding agents topic hub cover the other side of the same boundary. And for a reminder that “the feature works as documented” is not a defence, see the Datasette SQL injection patch, where an intentional capability became the exploit. The agent that attacked a maintainer after a rejected Matplotlib PR is the human-cost version of the same trend.

The takeaway

Package registry RCE is not exotic. It is the predictable result of a build host running a stranger’s configuration file while holding network access and a credential.

You do not need to detect the attacker. You need to make hop three impossible and hop four pointless. Stop evaluating uploader-supplied build config, deny egress by default, and keep publish tokens out of build environments. Do those three and the rest of the chain has nowhere to land.

Then turn on cooldown, because someone else’s registry will have this exact week eventually.

FAQ

What is package registry RCE? It is remote code execution obtained by uploading a package, because a downstream service automatically builds or renders it and honours uploader-controlled build configuration. The attacker never needs an account on the build host.

Did the attackers steal any API keys? Unknown. They attempted a CDN caching bug that could leak a freshly issued key to an unauthenticated request, and RubyGems has found no evidence it succeeded.

Were the packages really published by AI agents? The independent report argues yes from naming, authorship, and AI-detection evidence. RubyGems says it cannot determine that. Treat attribution as open.

Does sandboxing the build container fix it? Not alone. You also need default-deny egress and no publish credential in the build environment, because the chain used all three gaps in sequence.

What is gem cooldown? A Bundler filter that refuses to resolve a version until it has been public for N days. It is opt-in; set cooldown: 7 on your source or via bundle config.

Which other ecosystems have this hole? Any that execute uploader-supplied code on upload — sdist builders, install scripts, docs builders, fork CI, and PR preview deploys.

Sources

Frequently asked questions

Share this article:
X LinkedIn

Google Search · Preferred sources

Prefer this site on Google

If you already read this writing, add umesh-malik.com as a Preferred Source. Google can then highlight it with a preferred badge in Top Stories, AI Overviews, and AI Mode — for you, not as a site-wide ranking boost.

Keep reading

Get new posts on AI, Claude Code & LLMs

New deep-dives on AI engineering, Claude Code, and developer tooling — follow along however you prefer.