akj.io

15 C2 rotations, a test contract returning localhost, and a crib-drag attack on an encrypted stealer payload

Published
Reading
8 min

I stumbled onto a GitHub repository distributing malware through fake CVE proof-of-concept exploits. I reported it to GitHub but also got a bit curious.

After digging a bit I found out the ZIPs contained a Windows LuaJIT binary and an obfuscated Lua payload.

Netskope had already published a detailed analysis of the campaign back in March, identifying the threat actor as "TroyDen" and the malware family as SmartLoader. Hexastrike made a report covering 109 fake repositories in April.

Both reports cover the delivery mechanism, the Prometheus Lua obfuscation, the screenshot exfiltration, and a handful of C2 IP addresses.

Of course I only found these reports after doing a lot of these discoveries myself. I don't remember how I found out it was the "TroyDen" actor, but it was nice seeing I wasn't the only one.

Digging into the blockchain contract behind the C2 gave me the full infrastructure history. From there I found the operator's development artifacts and cracked the encrypted dead-drop to extract the StealC stealer binary.

The Polygon C2 mechanism

SmartLoader resolves its C2 address by querying a Polygon smart contract. The malware builds a JSON-RPC eth_call request, sends it to a public RPC endpoint over HTTPS, and reads back the stored URL.

Netskope didn't mention this mechanism. Hexastrike documented the contract address (0x1823A9a0Ec8e0C25dD957D0841e3D41a4474bAdc) and noted it queried polygon.drpc.org, but didn't investigate the contract itself.

The contract is simple: a string storage variable with getter and setter functions. The operator calls setMessage(url) to rotate the C2. Every rotation is a permanent on-chain transaction. This gives us something no traditional C2 infrastructure provides: a complete, tamper-proof history of every server the campaign has ever used; Thanks! But it's a pretty smart way to avoid getting locked out from the attacker's point of view.

I queried historical block states across the Polygon chain to reconstruct every C2 value since deployment:

#DateC2 IPSubnet
12025-11-2593.123.39.74
22025-12-1284.21.189.135
32025-12-22151.243.113.70
42026-01-01144.31.219.15144.31 (3x)
52026-01-26213.176.72.204
62026-02-0989.169.12.16089.169.12 (4x)
72026-02-2889.169.12.24189.169.12 (4x)
82026-03-19217.119.129.76
92026-04-05144.31.57.65144.31 (3x)
102026-04-1689.169.12.14989.169.12 (4x)
112026-05-0489.169.12.14089.169.12 (4x)
122026-05-14144.31.57.67144.31 (3x)
132026-06-0485.137.52.21
142026-06-21185.10.68.98
152026-07-1483.97.20.150Current

15 rotations over 8 months. The operator returns to the same hosting providers: the 89.169.12.x subnet appears four times, 144.31.x.x three times. The most recent rotation was three days before this investigation.

The fallbacks

The Polygon query travels over HTTPS. If every public RPC endpoint is unreachable, the malware falls back to a hardcoded IP that never appears on-chain. Running two payload variants under Wine revealed different fallback addresses:

  • Big variant (cdef.txt): fallback 85.209.129.2
  • Small variant (license.txt): fallback 213.176.73.159

The 85.209.129.2 address sits in the same /24 as Netskope's unconfirmed IOC 85.209.129.236. The small variant's fallback 213.176.73.159 is in the same /24 as Netskope's confirmed 213.176.73.130. Same operator, same hosting blocks, but segmented per payload variant.

These fallback IPs only activate when the Polygon query fails. In a standard Windows sandbox with internet access, the blockchain query succeeds and the fallback is never used.

Five RPC endpoints

The small variant exposed the full RPC fallback chain through HTTPS CONNECT tunnel attempts captured by my intercepting proxy:

  1. polygon.drpc.org
  2. polygon.publicnode.com
  3. polygon-mainnet.gateway.tatum.io
  4. polygon-public.nodies.app
  5. rpc-mainnet.matic.quiknode.pro

Blocking polygon-rpc.com (the obvious first target) does nothing. The malware cycles through five alternatives before falling back to the hardcoded IP. Blocking all nodes is not really a good way to avoid exfil of your data.

