Fake OnionClaw: Unravelling Layers to an Infostealer
A trojanized copy of the OnionClaw agent skill hid a Lua loader in a ZIP. Following it through a Polygon smart contract, two GitHub-hosted tasks and a second loader ends at a StealC v2-family infostealer. Every layer, and the indicators to hunt.

You may have recently seen LinkedIn posts about OnionClaw, a skill that lets your agents use The Onion Router, Tor. It's an interesting concept: have an agent proactively search for your organization's name on leak sites and other nefarious sites. Before I installed it, I had Claude run a security check against the codebase. A prompt as simple as "go review this repo for security issues" was enough to flag malicious code in the copy I was looking at.
Claude surfaced a malicious Lua script within the zipped file. That was the initial thread I had to pull. Where would this thread lead? What happens when someone runs it?
Layer 1 - rest.txt
The malicious repository under christinminor459 copied the original OnionClaw v2.1.13 snapshot, then added a single file: .github/ISSUE_TEMPLATE/Claw_Onion_1.9.zip. A closed pull request preserved 3dcdb04, dated April 6, 2026, the earliest commit I could recover containing the malicious ZIP. Its message reads like an ordinary bug fix:
fix: v2.1.13 — BUG-NEW --scrape 0 --out now writes file + WARN to stderr
The message contains the original project's bug-fix title, but added the zip. That April snapshot contains the exact archive I analyzed in October, including the malicious rest.txt. A newer commit date didn't mean a newer payload. April 6th is the earliest recovered commit date, but does not prove it was first uploaded on that date. It dates the rest.txt script, not whatever downstream activity may have occurred.
The ZIP contains three files in total:
Launcher.cmd- used to launchlua51.exewith therest.txtscript as a parameterlua51.exe- LuaJIT interpreterrest.txt- malicious loader written in Lua
Launcher.cmd content:
start lua51.exe rest.txt
rest.txt contains 300KB of obfuscated Lua. The .txt extension doesn't prevent execution because the launcher hands it to the interpreter as a parameter. Running Launcher.cmd starts this execution, simply viewing the repository does not execute the Lua content.
Static decoding shows code to collect host details, take a screenshot, and send both to the initial C2 server at http://217.119.129[.]99. The script also contains handlers for downloading and running files specified by the server. This is its role as a loader. A separate server-controlled branch can request Microsoft Defender exclusions.
The decoded script also contains persistence logic. If the server enables it, the script can copy its interpreter and Lua file under %LOCALAPPDATA%\ODM5\ and invoke schtasks to create a daily task to execute themselves. It chooses one of four task-name prefixes and appends _ODM5, producing names such as AdobeCreativeCloud_ODM5.
The same Lua script appears in register_codex_v3.1.zip, which MalwareBazaar labels SmartLoader and records as first seen on April 7, 2026. The two ZIPs have different hashes, but their rest.txt files match exactly. That ties this Lua script to a SmartLoader sample and gives us an idea of what might be next in the chain.
Layer 2 - Polygon
The threat actor also uses EtherHiding to supply a secondary address hosted on a Polygon smart contract.
It makes a read-only eth_call to 0x1823A9a0Ec8e0C25dD957D0841e3D41a4474bAdc, using function selector 0x3bc5de30. Queries to two independent RPC providers at Polygon block 95,019,999 returned a value that decoded to http://185.10.68[.]110. This IP provides the 3rd layer.

