f4n6.co.uk - October 2026 - TLP:CLEAR. All indicators are defanged. Hash: f745c2d897ad6f16cb5c0428ba74a7f041f7c3e94f52cc3727532577fe12507d
UAC Bypass
TL;DR
- A 9 MB, 64-bit Windows loader compiled at 05:57 UTC on 7 October 2026 and first seen on VirusTotal 36 minutes later, with 13 of 75 engines flagging it.
- Its import table is a decoy. Every real API is resolved by hash, and every string is XOR-encrypted with a 16-byte key we recovered from the ciphertext itself.
- Decrypted, the loader reads like a checklist: rewrite its own PEB so it looks like
C:\Windows\explorer.exe, elevate silently through the CMSTPLUA COM object (Elevation:Administrator!new:{3E5FC7F9-9A51-4367-9063-A120244FBEC7}), patch AMSI and ETW in memory, add Defender exclusions forC:\ProgramDataand%TEMP%via PowerShell, then rebuild a 725 KB encrypted stage 2 from four interleaved chunks, write it asC:\ProgramData\<14 random letters>.exeand launch it hidden. - In our isolated Windows 10 sandbox it exited after ~12 seconds, before any of that. We chased the reason to the ground: it is a lab property, not a sample property. The snapshot ran UAC-disabled and launched the sample already elevated, so the bypass had no boundary to cross; after enabling UAC and launching at medium integrity the sample still exits, waiting up to 15 seconds for a module that never loads. The dynamic chain stopped at step one either way.
- We still got stage 2. It was locked inside a runtime-decrypted section, but we lifted it by emulating the loader's own AES decryptor against a live process dump - the technique and the one ordering mistake that cost us an afternoon are below. Stage 2 is a 725 KB reflective DLL loader; stage 3 it builds from on-disk chunks and never left the sandbox.
- Along the way we found and fixed two bugs in our own tooling, and one genuine design gap: a UTF-8 em-dash that broke every detonation, and a Ghidra default memory budget that OOM-killed analysis of anything large and obfuscated.
The setup
The lab is malbox, a Model Context Protocol server that fronts Docker-isolated static analysis (capa, YARA, Ghidra headless), VirusTotal hash lookups, and VMware detonation with an in-guest agent and host-side tcpdump. The analyst driving it was Claude Code. The point of the exercise was to see how far an AI-orchestrated pipeline gets on a sample that is genuinely new, and where it has to stop and say "I don't know".
First look
The static pass took seconds and was already suspicious:
| Property | Value | Why it matters |
|---|---|---|
| Sections | .026018, .570127, .628785, .pdata, .afc258, .rsrc |
Randomised names, a crypter habit |
| Imports | 23, mostly USER32/GDI32 window APIs | A "GUI app" that never draws a window |
| capa | XOR encoding, Chaskey encryption, resolve function by parsing PE exports | API hashing plus a real cipher |
| Version info | "Windows Update Helper" / "Windows Runtime Library" | Masquerade |
| VT names | conhost.exe, hyzl3vib.exe, rundll32 |
Someone already ran it under a Windows name |
| Compile time | 2026-10-07 05:57:41 UTC | 36 minutes before the first VT upload |
Kaspersky's label was the most specific: UDS:Trojan.Win64.BypassUAC. That is a vendor's resemblance claim, not a verdict, but it turned out to be a good hint.
Ghidra, and a wall of noise
Headless Ghidra produced 103 functions. The entry chain calls 26 functions in sequence, each one roughly 16 KB of decompiled C that does nothing recognisable: XOR with stack addresses, multiplies by odd constants, opaque if ((x * 0x2fac6ae9 >> 0xf) & 1) branches that guard empty loops. This is mixed-boolean-arithmetic obfuscation. It consumes an analyst's attention and an emulator's cycles and produces one 32-bit value at the end. We left it alone.
The interesting code is small and sits at the end of the first section. Three functions, called in order with a buffer from LocalAlloc(LPTR, 0xD0000):
- A preparation function that resolves two modules by name and patches two exports in each, using
VirtualProtect(addr, 4, PAGE_EXECUTE_READWRITE)followed by a four-byte copy. It then runs three commands throughCreateProcessAwithCREATE_NO_WINDOWand waits 20, 30 and 20 seconds on them. - A reassembly function that
VirtualAllocs 0xD0000 bytes and, four times, copies 4 bytes then skips 29, 24, 27 or 25 bytes of filler out of the big data section, handing each chunk to a decryption routine with a 16-byte key and a 20-byte IV. - A drop-and-run function that builds a 14-letter lowercase name from a linear congruential generator, appends an extension, writes exactly 0xB1000 bytes to it in 128 KB
WriteFilecalls, sleeps 667 ms, and callsCreateProcessWon the quoted path withCREATE_NO_WINDOW.
Every string those functions touch is a pointer into encrypted data. Ghidra cannot tell you which modules get patched or what the three commands are.
Recovering the string key without running anything
The loader's string decryptor is itself inside a section that is only decrypted at runtime, so we could not lift it. But the ciphertext gave the key away. Several of the blobs are wide strings, and wide ASCII has a zero byte in every odd position. Where the plaintext is zero, the ciphertext byte is the key byte. Across five blobs the odd-position bytes repeated with period 16: fa ad 37 a9 8b 9c 85 88. The 10-byte blob that the drop function appends to a filename was almost certainly .exe, and .exe\0 as UTF-16 fixes the first five even key bytes. Checking those against the 18-byte module name gave amsi.dll, and the full key fell out:
6e fa 9f ad cc 37 33 a9 e0 8b 88 9c 0b 85 b9 88
Decrypting every referenced blob:
| Purpose | Plaintext |
|---|---|
| Patched module 1 | amsi.dll |
| Patched module 2 | ntdll |
| Patch A (amsi, export 1) | 29 C0 C3 90 = sub eax,eax ; ret ; nop |
| Patch B (amsi export 2, ntdll exports 1 and 2) | 48 33 C0 C3 = xor rax,rax ; ret |
| Command prefix | cmd.exe /C |
| Command 1 (wait 20 s) | powershell -NoProfile -WindowStyle Hidden -Command "Add-MpPreference -ExclusionPath 'C:\ProgramData','C:\ProgramData\Windows\Microsoft','C:\ProgramData\Microsoft\Windows'" |
| Command 2 (wait 30 s) | gpupdate /force |
| Command 3 (wait 20 s) | powershell -NoProfile -WindowStyle Hidden -Command "Add-MpPreference -ExclusionPath $env:TEMP" |
| PEB rewrite target | C:\Windows\explorer.exe |
| Drop directory | C:\ProgramData\ (fallback GetTempPathW) |
| Drop extension | .exe |
| COM | ole32 and Elevation:Administrator!new:{3E5FC7F9-9A51-4367-9063-A120244FBEC7} |
The last line is the CMSTPLUA ICMLuaUtil auto-elevating COM object. Combined with the PEB rewrite to explorer.exe, it is UACMe method 41: make the process claim to be a trusted Windows binary, then ask the elevated COM server to ShellExec on your behalf. The orchestrator function in the loader takes RtlAcquirePebLock, writes the explorer.exe string into RTL_USER_PROCESS_PARAMETERS.ImagePathName and CommandLine, walks the loader list, and releases the lock.
So the intended flow is: elevate, then in the elevated copy disable AMSI and ETW, exclude the working folders from Defender, force a policy refresh so the exclusions are live, drop stage 2 and go.
Detonation: a lab gate, chased to the ground
With everything isolated to a host-only vmnet and tcpdump on the host side, the sample launched, ran for about twelve seconds, and exited with code 0. No child processes, no DNS or TCP from the sample (the only packets were Windows telemetry lookups from svchost), no new files, no persistence changes. The 20-second waits on the Defender commands cannot fit in twelve seconds, so it never reached that stage.
The first detonations were even shorter, and the screenshot explained why: the clean snapshot booted to the lock screen, so VMware Tools ran the sample with no interactive session, no explorer.exe, and no desktop for a COM elevation to attach to. We re-took the snapshot with the analysis user logged in, and the tooling now reports interactive_session so a lock-screen run can't be mistaken for evasion again.
But the sample still exited. So we went after the gate properly, and it took three steps.
One: it was already elevated. A debugger attach showed the process token at High mandatory integrity - a CMSTPLUA bypass needs to start at medium integrity to elevate itself, and on a UAC-disabled snapshot every process is born elevated. The guest had EnableLUA=0. We enabled UAC, re-snapshotted, and added a medium-integrity launch path to the agent (a Scheduled Task with RunLevel=Limited, which the Task Scheduler runs with the filtered token). The agent log confirmed it worked: medium-integrity launch pid=<n>.
Two: medium integrity didn't change the outcome. The sample still ran ~12 s and exited, no child, no drop. So the exit is not simply "already elevated, bypass skipped". An in-agent probe that snapshots the sample's loaded modules showed them frozen at 18 - the static imports only (ole32/combase present, so the elevation is wired up) - and no child process ever appeared. That matches the decompilation: the loader polls once a second, up to fifteen times, for a module to load (FUN_14000a010 matches a module against a runtime-built path), and gives up when it never does.
Three: the debugger lies here. We set breakpoints on the COM elevation and the wait loop, and the debuggee exited sooner than in detonation, hitting none of them - being debugged is itself an anti-analysis signal, and cdb runs the sample high-IL under the VMware Tools service. This sample has to be observed without a debugger. We stopped there: the remaining question (does the elevation get attempted, and what does it return?) needs in-guest COM telemetry like Procmon, which is a tooling task for another day.
Lifting stage 2 without running the sample
The dynamic chain stopped at step one, but stage 2 did not have to. The reassembly routine allocates, and four times copies 4 bytes then skips 29, 24, 27 or 25 bytes of filler, handing each chunk to an AES decryptor with a 16-byte key and a 20-byte IV. The decryptor lives in a section that only exists in plaintext inside a running process - so we took it from one.
We broke under a debugger, found the VirtualProtect(RW) that marks .afc258 writable, and dumped the section after the loader had decrypted it in place. Splicing that back into the file and marking it code let Ghidra finally see the reassembly logic. Then we emulated the real decryptor with Unicorn, feeding it the four chunk offsets and the per-chunk key/IV blobs lifted from the live image.
The one mistake worth writing down: order matters. You must de-interleave each chunk first, then decrypt. Decrypt the raw interleaved blob and you get noise - except for a one-in-65536 chance of a false MZ, which is exactly the kind of thing that wastes an afternoon by looking like progress. De-interleaving first produced a clean 725 KB PE.
Stage 2 (e75461b8..., 10 sections, entropy 5.65) is itself a loader, not a payload. Decompiling it showed the real shape of the chain: it is not an embedded PE, it is a reflective DLL loader. It writes an internal blob to numbered chunk files in a config-derived directory (%s_%s_tempchunk_%d -> %s_%s_chunk_%d), reads them back, validates the reassembled buffer is a PE, creates a suspended process, and manually maps the DLL into it (NtAllocateVirtualMemory -> NtWriteVirtualMemory -> import/reloc fixup -> NtCreateThreadEx). Stage 3 is assembled on disk at runtime, which is why nothing in stage 2 is an embedded executable. Reaching it end to end needs a working elevated path - the same gate as above.
The bug we found in ourselves
Before the clean run above, two detonations died in seconds with Guest program exited with non-zero exit code: 1 and no agent log at all. The in-guest agent is a PowerShell script that had been extended that morning. It parsed fine by eye.
The cause was one character. A log line contained a UTF-8 em-dash (E2 80 94). The script has no byte-order mark, so Windows PowerShell 5.1 reads it in the system ANSI code page. Under cp1252 those three bytes become —, and 0x94 is U+201D, a right double quotation mark, which PowerShell accepts as a string delimiter. The string closed early, the rest of the line was a parse error, and powershell.exe -File exits 1 on a parse error before executing a single statement. Replacing the dash with -- fixed it; a unit test now fails the build if any guest agent contains a non-ASCII byte.
If your sandbox pushes scripts into Windows guests, save them with a BOM or keep them ASCII. The symptom looks exactly like an evasive sample refusing to run.
Detection
Existing SigmaHQ rules cover the two loudest behaviours: UAC Bypass via ICMLuaUtil and CMSTP UAC Bypass via COM Object Access (a High-integrity child of dllhost.exe /Processid:{3E5FC7F9-9A51-4367-9063-A120244FBEC7}), and Powershell Defender Exclusion / Windows Defender Exclusions Added - PowerShell (Add-MpPreference -ExclusionPath). Defender's own event 5007 will record the exclusion change.
For this build, the encrypted strings are stable because the XOR key is fixed, so they make a usable YARA rule:
rule f4n6_loader_xor16_bypassuac_2026_10 {
meta:
description = "x64 loader: XOR-16 encrypted strings (amsi.dll, ntdll, CMSTPLUA elevation moniker, Add-MpPreference), PEB explorer.exe spoof"
author = "f4n6.co.uk"
date = "2026-10-07"
hash = "f745c2d897ad6f16cb5c0428ba74a7f041f7c3e94f52cc3727532577fe12507d"
strings:
$amsi_enc = { 0f fa f2 ad bf 37 5a a9 ce 8b ec 9c 67 85 d5 88 }
$ntdll_enc = { 00 fa eb ad a8 37 5f a9 8c 8b }
$moniker_enc = { 2b fa f3 ad a9 37 45 a9 81 8b fc 9c 62 85 d6 88 00 fa a5 ad 8d 37 57 a9 8d 8b e1 9c 65 85 d0 88 }
$mppref_enc = { 1e 95 e8 c8 be 44 5b cc 8c e7 a8 b1 45 ea e9 fa 01 9c f6 c1 a9 17 1e fe 89 e5 ec f3 7c d6 cd f1 }
$explorer_enc = { 2d fa a5 ad 90 37 64 a9 89 8b e6 9c 6f 85 d6 88 19 fa ec ad 90 37 56 a9 98 8b f8 9c 67 85 d6 88 }
$patch_a = { 47 3a 5c 3d 26 c9 5f 6e }
condition:
uint16(0) == 0x5A4D and 3 of them
}
Hunting ideas that generalise beyond this hash:
- Defender exclusions added for
C:\ProgramDatasubfolders and%TEMP%within a minute of agpupdate /forcefrom a non-admin tool. - Any process whose PEB
ImagePathNamesaysC:\Windows\explorer.exewhile its mapped image lives elsewhere. EDRs that validate the PEB against the image section catch this; most process listings do not. - New 14-character lowercase
.exefiles underC:\ProgramData.
Indicators
| Type | Value | Context |
|---|---|---|
| SHA-256 | f745c2d897ad6f16cb5c0428ba74a7f041f7c3e94f52cc3727532577fe12507d |
Loader |
| SHA-1 | b1934c7ade0129279d1e32ccfdb1ebfd94d0495d |
Loader |
| MD5 | 44ab91f2369d173a84cdfaaf5f754e91 |
Loader |
| imphash | cf5217820c8fcdb1a18f3f82169cd566 |
Decoy import table, still useful for clustering builds |
| ssdeep | 49152:4Vev77WuXQuC8cw3RAcQMOHnDSSOEIy5sRMF1tN:0eJpRLAjOSsXs |
Loader |
| File path | C:\ProgramData\<14 lowercase letters>.exe |
Stage 2 drop (static) |
| Registry | Defender Exclusions\Paths for C:\ProgramData, C:\ProgramData\Windows\Microsoft, C:\ProgramData\Microsoft\Windows, %TEMP% |
Added once elevated |
| COM CLSID | {3E5FC7F9-9A51-4367-9063-A120244FBEC7} |
CMSTPLUA elevation moniker |
No network indicators were observed or found in the loader. Whatever stage 2 talks to is still inside the ciphertext.
What we don't know
- What stage 3 is. Stage 2 builds it from on-disk chunks and reflectively injects it; we recovered stage 2 but not the DLL it assembles, because that needs the elevated path to run to completion.
- Whether the loader has a non-elevated fallback path. The code we read exits; the obfuscated prologue may hide checks we did not emulate.
- Why the elevation doesn't complete in the lab. Medium integrity with UAC on is the state the sample asks for, yet it still waits and times out. Whether the COM call is never reached, or is reached and fails, needs in-guest telemetry (Procmon) we did not set up this pass.
- Attribution. "BypassUAC", "Wacatac" and "Kryptik" are generic labels. The build is 37 minutes older than its first public sighting, which says more about the operator's cadence than their identity.
Lessons
Four things made the difference, and none of them was a tool running by itself. The string-key recovery came from noticing that wide strings leak their key. The stage-2 recovery came from emulating the sample's own decryptor against a live dump instead of guessing the cipher - and from de-interleaving before decrypting, not after. The early-exit diagnosis came from refusing to accept a clean detonation at face value and instrumenting the guest until the gate showed itself (it was the lab, not the sample). And the muddiest part of the whole exercise was the sandbox's own defaults - a lock-screen snapshot, UAC disabled, a UTF-8 em-dash, a Ghidra memory budget too small for obfuscated binaries. An AI-driven pipeline is very good at the first ninety percent; the discipline that matters is being honest about which ten percent is still missing, and about which failures are the sample refusing to run versus the lab refusing to let it.
