A malicious Ruby gem runs a credential-stealing worm during installation, poisons AI coding tools, and can wipe files when a stolen GitHub token is revoked.
Socket’s Threat Research Team analyzed strutil-core version 1.0.1 , a malicious RubyGems package that runs a 5.48 MB credential-stealing worm when it is installed. The RubyGems account jamnajiea published a clean 1.0.0 on November 1, 2026, and pushed the malicious 1.0.1 less than two hours later. Socket’s AI Scanner flagged it 70 seconds after publication, and RubyGems removed both versions five hours after the malicious release.
strutil-core was a standalone gem, not a compromised existing package. We found no public projects that depended on it, and we have not confirmed any victims or any packages the worm infected from it. The payload is what makes it worth a closer look. It is a new build of the testmetest worm, a member of the Miasma family that grew out of the publicly released Shai-Hulud toolkit, and strutil-core is the only RubyGems package we have tied to it. Bitbison’s sandbox run confirms that the gem runs the payload at install time and reads credential files. The rest of this post comes from static analysis of the code, not from activity seen on victim systems.
Install-time execution: a native extension script, ext/strutil-core/extconf.rb , downloads the Bun JavaScript runtime and runs the bundled ext/strutil-core/index.js during gem install .
Credential theft: the payload collects cloud, CI, package registry, SSH, AI coding tool and wallet credentials, then exfiltrates them to paleasbest[.]top with GitHub and GitLab fallbacks.
Self-propagation: it contains republishing routines for RubyGems, npm and PyPI, plus JFrog Artifactory, GitHub, GitLab and SSH spreading code.
Persistence and AI tool poisoning: it installs a Python backdoor that downloads and runs any script the threat actor announces in a signed GitHub commit message, and writes MCP servers, session hooks and agent instruction files into local configuration and repositories.
Conditional wiper: if a stolen GitHub token starts returning HTTP 401 or 403, a background monitor deletes the user’s directory.
Packaged with the worm’s own tooling: the gem’s installer is identical to the output of the worm’s RubyGems injection module.
A String Helper With a JavaScript Worm Attached
The gemspec describes strutil-core as “Lightweight string manipulation helpers: slug, truncate, normalize.” The Ruby library, strutil_core.rb , implements only one of those helpers:
The gem also declares a native extension. RubyGems runs extconf.rb for native extensions during installation so that the extension can generate a Makefile. In strutil-core , ext/strutil-core/extconf.rb contains no build logic. On Linux and macOS, x86-64 or ARM64, it creates /tmp/.b_ , downloads Bun 1.2.18 from Bun’s official GitHub releases, and runs the adjacent index.js :
The script then writes a Makefile whose all , install and clean targets do nothing, so the installation finishes without errors. No application has to require the gem. Running gem install strutil-core -v 1.0.1 , or bundle install against a Gemfile that resolves to it, is enough.
The Gem Is the Worm’s Own RubyGems Injector Output
The decoded payload contains a RubyGems propagation module that logs with an [rg] prefix. Given a stolen RubyGems API key, it lists the gems the key can publish, downloads each one, unpacks data.tar.gz , and adds two files: the payload as ext/ /index.js and an installer as ext/ /extconf.rb . It then edits only the gemspec metadata, bumping the version and registering the new extension:
The rebuilt gem is pushed to the RubyGems API with a User-Agent that imitates the gem command-line client:
The installer text comes from a template function in the same bundle. Filling that template with the bundle’s own constants ( index.js and bun-v1.2.18 ) produces a 1,030-byte file with SHA-256 c5264f3b9cf2f95c83a70b7dd7430aab8abc262b853dbbcd5386c3a8a9c74769 . That is identical to the extconf.rb shipped in [email protected] .
This also explains a detail that would otherwise look like sloppy packaging. The library still declares VERSION = '1.0.0' inside a gem published as 1.0.1 , because the injector changes the gemspec version but never edits the gem’s existing Ruby source. Bitbison’s sandbox run of 1.0.0 , published by the same account 1 hour and 45 minutes earlier, recorded no malicious behavior. The sequence fits a clean seed release followed by an injected patch release.
Socket’s diff between 1.0.0 and 1.0.1 shows that the injector also moves files. After unpacking data.tar.gz , it treats the first directory it finds as the package root and repacks only that directory’s contents. In strutil-core that directory was lib , so the unchanged lib/strutil_core.rb reappears at the root of 1.0.1 , and anything else at the top level of the original release would be dropped. This matches npm, where every tarball keeps its files under a single package/ directory, and the same logic appears in the June Miasma module.
The injector is not new. A deobfuscated copy of the June 2026 Miasma payload, published on GitHub for defensive research , contains the same module in 05-rubygems-compromise/00-rubygems-package-injection.js . Thirty-three of the 36 non-empty lines in the strutil-core installer appear verbatim in that June template. The November build changes four things:
Bun 1.3.13 became 1.2.18 .
The Bun download URL is split across a _gh_base variable, which breaks static signatures that match the full github.com/oven-sh/bun/releases string.
The publish User-Agent changed from RubyGems/3.5.0 to RubyGems/3.5.16 ruby/3.2.2 (x86_64-linux) .
The module gained [rg] progress logging and a dry-run mode.
Two Layers of String Obfuscation
ext/strutil-core/index.js is a 5,475,376-byte Bun bundle. The first layer is a rotated 4,069-entry string table behind a decoder function, used at 15,715 call sites. The second layer protects 867 sensitive strings through a global function named fc3c26f84 . It derives a key with PBKDF2-HMAC-SHA256 (200,000 iterations), uses a 16-byte nonce per string, and reverses three substitution rounds. Nineteen embedded components (shell, Python, Node.js, YAML, JSON, C source and a Windows DLL) are additionally gzip-compressed and encrypted with AES-256-GCM.
After decoding, the bundle’s configuration block names the primary collection endpoint and the GitHub command markers:
Early in its main routine, the payload checks the system locale through Intl.DateTimeFormat and the LC_ALL , LC_MESSAGES , LANGUAGE and LANG variables. It exits on Russian, Belarusian, Kazakh, Uzbek, Kyrgyz, Tajik, Armenian, Azerbaijani, Ukrainian, Georgian, Turkmen and Moldovan locales.
Credential Collection
The collectors read credential files and environment variables for cloud providers, package registries, Git hosts, SSH, Docker, Kubernetes, Vault, password managers, operating system keychains, cryptocurrency wallets and AI coding tools. Bitbison’s sandbox recorded reads from 51 credential stores, including .aws/credentials , .npmrc , .pypirc , .gem/credentials , .docker/config.json , .kube/config , .claude/.credentials.json and .codex/auth.json .
With AWS credentials, the payload calls STS for the caller identity, enumerates and reads Secrets Manager secrets and SSM parameters across 17 regions, and requests ECR authorization tokens across 11 regions. Separate providers cover Azure, Google Cloud, Kubernetes and HashiCorp Vault.
GitHub Actions Runner Memory
When it runs inside GitHub Actions, the payload looks for the runner’s worker process by scanning /proc/*/cmdline :
It then pipes an embedded Python script to sudo python3 . The script reads /proc/ /maps and /proc/ /mem to pull secrets out of the worker’s memory, the same technique used in the March 2025 tj-actions/changed-files compromise. GitHub-hosted runners grant passwordless sudo , so the read succeeds by default. The payload also disables StepSecurity Harden-Runner, an agent that monitors and filters network traffic on GitHub Actions runners. Through the Docker socket, it kills the Harden-Runner container and starts a privileged Alpine container that writes runner ALL=(ALL) NOPASSWD:ALL into /etc/sudoers.d , which gives the runner user passwordless sudo even if Harden-Runner had disabled it. It also points the stepsecurity.io hostnames to 127.0.0.1 and ::1 in /etc/hosts and replaces /etc/resolv.conf with the public resolvers 8.8.8.8 and 1.1.1.1 .
Results are encrypted with AES-256-GCM, with the key wrapped using RSA-OAEP-SHA256, and posted to hxxps://paleasbest[.]top/v1/api after an empty-JSON health probe. If that path fails, the payload creates a public GitHub repository with the description project-utils and a generated name of the form adjective, plural noun, number (for example smart-workers-48213 ), or falls back to GitLab. GitHub commits carrying stolen tokens use the AutoSyncComplete prefix.
A Python Backdoor Controlled Through Signed GitHub Commits
The embedded sync.py (8,204 bytes, SHA-256 c7ef186aa59b67d9b198a14f6dc8aaee6dd86510ac7c37d28f65d310cd846411 ) is installed to ~/.local/ /runtimes/sync.py and registered as the systemd user service pkg-runtime on Linux or the LaunchAgent com.user.pkg-runtime on macOS. Every hour it searches public GitHub commits for msync-3f7a and reads a Base64-encoded URL from the commit message. It accepts only URLs carrying a valid RSA-PSS signature made with the threat actor’s private key, so nobody else can issue commands through the same marker. For each accepted URL, it downloads and runs the Python script there. It records processed URLs in /var/tmp/.fontconfig_state . The signature covers the URL, not the downloaded script.
AI Coding Tools and IDEs as Execution Triggers
Locally, the payload writes a copy of itself to ~/.config/index.js and searches the directory for writable settings.json , config.json , mcp.json and .aider.conf.yml files that belong to AI coding tools. MCP configurations gain a server named workspace-tools that runs the payload with node . Other configurations gain SessionStart hooks.
With stolen Git credentials, the same logic is committed into repositories. The payload writes .github/init.js , .claude/settings.json , .cursor/mcp.json , .gemini settings, CLAUDE.md , AGENTS.md and a .vscode/tasks.json task that runs on folder open. The MCP entry it commits looks like this:
The generated AGENTS.md tells coding agents to run a setup script before their first command. Opening an affected repository in an AI coding tool or VS Code can re-run the worm, depending on that tool’s workspace trust and approval settings.
Propagation Beyond RubyGems
In addition to the RubyGems injector, the bundle contains republishing routines for npm and PyPI, code that harvests and uses JFrog Artifactory credentials, a fake “CodeQL” GitHub Actions workflow that uploads secrets as a scan-output artifact, a GitLab CI job named cache_sync that requests OIDC tokens, short-lived chore/ci- branches with skip-checks:true commits, and SSH code that enumerates hosts from each user’s ~/.ssh/known_hosts . We confirmed that these routines are present in the code. We have not confirmed any successful downstream publication from this gem.
A Wiper Tied to Token Revocation
When the GitHub exfiltration path accepts a stolen personal access token, the payload installs a monitor called net-health-checker and hands it the token and a fixed handler:
The embedded shell installer writes ~/.local/bin/net-health-checker.sh , stores the token in plaintext at ~/.config/net-health-checker/token , and registers a systemd user service (with loginctl enable-linger , so it survives logout) or a LaunchAgent. The monitor runs for 172,800 seconds (48 hours), even though its own log message says “24h TTL reached”:
Every 45 seconds it checks the token against GitHub:
A 401 or 403 response runs the stored handler:
Revoking the token is exactly what a responder would do first, and it is what triggers the deletion. The monitor only installs on the GitHub-token path, so not every infected host has it.
How This Build Relates to testmetest and Miasma
SafeDep documented the testmetest npm worm in October and tied it to the June Miasma family. JFrog Security Research later linked it to 42 hijacked GitHub repositories. strutil-core shares concrete components with testmetest, not just general behavior.
sync.py , identical (SHA-256 c7ef186a... ).
The msync-3f7a command marker and AutoSyncComplete commit prefix.
net-health-checker with the same 45-second interval, 48-hour lifetime, 401/403 trigger and deletion command, and pkg-runtime at the same paths.
The project-utils repository description and the adjective-noun-number repository naming that produced names such as smart-workers-48213 and stable-scripts-9965 in earlier reporting.
Bun 1.2.18, the GitLab cache_sync job, the fake CodeQL workflow and scan-output artifact, .github/init.js , Runner.Worker memory scraping, Harden-Runner handling, SSH known_hosts spreading, and the locale exit.
Different in this build:
The C2 is paleasbest[.]top rather than aiarmor[.]website . The bundle contains none of the GitHub operator accounts named in earlier reporting.
The delivery is a RubyGems extconf.rb hook rather than npm’s binding.gyp , postinstall or load-time native addon triggers.
The full 5.48 MB bundle ships as index.js , closer to June Miasma’s large root loader than to testmetest’s 14.8 KB loader with a separate encrypted payload.
The encryption layers are a PBKDF2-keyed string cipher and AES-256-GCM assets, rather than the XOR and Brotli layers SafeDep described.
The embedded Windows N-API addon, node_win.napi.node , has a compile timestamp of October 27, 2026 at 21:41:31 UTC and a hash that differs from the testmetest addons published so far.
We assess strutil-core as a later build of the testmetest worm, with new infrastructure and a RubyGems delivery path. The Shai-Hulud source has been public since May 2026, so shared code does not establish that the same operator is behind every build.
Infrastructure, Accounts and a PyPI Sibling
Collection endpoint: hxxps://paleasbest[.]top/v1/api . We did not test whether it is live.
Publisher: RubyGems account jamnajiea , which now owns no gems. No GitHub account uses that name.
Gemspec metadata: author string “Mark Tillman”, homepage github.com/marktillman/strutil-core . The repository does not exist. We found nothing linking the gem to any real person or account of that name.
PyPI sibling: pystr-codec 0.1.0, 0.1.1 and 0.1.2 use the same paleasbest[.]top/v1/api endpoint and a .pth startup trigger, as documented by Kamil Mańkowski ( MAL-2026-18432 ). Socket ingested those versions on October 31 at 22:21, 22:46 and 23:32 UTC. The last one arrived less than three hours before strutil-core 1.0.0. The strutil-core bundle does not match any pystr-codec file, so the two packages carry separate builds.
Exposure and Timeline
October 31, 22:21 to 23:32 UTC: pystr-codec 0.1.0 to 0.1.2 appear on PyPI.
November 1, 02:19:56 UTC: strutil-core 1.0.0 published by jamnajiea .
November 1, 04:05:04 UTC: strutil-core 1.0.1 published.
November 1, 04:05:29 UTC: the public ruby-digger metadata tracker records 89 total downloads, with 0 on 1.0.1. Those downloads accrued to the clean 1.0.0, most likely from mirrors and automated scanners.
November 1, 04:06:14 UTC: Socket’s AI Scanner flags ext/strutil-core/index.js as malware, 70 seconds after publication.
November 1, 08:52 to 09:13 UTC: RubyGems removes both versions, per Bitbison. The RubyGems compact index places the removal after another gem’s 08:22:55 UTC release, which is consistent.
November 1, 19:16:47 UTC: Samy Al Bahra opens OpenSSF malicious-packages PR 1701 .
The public window was five hours. We do not know how many systems installed 1.0.1 , and download counters are not victim counts. Because the payload persists outside the gem directory and steals long-lived credentials, uninstalling the gem does not undo an infection.
If any system installed [email protected] , treat that system and every credential reachable from it as compromised. Isolate the host first. Stop and remove net-health-checker and pkg-runtime , along with their systemd units or LaunchAgents, before revoking the GitHub token stored in ~/.config/net-health-checker/token , because the monitor deletes the directory when that token stops working. Then rotate credentials from a clean device and rebuild the host.
Check Gemfile.lock files, CI caches and container images for strutil-core . Rotate RubyGems, npm, PyPI, GitHub, GitLab, cloud, SSH, Vault, Kubernetes and AI provider credentials that were available to any affected machine or runner. Review packages you maintain for unexpected patch releases, especially gems that gained an ext/*/extconf.rb or a new extensions: entry you did not add. Remove unrecognized workspace-tools MCP entries, SessionStart hooks, .github/init.js , AGENTS.md , CLAUDE.md and .vscode/tasks.json changes from repositories and directories.
Hunt for the hashes below, for the persistence artifacts described above ( pkg-runtime , net-health-checker , ~/.config/index.js and /var/tmp/.fontconfig_state ), for bun run launched from /tmp/.b_ by a Ruby process, for sudo python3 reading /proc/ /mem on CI runners, and for egress to paleasbest[.]top . your GitHub organizations for new public repositories described as project-utils , branches named chore/ci-* , commits containing skip-checks:true or AutoSyncComplete , and workflows named CodeQL that upload a scan-output artifact. Treat any gem whose extconf.rb has SHA-256 c5264f3b... as output of this injector.
Socket detects threats like this across RubyGems and the other ecosystems the worm targets, including native extension scripts that download and run code at install time, obfuscated payloads and credential collection. The Socket GitHub App scans pull request dependency changes and flags malicious gems before merge. The Socket CLI enforces allow and deny rules in CI pipelines. Socket Firewall blocks known malicious packages before they are fetched. Socket MCP prevents AI-assisted coding workflows from introducing suspicious dependencies into your codebase.
T1195.002 Compromise Software Supply Chain
T1059.007 Command and Scripting Interpreter: JavaScript
T1059.004 Command and Scripting Interpreter: Unix Shell
T1059.006 Command and Scripting Interpreter: Python
T1105 Ingress Tool Transfer
T1027 Obfuscated Files or Information
T1140 Deobfuscate/Decode Files or Information
T1614.001 System Location Discovery: System Language Discovery
T1552.001 Unsecured Credentials: Credentials In Files
T1555 Credentials from Password Stores
T1528 Steal Application Access Token
T1003.007 OS Credential Dumping: Proc Filesystem
T1562.001 Impair Defenses: Disable or Modify Tools
T1548.003 Abuse Elevation Control Mechanism: Sudo and Sudo Caching
T1543.002 Create or Modify System Process: Systemd Service
T1543.001 Create or Modify System Process: Launch Agent
T1102.001 Web Service: Dead Drop Resolver
T1567.001 Exfiltration Over Web Service: Exfiltration to Code Repository
T1041 Exfiltration Over C2 Channel
T1485 Data Destruction
Indicators of Compromise
RubyGems account: jamnajiea
Gemspec author string: Mark Tillman
Gemspec homepage: github.com/marktillman/strutil-core (nonexistent repository)
hxxps://paleasbest[.]top/v1/api
File Hashes (SHA-256)
strutil-core-1.0.1.gem : 3b3f6e1bde0b28b015030a81a6d805b5b5306dc3f021e1102123971cfd83acc0
ext/strutil-core/index.js : 306c1deec402e8b4a6478e7f0fe02194bcd224d37d8fc6ba61ad1951e8abd7a9
ext/strutil-core/extconf.rb : c5264f3b9cf2f95c83a70b7dd7430aab8abc262b853dbbcd5386c3a8a9c74769
strutil_core.rb : 66c1c6b6b2c47e96fa5fd328338b939e1360511ab5eb2dda6d9112f592259d6b
Embedded sync.py : c7ef186aa59b67d9b198a14f6dc8aaee6dd86510ac7c37d28f65d310cd846411
Embedded net-health-checker installer: e29a5319888319b51345ab8e3e009938ee5c2b9ae5d6bdd053f4f44c98f309d3
Embedded Windows addon node_win.napi.node : c78daf27c42c3929758b24c2ef0baf65eea81b26b0fddfe8a5b748672d40d5f5
pystr-codec 0.1.0, 0.1.1, 0.1.2 (PyPI, same collection endpoint)
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