But the smart contract does make it easy to find IOCs in the future as well.

The operator's wallet

The contract owner is 0xde275ad38c3352a7cb6b0d3efcbf45900c9716f2, created November 15, 2025. It was funded by 0xF9Bd8BAD6deb8706f63c16485f7209b8362529eA, a gas relay service with 162,000 transactions that sends small MATIC amounts to thousands of addresses. No direct exchange deposit, no KYC trail - but that would also have been too easy.

The wallet deployed two contracts thirty-one minutes apart on November 15:

  1. 0xc224c02cc0c5b5bf83538b3db0b6f5b10a2bf6d2 at 14:09 UTC
  2. 0x1823A9a0Ec8e0C25dD957D0841e3D41a4474bAdc at 14:40 UTC

The first contract still returns 127.0.0.1. The operator tested the C2 resolution mechanism against localhost before deploying the production version. A "Destroy Contract" transaction was sent to the test contract, but it survived; the SELFDESTRUCT opcode was deprecated in recent EVM updates. The test contract is still readable on Polygonscan. Unfortunately no more leaks there, but one OpSec mistake in some test infra could have been juicy.

Dynamic analysis

I'm by no means an expert when it comes to static or dynamic analysis, but I gave it a shot.

The Prometheus-obfuscated Lua payload defeated static analysis. The VM virtualizes all operations into a dispatch table of around 137 opcode handlers inside a binary-search if/else tree. Function names, URLs, and API strings are assembled character by character at runtime from an encrypted string table. I built a Windows emulation layer in C (fake PE with ARM64 trampolines, fake PEB/TEB/LDR structures, 99 WinAPI stubs, a SIGBUS fault handler for zero-page TEB reads), but the Prometheus VM's indirect FFI call dispatch consistently crashed LuaJIT's interpreter on ARM64 macOS.

I was on my laptop, and my Windows server was not reachable, but then I remembered Wine... it solved it. The malware's own Windows LuaJIT binary ran under Wine with a Lua tracer prepended that hooked ffi.cdef, ffi.string, ffi.load, string.format, and os.execute. This extracted 3,748 unique strings from the big variant and 2,891 from the small variant.

The tracer captured the full operational sequence: GeoIP fingerprinting via ip-api.com, BMP screenshot via GDI BitBlt/GetDIBits, system profiling (MachineGuid, ComputerName, Username, OS version), and a multipart POST to the C2 with the screenshot as payload. Persistence is a scheduled task: schtasks /create /sc daily /st 10:05 /f.

One detail from the proxy logs: both variants send an outdated Chrome user-agent. The big variant uses Chrome/140.0.0.0, the small variant Chrome/145.0.0.0. Current Chrome stable is 151. The version strings are hardcoded per build, making them usable as network detection signatures. Real machines could have these versions too, but I'd still be worried if that was the case. Please patch!

Proxy bypass

The Polygon HTTPS query bypasses HTTP proxy settings that my machine had. My proxy captured every HTTP request (GeoIP lookup, C2 POST) but never saw a CONNECT tunnel for the Polygon endpoints. The malware sets ProxyBypass = 1 in the Windows Internet Settings registry. HTTP-level monitoring tools miss the blockchain query.

Cracking the dead-drop encryption

Hexastrike documented the dead-drop delivery mechanism: the C2 response contains URLs pointing to GitHub repositories hosting encrypted payloads. The repository deepanshugoel99/long was still live during my deep dive, containing message1.txt (617KB) and message2.txt (2.6MB).

Both files are hex-encoded, then XOR-encrypted with a 32-byte repeating key. Previous research noted the XOR key "was not recoverable through static analysis." I recovered it through a crib-drag attack.

