Ruby malware: five threat classes you can grep

Five ruby pedestals in a cream cave, a teal ship at the back.

Four versions of git_credential_manager, 2.8.0 through 2.8.3, landed on RubyGems.org in about nine hours on 18 and 19 July 2026. Charlie Eriksen’s Aikido note, published 19 July 2026 and updated 22 July 2026, said later builds ran on a plain require, skipped about thirty CI environment variables, and then appeared as a new child of Dendreo and of a Fastlane plugin family he counted at 574,661 downloads. That is three classes in one weekend. The other two live in your own tree.

Black Ruby was ransomware that dressed up as Defender. That locker is an endpoint ticket, not a Rails hatch. Keep the secure coding checklist for the rest of the request surface. Read injection when the string already reached SQL or a shell. This page stays on five classes you can name in a review comment.

Name the five classes, then grep

Each class has a first-party date or a CWE, a grep, and a reject. If a line matches two classes, fail on both. Do not wait for a CVE number. The July 2026 weekend had none while the versions were live.

ClassFirst signalReject with
HijackedPublisherknown name, new number, no tagrestore the last pin
DormantGraftquiet gem adds a childdrop the child, pin parent
BrandImpersonatorlookalike vendor stringremove the gem
EvalHatcheval on request textcall load_prefs
UnsafeCodecMarshal.load / unsafe_loadJSON plus an allowlist
A scanner that only matches CVE ids stays quiet on all five until someone files an advisory.
LOCKFILE   HijackedPublisher   unexpected version on a known gem
           DormantGraft        years-quiet gem grew a new child
           BrandImpersonator   copies a well-known product name

APP TREE   EvalHatch           eval / instance_eval on params
           UnsafeCodec         Marshal.load or YAML.unsafe_load

REJECT     restore pin, drop child, parse through load_prefs

HijackedPublisher: a quiet owner ships a new number

CWE-94 landed in the registry before it landed in your app. rest-client issue 713 is the first-party writeup. On 14 August 2019 someone published 1.6.10 through 1.6.13 with a stolen owner password. About 1000 downloads. On 19 August the reporter opened the ticket and RubyGems yanked the line. CVE-2019-15224 is the later id. The maintainers said the 1.6.13 payload waited for a production-like Rails.env, then fetched remote Ruby. I will not walk that fetch. The detect is the pin:

# detect_classes: HijackedPublisher uses HIJACK_PINS
# add the yanked series the issue named, then fail the lockfile

Unaffected lines, in their words, are 1.6.9 and below or 1.6.14 and above. Most apps were already on 1.7 or 2.x. The people who resolved into 1.6.13 had floated an abandoned series. That is the class: a name you already trust, a number you did not mean, a git tag that does not exist.

Detection in review:

  • The lockfile moved a gem you already depend on, and the git tag for that number is missing.
  • The changelog on the upstream repo has no matching release.
  • The owner account was quiet for years, then shipped four numbers in a day.

Reject by restoring the last pin that predates the window. Then read the diff of the yanked gem only to list new files, not to study a payload. Rotate tokens the process could have read if the install already ran.

DormantGraft: a sleeping gem grows a new child

Aikido’s July note is the citation for this class. Dendreo first published in 2017. Two new versions appeared the same weekend as git_credential_manager and added that gem as a dependency. The Fastlane plugin was a second owner, last tagged 0.3.1 in the public repo, then 0.3.2 on the registry on 19 July 2026. The child is the payload. The parent is the trust you already had.

# detect_classes: DormantGraft uses GRAFT_PINS
# fail when a gem with no release in 24 months gains a new runtime child

You will not have a perfect age oracle in CI. You can still fail a PR that adds a runtime dependency to a gem nobody on the team can name, and you can still grep the coordinates of the last graft. StepSecurity’s follow-up, dated 19 July 2026, listed the same three package families. I am using those names as a list. I am not replaying the Forgejo host.