Figure 1. The recovered second Lua loader uses the same contract and selector as rest.txt. The code excerpt is statically decoded; its formatted request is expanded below it for readability.
Layer 3 - The task
The sandbox run of the ZIP, with Fakenet disabled, recorded a 413 from the embedded server, a successful Polygon RPC request and a POST to the fallback server with no recorded HTTP response. No downstream payload download was visible in its request list.
Header-only checks found a hard limit enforced by the embedded server. A declared body of 1,048,576 bytes got a 404 (Not Found) status code; but one byte more got a status code of 413 (Content Too Large). Both errors arrived before any body was sent. This measured rejection by declared size rather than a successfully processed upload.
The breakthrough came from a synthetic registration to the address that was provided in the Polygon contract. Using a 64×64 black BMP and corrected metadata resulted in a returned task pointing to 22/cd.txt in a second GitHub repo. At the time of writing the repo is showing as 2 days old (5 Oct 2026). Size alone wasn't proven to cause that success; the server's timing and state weren't controlled.
The Microsoft request was a distraction. The Lua connectivity check looks for a handle returned by InternetOpenUrlW but it doesn't explicitly require HTTP 200. This means a failed connection can stall the loader, but it isn't evidence that the command server has been destroyed/seized.
Layer 4 - Another Lua, Another Task
Task 840 told the client to download cd.txt, decode it, save it as dist.lua in Temp and start it. The download was hexadecimal text. Applying the loader's repeating XOR key recovered another obfuscated Lua program, this time identifying itself as loader 847.
The second Lua script contains similar daily task persistence logic. Its copy directory is %LOCALAPPDATA%\7d7752\, and its task names use a different set of prefixes with _7d7752, such as OfficeClickToRunTask_7d7752.
That Lua script contained 89.169.12[.]149. A synthetic registration using its settings returned task 838, pointing to 22/ip.txt and selecting %LOCALAPPDATA%\Programs\nodejs\node.exe as the destination.

Figure 2. Selected task fields from two separate synthetic registrations on October 6; times are UTC. Both links point to Sydneycondemnatory52/ssh. The responses choose the filenames, destinations and whether to start them.
Despite the node.exe filename, decoding ip.txt produced a Windows wrapper carrying an encrypted executable resource. Decrypting that resource with the AES key reconstructed from the wrapper's code recovered the inner executable, an infostealer. The saved cd.txt and ip.txt bytes also matched the GitHub blobs recorded at a pinned commit, preserving the link beyond the mutable main URLs.
Note: The captured responses were broken. Both Lua clients embed a 32-byte key, while the response fields decoded with a different, 16-byte key. Reproducing the clients' decoding and text substitutions offline produced invalid Lua syntax with the embedded key. Their parsing helper would return nil, and the caller would fail before downloading a task. The downloadable files themselves decoded with the original key.
Recovering the different response key let me continue the investigation past that break. The instructions were decoded offline, I retrieved the files they named and analyzed them. These particular replies would not let victims without modified code complete the chain. Why the keys differ, and whether other clients received compatible replies, remains unknown. This isn't the first time a threat actor has been lazy. Maybe next time they will infect themselves to prove the chain works.
Layer 5 - The Infostealer
The inner executable's decoded configuration and request builders identify http://144.31.57[.]121/. A GET request to that address returned 403 (Forbidden). A plaintext {} POST request also returned 403.
Although the client declares Content-Type: application/json, it sends Base64-encoded RC4 ciphertext containing JSON. Reconstructing the encoding and type=create registration fields produced HTTP 200 status code, a session token, and a collection configuration!

Figure 3. The Python excerpt is analyst-written protocol reconstruction. Below it are selected fields from the actual decoded response. The synthetic session token is redacted.
The configuration listed 28 browser entries, 120 extension entries covering 119 unique IDs, and 50 file-collection rules. Its targets included browser cookies and logins, wallet and password-manager extension storage, Telegram, .aws, .azure and the MSAL cache. Steam, Discord, Outlook and Foxmail collection were enabled. Browser history, WinSCP and native screenshot collection were disabled for this session. The earlier Lua loaders have their own screenshot behavior.
I then sent a 25-byte file. The collector acknowledged both upload_file and done with success. Those replies confirm acceptance by the application, but didn't identify where the file was stored.
The registration fields, separate RC4 keys, collection settings, chunked file-upload fields and varying hexadecimal response fields align with published StealC v2 research. That supports a strong StealC v2-family or a variation for the infostealer family. The exact version and operator remain unknown.
Layer 6 - Another Request
The configuration included loader: false. I initially read that as disabling further downloads. Tracing the native code corrected that: the flag controls when the loader request happens. False selects the request after done; true selects it earlier.

