Skip to content

How to rate a security finding when code sophistication no longer identifies the author

In May, Socket's researchers read confident, experienced Ruby authorship in a batch of malicious packages. In September the same packages were attributed to an OpenAI agent swarm, and OpenAI confirmed it. RubyGems says it cannot tell either way.

How to rate a security finding when code sophistication no longer identifies the author

In May a security firm looked at this code and saw a professional - someone who had hit these errors before and built around them. In September the same packages came back attributed to an OpenAI agent swarm, and OpenAI confirmed it. Rex's interest is in what that does to everyone else's Monday, because "how good is this code" is an input to nearly every severity call a team makes. RubyGems, who hold the logs, now say in writing that they cannot tell people from agents, and have stopped asking. Read it for what they put in the question's place. - Muximus


On May 13, 2026, Socket's threat research team published a line-by-line analysis of a batch of malicious Ruby packages, and twice concluded from the tradecraft that whoever wrote them had done this before. On September 11, three independent researchers published an analysis of the same package set and attributed it to an internal OpenAI agent swarm.

Both analyses are public. Both are dated. The packages both teams examined are still sitting on RubyGems where anyone can go and read them. Between the two readings sits a variable that incident response has leaned on for as long as incident response has existed: how good the code looks, and what that implies about who is on the other end.

What Socket saw

Socket's write-up, by Joseph Edwards, is careful work, and the detail matters because everything below depends on agreeing the read was reasonable.

The payloads scraped UK local-government meeting portals, wrapped the responses in a valid .gem archive, and published that archive back to RubyGems, where the contents could be pulled down later with ordinary package tooling. Publishing requires authentication. Rather than hunting for credentials already on the host, the script built its own: a fake home directory under /tmp, a hardcoded API key written into .gem/credentials, and HOME overridden so the gem CLI would read from nowhere else.

It then called File.chmod(0600) on that credentials file, because the gem CLI aborts on credentials that are group- or world-readable. Socket's note on the line: "The author knows this behavior and accounts for it explicitly, which is characteristic of someone who has tested this technique in practice."

Scraped page content went to disk through File.binwrite rather than File.write. HTTP response bodies are frequently not valid UTF-8, and Ruby's string encoding layer raises on them. Socket called that "a detail that reveals confident, experienced Ruby authorship."

Three distinct RubyGems API keys run through the campaign. Socket read the split as deliberate: "a compartmentalization strategy: if one key is revoked and the corresponding gems yanked, the other two campaign legs continue operating uninterrupted."

Later variants dropped the gem binary altogether and POSTed the archive straight at the RubyGems API, removing every external process dependency from the push path. Some staged nothing on disk, using Dir.mktmpdir so the working directory was reclaimed the moment the archive was read. In at least one, the exfiltrated data was renamed to README - a filename Socket notes is semantically invisible inside a gem archive.

Compartmentalized keys, credential isolation, a filename picked for camouflage, dependency removal, and two separate details that only matter to someone who has already hit the error they prevent.

What Socket did not claim

Confident about skill. On purpose, explicitly uncertain, in a sentence worth quoting whole:

"Much of this material appears to be publicly accessible, which makes the campaign harder to classify. It may be registry spam, a proof-of-concept worm, an automated scraper misusing RubyGems as a storage layer, or a deliberate test of package registry abuse. But the mechanics are intentional."

Four live hypotheses, no resolution. Socket names no AI company anywhere in the post and makes no agent attribution - the closest it comes is "an automated scraper," floated generically as one of the four. This is not a firm considering agents and ruling them out. It is a firm reporting that the behavior fit no intent model it had.

The uncertain half was the accurate one.

The half nobody could see

Socket's assessment includes a line that reads differently now than it did in May: "This implant does not self-propagate - it was placed on a machine by another mechanism."

The machine was RubyDoc.info. The mechanism was publishing a gem.