A graft is easy to miss because the parent version looks like routine maintenance. The review question is not “is Dendreo popular.” The question is why a project that sat still since 2020 suddenly needed a credential-manager child. If nobody on the team can answer that in one sentence, the line does not merge. Add the child name to GRAFT_PINS after you drop it, so the next laptop that still holds the bad pin fails CI.

Detection in review:

  • A gem that sat still since 2019 or 2020 grew a new runtime child.
  • The child is not in the gem’s documented extras.
  • The parent version has no matching git tag.

Reject by dropping the child and pinning the parent to the last number you can explain. If the child already installed on a laptop, treat that laptop as the incident, not the CI runner. Aikido said the loader skipped CI variables on purpose. A clean Actions log is not a clean workstation.

BrandImpersonator: a lookalike of a vendor tool

The same July weekend started with a brand-new gem named git_credential_manager. That string is how Microsoft’s Git Credential Manager presents itself. The official project lives under git-ecosystem on GitHub. Aikido said the Forgejo user on git.disroot.org reused that org look. VirusTotal flagged a binary from that host. I am citing the name collision, not the binary.

# detect_classes: BrandImpersonator uses IMPERSONATOR plus VENDOR_LOOKALIKES
VENDOR_LOOKALIKES = %w[git_credential_manager git-credential-manager].freeze

Typosquats are the cousin. A letter off rails, a hyphen moved, a plural. The 2026 StubMaker writeups described clumsy lookalikes that ran extconf.rb to fetch a Windows loader. I opened a secondary recap for that campaign and I am not treating it as first-party. If your lockfile grows a gem whose only job is an extconf.rb that downloads a binary, fail the PR. Ask for the compile reason. A JSON parser does not need a 22 MB fetch.

Detection in review:

  • The new name matches a vendor product the team already installs another way.
  • The homepage is a public Forgejo or a fresh GitHub account, not the vendor org.
  • The gemspec sets extensions and the extension fetches a URL.

Reject by removing the gem. Do not alias it. Do not “just pin an older number” of a name you never meant to install.

EvalHatch: the process compiles a string

CWE-94 in your app is the class that does not need a registry. eval, instance_eval, class_eval, module_eval, and binding.eval compile text in the same process that holds your secrets. $SAFE is gone. A params key is not a program. If the client sent preferences, parse JSON. If the client sent a formula, write a grammar.

# lib/load_prefs.rb
# frozen_string_literal: true
require "json"

PREF_KEYS = %w[locale density].freeze
LOCALES = %w[en de fr].freeze
DENSITIES = %w[comfortable compact].freeze

def load_prefs(text)
  data = JSON.parse(text)
  raise ArgumentError, "prefs" unless data.is_a?(Hash)
  raise ArgumentError, "keys" unless (data.keys - PREF_KEYS).empty?
  locale = data.fetch("locale")
  density = data.fetch("density")
  raise ArgumentError, "locale" unless LOCALES.include?(locale)
  raise ArgumentError, "density" unless DENSITIES.include?(density)
  { "locale" => locale, "density" => density }
end

Identifiers stay load_prefs, PREF_KEYS, LOCALES, and DENSITIES. A route that still wants “a little Ruby” is asking for EvalHatch. Issue 713 described remote text reaching eval. Your form should not offer the same call just because you trust the session cookie.

# BAD: compile request text
# eval(params[:expr])
# instance_eval(params[:rule])
# binding.eval(cookies[:theme])

# FIX
prefs = load_prefs(request.body.read)

Detection in review: any new eval family call, any send whose method name came from params, any Kernel.open on a URL the client chose. send with a literal symbol is fine. send(params[:method]) is EvalHatch with extra steps. Block the merge. Route the value through load_prefs or through an allowlist of symbols.

UnsafeCodec: Marshal and YAML become an object graph

UnsafeCodec is the deserialize twin. Marshal.load rebuilds whatever graph the bytes describe, including classes that run code at load. A cookie, a cache blob, or a job payload is not a trusted dump just because your app wrote a dump last week. An attacker who can write the cookie writes the graph.

