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.

13 min read
Anthony Jirouschek
Anthony JirouschekLinkedIn

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 launch lua51.exe with the rest.txt script as a parameter
  • lua51.exe - LuaJIT interpreter
  • rest.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.

Decoded Lua constants and the Polygon eth_call request.

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.

Selected fields from decoded tasks 840 and 838, including download URLs and destination names.

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!

Analyst request-encoding code and selected fields from the collector's successful registration response.

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.

Native disassembly showing the loader call after done, followed by the decoded empty loader response.

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 contentBytesSHA-256
Claw_Onion_1.9.zip580,808a4d836eec38f35f790522da33cdb745a6623ce6802744aae9dac03e8d15cedcc
Launcher.cmd26d71cdcdd390fb300904a7fdc31d56c0a3a728350eca2debf3d00d7932dc14839
Bundled LuaJIT interpreter, lua51.exe872,4483ee51b5f9579b775e87edb23c238650a512c2fe46efc06694cd906a771727f97
Original Lua loader, rest.txt299,67958448cb79f50cf71408e7eabfe7e0718e77a09910fd59963f83840b67faa245f
cd.txt, encoded HTTP body396,876c506fe9d218d6ff18e3ffc1f45e2f245b019b733e56dc6dd508ff0bb219a0c9b
Decoded second Lua loader, dist.lua198,43806bed48e1b04e9bb0b8a62cc3c733fb52a4a44496f6461795623dae61952a26f
ip.txt, encoded HTTP body2,422,7844f5414d1fb4c432bf706f626b0a83a6d60ada0e39efab5ebc62c86a1d2b76166
Decoded Windows wrapper, designated node.exe1,211,392e8a038fdb6adfbea7740e9654c3dff46f3d58cfa4c1eb08850912e5810b46b0a
Embedded infostealer, decrypted with padding removed634,880d0bb735a344aab2dc5b874a9c928ce0bf5815c0e573ab313f8552b28aabc0601

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

RepositoryRelevant pathRole
github[.]com/christinminor459/OnionClaw.github/ISSUE_TEMPLATE/Claw_Onion_1.9.zipRepository containing the analyzed archive
github[.]com/Sydneycondemnatory52/ssh22/cd.txtEncoded second Lua loader supplied by task 840
github[.]com/Sydneycondemnatory52/ssh22/ip.txtEncoded 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 addressRole in this investigation
217.119.129[.]99Initial server embedded in rest.txt
185.10.68[.]110Server returned by Polygon at block 95,019,999
89.169.12[.]149Initial server embedded in recovered dist.lua
144.31.57[.]121Native 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

ArtifactWhat it means
lua51.exe rest.txtInitial launch command; correlate with the archive members and hashes
Lua interpreter command line containing %TEMP%\dist.luaSecond 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>.jsonCached outer server response, potentially preserving task URLs and settings
%TEMP%\212a1642782b3933Empty marker derived from dist.lua
%TEMP%\2b2c015378223437Empty marker derived from node.exe
%LOCALAPPDATA%\ODM5\ODM5.exeConditional persistence copy for original loader 839
%LOCALAPPDATA%\7d7752\7d7752.exeConditional persistence copy for second loader 847
rest.txt or dist.lua beside the respective persistence executableCompanion script copied by the script-launch persistence branch; other launch modes can preserve different companion filenames
lua51.dll within either persistence directoryConditional 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 839Second loader 847
AdobeCreativeCloud_ODM5OfficeClickToRunTask_7d7752
CloudDrive_ODM5AdobeGCInvoker_7d7752
AudioManager_ODM5OneDriveStandaloneUpdater_7d7752
TaskScheduler_ODM5CCleanerSkipUAC_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:

ComponentMutex
Original rest.txth73l6jwoi76f57ulqfmtwdt7kysfymvxrenk8bx1b80xve4zzml7ofpyqhyzs189mrp4u5npw9
Recovered dist.lua5m9gr3w9a1jb3hvgth99giq6qxt73mr4wid7z34k7i3wf0u

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.exe that 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.