Figure 4. Annotated disassembly shows the post-completion branch. The response underneath came from the synthetic session on October 6 at 18:01:24 UTC: success, with an empty loader array.
That final request offered no further payload URLs to this session at that time. It doesn't tell us what another machine or a later session would receive.
I also checked for a public route to the uploaded test file. Common panel paths and candidates from the StealC articles returned the same generic 403 as random nonexistent paths. Passive DNS and URL history checks supplied no usable route either. This investigation reconstructed the chain from saved files and separate synthetic exchanges, not an observed end-to-end execution.
Indicators
These indicators come from the saved samples, repository history and October 5/6th exchanges. They identify files and activity worth investigating.
The JSON indicator set and CSV version include the indicators below, exact download URLs and redirects, protocol markers and decoding keys. Each entry identifies its role and whether it was recovered from code, derived from a task, or observed in a response.
File hashes
All hashes are SHA-256. The encoded downloads and the files produced by decoding them have different hashes.
| File or recovered content | Bytes | SHA-256 |
|---|---|---|
Claw_Onion_1.9.zip | 580,808 | a4d836eec38f35f790522da33cdb745a6623ce6802744aae9dac03e8d15cedcc |
Launcher.cmd | 26 | d71cdcdd390fb300904a7fdc31d56c0a3a728350eca2debf3d00d7932dc14839 |
Bundled LuaJIT interpreter, lua51.exe | 872,448 | 3ee51b5f9579b775e87edb23c238650a512c2fe46efc06694cd906a771727f97 |
Original Lua loader, rest.txt | 299,679 | 58448cb79f50cf71408e7eabfe7e0718e77a09910fd59963f83840b67faa245f |
cd.txt, encoded HTTP body | 396,876 | c506fe9d218d6ff18e3ffc1f45e2f245b019b733e56dc6dd508ff0bb219a0c9b |
Decoded second Lua loader, dist.lua | 198,438 | 06bed48e1b04e9bb0b8a62cc3c733fb52a4a44496f6461795623dae61952a26f |
ip.txt, encoded HTTP body | 2,422,784 | 4f5414d1fb4c432bf706f626b0a83a6d60ada0e39efab5ebc62c86a1d2b76166 |
Decoded Windows wrapper, designated node.exe | 1,211,392 | e8a038fdb6adfbea7740e9654c3dff46f3d58cfa4c1eb08850912e5810b46b0a |
| Embedded infostealer, decrypted with padding removed | 634,880 | d0bb735a344aab2dc5b874a9c928ce0bf5815c0e573ab313f8552b28aabc0601 |
The interpreter and short batch command need surrounding evidence; neither is unique proof of compromise. The inner infostealer was recovered from the wrapper and may never appear as a separate file on disk. The Lua code can also pad copied files, and task 838 requests padding for node.exe, so a deployed copy may have a different size and hash.
Repositories and download paths
| Repository | Relevant path | Role |
|---|---|---|
github[.]com/christinminor459/OnionClaw | .github/ISSUE_TEMPLATE/Claw_Onion_1.9.zip | Repository containing the analyzed archive |
github[.]com/Sydneycondemnatory52/ssh | 22/cd.txt | Encoded second Lua loader supplied by task 840 |
github[.]com/Sydneycondemnatory52/ssh | 22/ip.txt | Encoded Windows wrapper supplied by task 838 |
The task download URLs were:
hxxps://github[.]com/Sydneycondemnatory52/ssh/raw/refs/heads/main/22/cd.txt
hxxps://github[.]com/Sydneycondemnatory52/ssh/raw/refs/heads/main/22/ip.txt
Both redirected to raw.githubusercontent[.]com/Sydneycondemnatory52/ssh/refs/heads/main/22/ with the corresponding filename. Search proxy and browser history for the full account and path, not just the shared GitHub hostname. The archive was pinned to commit 524d4aa245cdc172b894f68b9397c885bcfb985b; the two download bodies matched blobs at 3a21ace0edddbc2f5163f08ea0c5ee1321c7a354. These record the analyzed versions, not what mutable URLs serve later. The original JacobJandon/OnionClaw project is a comparison source, not an IOC for this added archive.
Network indicators
| HTTP address | Role in this investigation |
|---|---|
217.119.129[.]99 | Initial server embedded in rest.txt |
185.10.68[.]110 | Server returned by Polygon at block 95,019,999 |
89.169.12[.]149 | Initial server embedded in recovered dist.lua |
144.31.57[.]121 | Native collector; encoded POSTs to / |
Both Lua loaders construct the registration path /api/NTE3YjdjNWU1NjYzNjU2YTA1N2Y= and task-report path /task/NTE3YjdjNWU1NjYzNjU2YTA1N2Y=. The latter reports a task ID; a request to it does not by itself prove the task executed successfully. Loader IDs are 839 and 847; the recovered download task IDs are 840 and 838.
The Polygon indicator is the combination of contract 0x1823A9a0Ec8e0C25dD957D0841e3D41a4474bAdc, selector 0x3bc5de30 and method eth_call on Polygon mainnet, chain ID 137. The IP response is time-specific. The native collector uses Base64-encoded RC4 JSON and request types create, upload_file, done and loader.
Host artifacts
| Artifact | What it means |
|---|---|
lua51.exe rest.txt | Initial launch command; correlate with the archive members and hashes |
Lua interpreter command line containing %TEMP%\dist.lua | Second Lua launch, if the first task is processed successfully |
%LOCALAPPDATA%\Programs\nodejs\node.exe with argument NTE3YjdjNWU1NjYzNjU2YTA1N2Y= | Executable path and argument selected by task 838; a legitimate Node.js filename alone is insufficient |
<Documents>\<MachineGuid>.json | Cached outer server response, potentially preserving task URLs and settings |
%TEMP%\212a1642782b3933 | Empty marker derived from dist.lua |
%TEMP%\2b2c015378223437 | Empty marker derived from node.exe |
%LOCALAPPDATA%\ODM5\ODM5.exe | Conditional persistence copy for original loader 839 |
%LOCALAPPDATA%\7d7752\7d7752.exe | Conditional persistence copy for second loader 847 |
rest.txt or dist.lua beside the respective persistence executable | Companion script copied by the script-launch persistence branch; other launch modes can preserve different companion filenames |
lua51.dll within either persistence directory | Conditional companion DLL copy when the DLL-load check succeeds; no such DLL is present in the analyzed ZIP |
The Temp markers are created before payload execution, so their presence is evidence of task processing, not proof the payload ran. Documents and Temp are resolved through Windows APIs and may be redirected.
The scheduled-task names constructed by the two Lua scripts are listed below. Creation of these tasks was not observed:
| Original loader 839 | Second loader 847 |
|---|---|
AdobeCreativeCloud_ODM5 | OfficeClickToRunTask_7d7752 |
CloudDrive_ODM5 | AdobeGCInvoker_7d7752 |
AudioManager_ODM5 | OneDriveStandaloneUpdater_7d7752 |
TaskScheduler_ODM5 | CCleanerSkipUAC_7d7752 |
These are alternatives selected by the code, not eight tasks observed on a victim. Other conditional branches use task names derived from filenames, modify HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Run and HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run, or write C:\Windows\Setup\Scripts\ErrorHandler.cmd alongside a highest-privilege task named Setup referencing C:\Windows\System32\oobe\Setup.exe. Those Windows paths are not inherently malicious; inspect their contents, values, task actions and creating process.
Also check for a PowerShell request using Add-MpPreference -ExclusionPath $env:SystemDrive -ExclusionExtension .exe, .dll -Force. This is a configurable elevation/exclusion attempt, not a demonstrated change on a victim.
Both Lua loaders contain a named mutex:
| Component | Mutex |
|---|---|
Original rest.txt | h73l6jwoi76f57ulqfmtwdt7kysfymvxrenk8bx1b80xve4zzml7ofpyqhyzs189mrp4u5npw9 |
Recovered dist.lua | 5m9gr3w9a1jb3hvgth99giq6qxt73mr4wid7z34k7i3wf0u |
Wrapping up
What started as a routine "review this repo" prompt ended six layers down at an infostealer, with a smart contract, two GitHub accounts and four servers along the way. If you're adopting agent skills, treat them like any other third-party code: check where a skill came from, compare it against the original project, and look at what it ships before you run it. A fork with one extra ZIP was all this took.
The indicators above will go stale as the operator rotates servers and repositories. The behavior won't:
- A Lua interpreter launched from a freshly downloaded archive
- A blockchain RPC call standing in for a C2 address
- A
node.exethat isn't Node - Scheduled tasks dressed up as Adobe and OneDrive updaters
That's what is worth detecting, and the real question is whether your telemetry and detections would actually see it.
That's the question Spectrum is built to answer. When a threat like this surfaces, Spectrum checks it against the technologies in your environment, maps what you'd need to detect in the Threat Graph, and authors detections tailored to your stack. Want to know whether you'd have caught fake OnionClaw? Connect Spectrum and see your own coverage.