YAML.unsafe_load is the Psych hatch. Fail the PR on unsafe_load and on Marshal.load. If you must parse YAML, call YAML.safe_load with permitted_classes you listed. Prefer JSON plus load_prefs.

# BAD
# Marshal.load(cookies[:pref])
# YAML.unsafe_load(request.raw_post)

# FIX: same load_prefs allowlist
prefs = load_prefs(cookies[:pref]) if cookies[:pref]

Detection in review:

  • New Marshal.load or Marshal.restore.
  • New YAML.unsafe_load or Psych.unsafe_load.
  • A job adapter still using Marshal for user-touched payloads.

ActiveJob can be configured to use JSON. If a queue still dumps Marshal, treat that queue as UnsafeCodec until you prove the bytes never leave a process you own. Do not “encrypt the cookie” and keep Marshal.load. Encryption without an allowlist is still an object graph.

Prove detect_classes fails the planted line

You are not proving a gem is kind. You are proving each class has a grep, and that the grep fails a PR you own.

# scripts/detect_classes.rb
# frozen_string_literal: true
require "pathname"

HIJACK_PINS = [/rest-client \(1\.6\.1[0123]\)/].freeze
GRAFT_PINS = [
  /Dendreo \(1\.1\.[34]\)/,
  /fastlane-plugin-run_tests_firebase_testlab \(0\.3\.2\)/,
].freeze
IMPERSONATOR = [/git_credential_manager \(2\.8\.[0-3]\)/].freeze
EVAL_HATCH = [
  /\beval\s*\(/,
  /\binstance_eval\s*\(/,
  /\bclass_eval\s*\(/,
  /\bmodule_eval\s*\(/,
  /\bbinding\.eval\s*\(/,
].freeze
UNSAFE_CODEC = [/\bMarshal\.load\b/, /\bYAML\.unsafe_load\b/, /\bPsych\.unsafe_load\b/].freeze

def add_hits(klass, rules, text)
  rules.each { |re| HITS << "#{klass} #{re.source}" if re.match?(text) }
end

HITS = []
lock = Pathname.pwd.join("Gemfile.lock")
abort "missing Gemfile.lock" unless lock.file?
body = lock.read
add_hits("HijackedPublisher", HIJACK_PINS, body)
add_hits("DormantGraft", GRAFT_PINS, body)
add_hits("BrandImpersonator", IMPERSONATOR, body)
Pathname.pwd.glob("**/*.rb").each do |path|
  next if path.to_s.include?("vendor/bundle")
  text = path.read
  add_hits("EvalHatch", EVAL_HATCH, text)
  add_hits("UnsafeCodec", UNSAFE_CODEC, text)
end
abort HITS.join("\n") unless HITS.empty?
warn "detect_classes ok"

Identifiers stay detect_classes, HIJACK_PINS, GRAFT_PINS, IMPERSONATOR, EVAL_HATCH, UNSAFE_CODEC, and HITS. Plant a line in a file you own, expect nonzero, delete the line. Then break a lockfile copy with the yanked 1.6.13 pin and expect HijackedPublisher. You are not fetching that gem.

ruby scripts/detect_classes.rb
# printf '%s\n' 'eval("1")' >> tmp_hatch.rb
# expect the hook nonzero, then delete tmp_hatch.rb

Questions we keep getting

Why not list Black Ruby as a class?

Black Ruby was a Windows locker that also mined coins. Keep that name off a Rails runbook. It is neither a language hatch nor a gem pin. Putting it on a Rails page trains people to buy an endpoint agent and skip the lockfile diff. Keep it off this list.

Is a typosquat its own class?

Treat a letter-off name as BrandImpersonator. The reject is the same: remove the gem. Split it out only if your review template needs a separate checkbox. Five classes is already enough to teach.

Does detect_classes replace frozen install?

No. The hook finds a known-bad pin and an evaluator. Frozen install stops a name that is not in the lockfile yet. Run both. A new impersonator you have never listed will not match IMPERSONATOR until you add the string.