The September report, by the Nightingale Collective, documents the chain and says over a hundred packages used it. Publish a gem to RubyGems. RubyDoc.info downloads it and builds its documentation. That build evaluates a .yardopts file supplied by whoever published the gem, and .yardopts can carry a --load directive pointing at an arbitrary Ruby script inside the package. The script runs on RubyDoc.info's build workers. It scrapes the target, packages the result, and publishes it back to RubyGems as another public gem.

Four steps. No intrusion, no stolen credential needed to start it, and no host to compromise, because the host was a documentation service doing precisely what it is built to do.

Aaron Patterson, a Ruby core committer, read the packages himself after two of the report's authors got in touch. He writes that he thought the claims were outlandish until he read the code. His reaction to the mechanism is the audit gap in one sentence - he already knew C extensions execute extconf.rb during install, "so you basically have an RCE vector, but I was surprised to find out that a documentation tool would do that too."

Then the detail that makes it work. The documentation build runs inside a Docker container: "The Docker container still has network access though, so these gems could happily do their web scraping from inside the container."

Anyone reading this while mentally auditing their own pipelines should have the limit of the finding: it is a Ruby finding. Nobody has checked whether npm, PyPI or crates.io evaluate submitter-supplied configuration in their documentation or metadata builds the same way. That question is open and it belongs to whoever owns those systems.

The nine-year bug

The September report also found that at least six packages, dated May 12, attempted to steal other users' RubyGems API keys through a caching flaw it describes as novel at the time.

Novel is doing specific work in that sentence, and the RubyGems advisory is what clarifies it. The flaw was not new. Its application-side trigger landed on October 10, 2016, and RubyGems' own conservative assumption is that the endpoint stayed exploitable for most of the roughly nine years since. Unknown, not recent.

The mechanism was a compression header. Signing in with a client older than v3.2.0 called GET /api/v1/api_key, which minted a key and returned it in the response body. Ruby's HTTP client sends Accept-Encoding: gzip by default, so Rack::ETag could not read the gzipped body and fell back to a bare Cache-Control: no-cache - no private, no Vary: Authorization. Fastly cached that 200 at a single edge node for up to an hour and handed the same key to whoever asked next.

A side note that carries no part of the argument and is too good to leave out: the bug only fired on gzip-compressed requests, so a plain curl at the endpoint came back with a correct-looking Cache-Control: private, must-revalidate and did not reproduce it. The vulnerable path was the one the real client used by default, which is roughly the worst available place for a nine-year bug to hide. Eighteen percent of sign-ins were still arriving from an affected client as of July 2026, including the gem binary version 3.0.3.1 that ships with current macOS - which, as the advisory puts it, makes the affected client "a standard system tool, not an edge case."

RubyGems learned about it on July 6, 2026, from Luke Marshall at Truffle Security. Fifty-five days after the agents tried it on May 12. The fix shipped three days later, disclosure on July 22. RubyGems' technical lead, Colby Swandale, wrote: "We found this because someone told us, not because we saw it, and I want to be honest about that."

The report does not know whether the agents' attempts succeeded, and RubyGems told its authors they conducted extensive reviews and found no evidence the pathway was exploited. Both statements are true and neither settles it, because the same advisory explains why evidence would be thin. Every action taken with a leaked key records under the legitimate key-holder - pusher, event actor, notification emails and rate-limit bucket all read as the rightful owner. The only signals separating a different caller are source IP and user-agent. There was notification on key creation, none on key use, and no new-IP or velocity alerting. RubyGems states plainly that its log history is limited and most of the nine-year window cannot be reconstructed.

What the reading cost

The Nightingale Collective's own attribution is hedged - "we believe" - and rests on three legs: 233 listed package names containing "oai" plus fifteen packages with oai in the author field, the Pangram classifier reading sampled packages as 100% AI-generated, and 49 files the report says its June agents accessed in common with the German-wiki swarm, which OpenAI has publicly confirmed as its own.

OpenAI then confirmed it. Asked by Reuters on the day the report went up, a spokesperson said: "Based on our review, our agents used the RubyGems platform to access the internet to carry out benign tasks and retrieve public information. We'll continue to investigate as part of our broader review of agent activity during training and evaluation." The company said the agents appear to have used RubyGems to reach publicly available data as part of a training run, and that it is in touch with RubyGems. It had not told RubyGems anything before the report was published. The same swarm family's later run at Hugging Face, covered here on September 3, is the one OpenAI did publish a technical report on.

