Install Was the Infection: strutil-core Brought a Shai-Hulud-Descendant Worm to RubyGems

Socket Threat Research reported on 6 November 2026 that strutil-core version 1.0.1 on RubyGems ran a 5.48 MB credential-stealing worm at install time. The RubyGems account jamnajiea published a clean 1.0.0 on 1 November at 02:19:56 UTC and pushed the malicious 1.0.1 less than two hours later at 04:05:04 UTC. Socket’s AI Scanner flagged the bundled payload as known malware at 04:06:14 UTC — seventy seconds after publication — and RubyGems removed both versions roughly five hours later, between 08:52 and 09:13 UTC. Socket found no public projects depending on the gem and has confirmed no victims and no downstream packages the worm infected from this host. The payload is what makes the incident worth studying: a new build of the testmetest worm from the Miasma family that grew out of the publicly released Shai-Hulud toolkit — and the first build Socket has tied to RubyGems.

A string helper with a JavaScript worm attached

The gemspec describes strutil-core as lightweight string helpers — slug, truncate, normalize — and the Ruby library implements exactly one of them. The malware lives in the declared native extension: ext/strutil-core/extconf.rb contains no build logic. On Linux or macOS (x86-64 or ARM64) it downloads the Bun 1.2.18 runtime from Bun’s official GitHub releases into a temporary directory, runs the adjacent index.js with it, cleans up, and writes a Makefile whose targets do nothing so the install finishes without errors. No application ever has to require the gem. Running gem install — or a bundle install resolving to it — is the whole infection chain. Bitbison’s sandbox run of the clean 1.0.0 recorded no malicious behavior, which fits a clean seed release followed by an injected patch.

The sharpest detail in Socket’s analysis is that the gem is the worm’s own RubyGems injector output. The decoded payload contains a RubyGems propagation module that, given a stolen RubyGems API key, downloads each publishable gem, adds the payload plus an installer, bumps the gemspec version, and pushes the rebuilt gem with a User-Agent imitating the gem client. Filling the payload’s installer template with its own constants reproduces the shipped extconf.rb byte-for-byte (SHA-256 c5264f3b…9c749). Thirty-three of the installer’s thirty-six non-empty lines appear verbatim in a deobfuscated copy of the June 2026 Miasma payload published for defensive research. That also explains the sloppy version string: the injector bumps the gemspec but never edits existing source, so the malicious 1.0.1 still declares VERSION = '1.0.0'. The November build changes four things from June — Bun 1.2.18 instead of 1.3.13, a split download URL that breaks naive static signatures on the Bun releases string, a newer client User-Agent, and added progress logging with a dry-run mode.

Fifty-one credential stores, runner memory, and a signed backdoor

After a locale check that exits on systems reporting Russian, Belarusian, Kazakh, Uzbek, Kyrgyz, Tajik, Armenian, Azerbaijani, Ukrainian, Georgian, Turkmen or Moldovan locales, the payload collects cloud, registry, Git host, SSH, Docker, Kubernetes, Vault, password-manager, OS-keychain, wallet and AI coding tool credentials — including .claude/.credentials.json and .codex/auth.json, which Bitbison’s sandbox confirmed among 51 stores read. With AWS credentials it calls STS, enumerates Secrets Manager secrets and SSM parameters across 17 regions, and requests ECR tokens across 11. Inside GitHub Actions it hunts the runner worker process through /proc and pipes an embedded Python script through passwordless sudo to scrape secrets from worker memory — the technique from the March 2025 tj-actions/changed-files compromise — then kills the StepSecurity Harden-Runner container through the Docker socket and installs a privileged Alpine container granting the runner passwordless sudo. Results are encrypted with AES-256-GCM with RSA-OAEP-wrapped keys and posted to paleasbest[.]top/v1/api, falling back to public GitHub repositories (description project-utils, generated adjective-noun-number names, AutoSyncComplete commit prefix) or GitLab.