The smaller file (message1) is an updated Prometheus-packed Lua loader. Every Prometheus payload starts with return(function(...)local. The larger file (message2) should be a PE executable starting with MZ. XORing the two ciphertexts cancels the shared key and produces plaintext_1 XOR plaintext_2. Fragments of Lua source code ("function", "local", "return") were visible in the XOR output, confirming both files use the same key.

Using return(function( as known plaintext for message1, I XOR'd it against the ciphertext to extract 17 key bytes. These same bytes, applied to message2, produced 4d5a9000030000000400000ffff0000: a valid DOS MZ header. The standard PE DOS header structure at offsets 16-31 provided more known bytes (the e_sp and e_lfarlc fields). Brute-forcing the variable name after local (one character plus =func) against valid PE signature positions yielded the remaining bytes.

Full 32-byte XOR key: ECe6VGLRJum2qYtl79OiOU7aHot7Zhbn

The decrypted PE is a 1.3MB x86-64 StealC binary compiled April 10, 2026 (SHA256: 87de3e5a8ef669589c421220cd392ae8027a8f8d3cd97d35ac339f87dcff12c8). This matches the hash Hexastrike reported. The decrypted Lua payload is a 301KB loader update (SHA256: 231ec53d539907d2b58143b69392fb977f0efef30aed2e5be74f941f3da256d0).

The StealC binary imports only KERNEL32.dll (72 functions) and resolves everything else at runtime. Its config is stored as a 1MB base64url-encoded blob in an RT_RCDATA resource, RC4-encrypted at entropy 7.98. The RC4 key derivation requires runtime analysis. The build ID (hvaah_spsdh) and candidate RC4 keys (4r_ex33bm, 0fwc5ner7lv) are stored near the "string too long" marker in .rdata, but the standard StealC decryption approach didn't produce readable output for this build. The config decryption remains open.

The campaign is active

On the day of this investigation (July 17, 2026):

  • The lure repository mikenob39wang/phone-number-location-tracking-tool received a commit at 07:52 UTC
  • The dead-drop repository deepanshugoel99/long was live with encrypted payloads
  • The Polygon contract had been updated three days prior
  • Both fallback C2 servers (85.209.129.2 and 213.176.73.159) accepted connections

Monitoring

I made a contract monitoring script that queries the Polygon contract and tracks C2 rotations over time: c2_monitor.mjs

bash
# One-shot check
node c2_monitor.mjs
 
# Full historical scan (reconstructs all 15 rotations from on-chain data)
node c2_monitor.mjs --history
 
# Watch mode: poll every 30 minutes, alert on rotation
node c2_monitor.mjs --watch 30
 
# JSON output for piping into other tools
node c2_monitor.mjs --json

Since the operator records every C2 rotation on-chain before any victim connects to the new server, the contract doubles as a free early-warning feed.

IOCs

Polygon contract:

  • Contract: 0x1823A9a0Ec8e0C25dD957D0841e3D41a4474bAdc
  • Owner wallet: 0xde275ad38c3352a7cb6b0d3efcbf45900c9716f2
  • Test contract: 0xc224c02cc0c5b5bf83538b3db0b6f5b10a2bf6d2

C2 IPs (from blockchain, all 15):

code
93.123.39.74, 84.21.189.135, 151.243.113.70, 144.31.219.15,
213.176.72.204, 89.169.12.160, 89.169.12.241, 217.119.129.76,
144.31.57.65, 89.169.12.149, 89.169.12.140, 144.31.57.67,
85.137.52.21, 185.10.68.98, 83.97.20.150

Hardcoded fallbacks: 85.209.129.2, 213.176.73.159

Dead-drop XOR key: 4543653656474c524a756d327159746c37394f694f553761486f74375a68626e

StealC SHA256: 87de3e5a8ef669589c421220cd392ae8027a8f8d3cd97d35ac339f87dcff12c8

Loader update SHA256: 231ec53d539907d2b58143b69392fb977f0efef30aed2e5be74f941f3da256d0

User-agent fingerprints:

  • Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
  • Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36

Persistence: schtasks /create /sc daily /st 10:05 /f

Live repositories (as of 2026-07-17):

  • github.com/mikenob39wang/phone-number-location-tracking-tool
  • github.com/deepanshugoel99/long

Closing Thoughts & Next Steps

I'll be continuing to monitor these repos, and maybe if I have the time, dig deeper into the StealC part of all this. Thanks for reading. If you have questions or anything else, just reach out.