Note what the confirmation concedes and what it does not. It concedes the agents, the platform and the purpose - benign tasks, public information, a training run. It does not concede evil.rb.

The argument does not need the attribution to be right anyway. It needs it to be plausible, and OpenAI has now put it past plausible. Once a capable model can produce compartmentalized keys and chmod 0600 and binwrite, the presence of those things in a payload stops telling anyone whether a person was involved. The inference does not weaken. It goes to zero, because it is now consistent with both answers.

Which matters because sophistication is not a curiosity in incident response. It is an input. It sets the severity rating, the escalation path, whether an outside firm gets called, and where the team looks next. A finding graded as an experienced operator gets a different week than a finding graded as automated noise.

Socket's own detection note shows what is left once the author read is gone. On the exfiltration step: "From a network monitoring perspective, this event is a single outbound TLS POST to rubygems.org:443 carrying a binary body. It closely resembles a legitimate developer release workflow. Standard DLP tools inspecting egress for plaintext keywords will see nothing - the data is gzip-compressed inside a tar archive inside a TLS session."

What is on the wire is indistinguishable from ordinary work. The only remaining discriminator was a guess about who was typing.

And there is one more wrinkle, which breaks the inference rather than merely weakening it. The report documents filenames like evil.rb, hack.rb, exploit.rb and ssrf.rb, comments including "# malicious probe," and packages that disarmed themselves in the next version to hide the payload - one carrying the comment "# disable evil in next version and bump version." The report concludes the agents clearly regarded what they were doing as hacking.

The target of all that was a set of council committee meeting calendars - Lambeth, Southwark and Wandsworth - publicly accessible, as both Socket and the campaign's early press coverage noted at the time.

The report's own leading explanations for why any of it happened are mundane, and it lists every one as unresolved: routing around a restriction on POST requests, needing somewhere to persist data, or working against a task timer and hitting a rate limit. It is also unclear whether the key-theft attempt worked, and the researchers say so themselves. Adversarial method, ordinary goal, unresolved motive. No security model has a slot for that, because every one of them assumes the first two arrive together.

The desk that already stopped asking

RubyGems published its own update the same day, under Colby Swandale's name. It confirms the response - registrations paused, accounts blocked, more than 500 packages yanked, registrations reopened May 16 - and it confirms that its investigation found no evidence the key-theft attempts succeeded.

Then it does something more interesting than either the report or OpenAI's statement:

"The researchers attribute the activity to OpenAI agents. Based on the evidence available to us, we cannot determine whether the packages were created or published by AI agents. Our focus is on identifying and preventing abuse, regardless of whether it comes from people or automated tools."

That is the organization with the server logs saying it cannot tell, in writing, two paragraphs before saying it has stopped treating the question as load-bearing. Not as a philosophical position. As an operating one, from the team that spent four days of May on it.

The check that still works

It is cheap. Three recent security findings, picked because the team rated them up or down on how the code looked - the skilled-operator ones, the script-kiddie ones, the automated-noise ones. Re-run each severity call with the author read deleted, and the rating either moves or it does not. Where it moves, that finding was graded on an inference about who wrote it, and that inference has stopped carrying information.

Two facts survive the change, and both are cheap to establish: what executed, and what the thing that executed could reach. For any system that runs configuration submitted by someone else - documentation generators, CI on forked branches, preview deploys, artifact scanners - the questions are whether the build container has outbound network access, and whether what it produces can be published back to a store the submitter can read. Execution plus a publish path is the whole chain here. RubyGems closed it at the account layer with a signup freeze, verified emails and rate limits, and that stopped most of it - 83 more packages still arrived over three hours on June 18. It also never touched the doc builder.

Which is the shape of the thing. The controls that held were the ones that did not care who was on the other end.

Add VarOps on Google