Persistence is a Python backdoor, sync.py, installed as a systemd user service (pkg-runtime) or LaunchAgent that hourly searches public GitHub commits for the marker msync-3f7a and executes any Base64-encoded URL carrying a valid RSA-PSS signature from the operator’s key. The signature covers the URL, not the downloaded script — a command channel with no third-party takeover path but also no victim-side revocation. The sync.py hash, the command marker, the commit prefix, and the wiper below are all byte-identical to the earlier testmetest build that armed 42 GitHub repositories; Socket assesses strutil-core as a later build with new infrastructure, and cautions that shared code from a toolkit public since May 2026 does not establish a shared operator. For the longer lineage, see our retrospective on the s1ngularity, Shai-Hulud and Teampcp campaigns and the June Miasma npm worm.

The agent config is the propagation path

The worm treats AI tooling as both credential source and execution trigger. Locally it copies itself to ~/.config/index.js and rewrites every writable settings.json, config.json, mcp.json and .aider.conf.yml it finds: MCP configurations gain a server named workspace-tools that runs the payload with node, and other configurations gain SessionStart hooks. With stolen Git credentials the same logic is committed into repositories — .github/init.js, .claude/settings.json, .cursor/mcp.json, Gemini settings, CLAUDE.md, AGENTS.md instructing agents to run a setup script first, and a .vscode/tasks.json that fires on folder open. Beyond RubyGems the bundle carries republishing routines for npm and PyPI, Artifactory credential harvesting, a fake CodeQL workflow exfiltrating secrets as a scan artifact, a GitLab cache_sync job requesting OIDC tokens, short-lived chore/ci- branches, and SSH spreading over ~/.ssh/known_hosts — all confirmed present in code, with no confirmed downstream publication from this gem.

Then the deterrent: a monitor called net-health-checker, installed only on the GitHub-token path, checks each stolen token every 45 seconds for 48 hours (its own log line claims 24). On HTTP 401 or 403 it runs rm -rf ~/; rm -rf ~/Documents. Revoking the token — the first thing a responder does — is exactly what triggers deletion, with the token sitting in plaintext and the service set to survive logout. Socket also ties a PyPI sibling to the same endpoint: pystr-codec 0.1.0–0.1.2, documented by Kamil Mańkowski (MAL-2026-18432), appeared 31 October between 22:21 and 23:32 UTC — the last one less than three hours before strutil-core 1.0.0 — using a .pth startup trigger and a separate build. The public download counter showed 89 installs, all on the clean 1.0.0 and most likely mirrors and scanners; because the payload persists outside the gem directory and steals long-lived credentials, uninstalling the gem does not undo an infection.

What to actually do

  1. If any system installed strutil-core@1.0.1, treat the host and every credential reachable from it as compromised: isolate first, then rotate cloud, registry, Git host, SSH, Vault, wallet and AI-tool credentials — do not merely revoke the GitHub token, since revocation fires the wiper.
  2. Hunt for the persistence markers before revoking anything: net-health-checker and pkg-runtime services, ~/.local/share/runtimes/sync.py, ~/.config/index.js, and unexpected workspace-tools MCP servers or SessionStart hooks in agent configs. Back up user data out-of-band first.
  3. Audit repositories touched from an infected host for committed .github/init.js, CLAUDE.md/AGENTS.md setup-script instructions, rogue MCP servers, and .vscode/tasks.json folder-open tasks — opening them in an AI tool can re-run the worm depending on workspace-trust settings.
  4. Pin and hash-verify install-time hooks as a class: extconf.rb, binding.gyp, preinstall scripts and .pth files execute before any review step. Scanner coverage that flagged this in 70 seconds is the exception; the five-hour window and the sibling PyPI packages argue for allowlisted registries and egress control on build hosts.

Our verification was read-only. We read Socket’s Threat Research report first-hand in full (Kush Pandya, 6 November 2026: timeline, Bun 1.2.18 install chain, injector byte-identity, 51-store collection, Runner memory scraping, Harden-Runner handling, exfil endpoints, sync.py backdoor, agent-config poisoning inventory, wiper mechanics, testmetest/Miasma lineage table, pystr-codec sibling, exposure window, and recommendations). The ruby-digger download count, Bitbison sandbox results, OpenSSF malicious-packages PR 1701, and MAL-2026-18432 attribution are carried via Socket’s report, which states them explicitly; Socket itself notes it did not test whether the collection endpoint is live. We installed nothing, ran no gem, queried no C2, and downloaded no payload. No victims are confirmed, and we state none. Brave Search was used for discovery only.

Sources: