Silent Stranger
- Category: rev
“Who are you with the lights out?”
launcher.exe is a 170 KB PE32 that wants three arguments. Each level’s code is encrypted under the previous answer, so they only fall in order, and the flag is a final blob keyed by all three together. The third level is the interesting one: its control flow is not branches, it is structured exception handling — it faults on purpose and lets its own SEH handlers decide where execution resumes.
Solution:
1. Two sections that are empty on disk
$ rabin2 -S launcher.exe
nth paddr size vaddr vsize perm name
0 0x00000400 0x19600 0x00401000 0x1a000 -r-x .text
1 0x00019a00 0xe400 0x0041b000 0xf000 -r-- .rdata
2 0x00027e00 0xa00 0x0042a000 0x2000 -rw- .data
3 0x00028800 0x1c00 0x0042c000 0x2000 -rwx .lvl3
4 0x0002a400 0x200 0x0042e000 0x1000 -rw- .fptable
Two non-standard section names, and only one of them is the challenge. .fptable turns out to be the MSVC runtime’s lazily-resolved API pointer cache — VirtualProtect(0x42e000, 0x80, PAGE_READWRITE), store a GetProcAddress result, flip it back — so it is a red herring. .lvl3 is the real one: RWX, zero entropy, all-zero on disk, and written at run time.
The imports say what writes it: VirtualAlloc + VirtualProtect + FlushInstructionCache for the code, and six bcrypt.dll entries (BCryptOpenAlgorithmProvider, BCryptCreateHash, BCryptHashData, BCryptFinishHash, …) — both algorithm identifiers are the wide string "SHA256" — for the key schedule. IsDebuggerPresent is imported too, but its only two call sites are the MSVC CRT’s own fatal-error paths — every anti-debug check that matters here is hand-rolled out of the PEB.
strings finds no prompts beyond the arguments and the failure messages, which is the whole plot in four lines:
arg1: %255s arg2: %255s arg3: %255s
level 1 failed
level 2 failed
level 3 failed
2. main is a three-stage gate, and each stage decrypts the next
sub_401470, trimmed to the spine:
if (a0 != 4) { /* prompt for v3, v4, v5 */ } else { v13 = argv[1]; v14 = argv[2]; v10 = argv[3]; }
v9 = sub_401340(0x41b1b0, dat_420500); // level 1: copy -> RX
if (sub_4013c0(v9, v13) != 1) { puts("level 1 failed"); return 1; }
v11 = malloc(dat_420504);
sub_401220(v11, 0x420508, dat_420504, v13, strlen(v13)); // decrypt level 2 with arg1
v8 = sub_401340(v11, dat_420504);
if (sub_401400(v8, v14, sub_401440) != 1) { puts("level 2 failed"); return 1; }
sub_401220(0x42c000, 0x420aa8, dat_4224e0, v14, strlen(v14)); // decrypt level 3 with arg2
FlushInstructionCache(GetCurrentProcess());
if (sub_4013c0(0x42c000, v10) != 1) { puts("level 3 failed"); return 1; }
sub_4010d0(v13, v14, v10, v6); // v6 = SHA256(a1 | a2 | a3)
v12 = malloc(dat_4224e4 + 1);
sub_401220(v12, 0x4224e8, dat_4224e4, v6, 0x20); // decrypt the flag with the digest
puts(v12);
Two helpers carry everything. sub_401340 is the loader — VirtualAlloc, copy, VirtualProtect(PAGE_EXECUTE_READ), FlushInstructionCache. sub_401220 is the cipher, and its disassembly is unambiguous:
401266: mov eax,DWORD PTR [ebp-0x4]
401269: and eax,0x1f
40126c: jne 0x401311 ; refresh only every 32 bytes
401281: call 0x402520 ; memcpy(buf, key, keylen)
401294: mov BYTE PTR [ebp+edx*1-0xc],al ; counter, little-endian
4012de: call 0x402520 ; memcpy(buf + keylen, &counter, 4)
4012f8: call 0x401000 ; SHA256(buf, keylen + 4) -> digest[32]
401311: mov eax,DWORD PTR [ebp+0xc]
401317: movzx ecx,BYTE PTR [eax]
40131d: and edx,0x1f
401320: movzx eax,BYTE PTR [ebp+edx*1-0x2c]
401325: xor ecx,eax
40132d: mov BYTE PTR [edx],cl
def keystream_xor(src, key): # sub_401220
out, ctr, dig = bytearray(), 0, b''
for i, b in enumerate(src):
if i % 32 == 0:
dig = hashlib.sha256(key + struct.pack('<I', ctr)).digest(); ctr += 1
out.append(b ^ dig[i % 32])
return bytes(out)
sub_4010d0 is the same SHA-256 with 0x7c (|) hashed between the three arguments. Reading the four size dwords out of .rdata gives the whole layout:
| blob | va | length | key |
|---|---|---|---|
| level 1 | 0x41b1b0 |
21327 | none — plaintext |
| level 2 | 0x420508 |
1437 | arg1 |
| level 3 | 0x420aa8 |
6712 | arg2 |
| flag | 0x4224e8 |
79 | SHA256(arg1 \| arg2 \| arg3) |

Only level 1 is visible to any static tool — levels 2 and 3 have no code cross-references at all until they are decrypted, which is also why the tool counts look fine and mean nothing (r2 -A: 685 functions, kuna: 792; the 6712-byte level 3 is in neither number).
3. Level 1: 248 bit checks, each poisoned twice
d: push ebp ...
11: cmp DWORD PTR [esp+0x18],0x1f ; len(arg1) == 31
2a: mov eax,fs:0x30
30: movzx ebp,BYTE PTR [eax+0x2] ; ebp = PEB->BeingDebugged
37: call 0x27b5 ; the expected bit
3c: xor eax,ebp
3e: and eax,0x1
41: movzx edx,BYTE PTR [edi] ; input[0]
44: and edx,0x1 ; bit 0
47: xor eax,edx
49: xor eax,0x1
4c: add ebx,eax ; +1 if the bit matched
...
27a2: cmp ebx,0xf8 ; 31 * 8 -> every bit
27a8: sete al
248 copies of that block, one per bit, separated by twelve nops. The generators are all the same shape:
27c2: mov edx,DWORD PTR [esp] ; own return address
27c5: xor ecx,ecx
27c7: cmp BYTE PTR [edx],0xcc ; software breakpoint at the call site?
27ca: sete cl
27cd: mov eax,0xe21fcd24
27d2: xor eax,0x6b3d96e3
27d7: shr eax,0xa
27da: and eax,0x1
27dd: xor eax,ecx ; flip the answer if patched
27e2: ret
So the expected bit is poisoned by PEB->BeingDebugged in the caller and by an int3 check on its own return address. Both are free to satisfy — emulate instead of debugging. With fs: served by a real GDT entry, the blob runs as-is, and EAX at each movzx edx, byte [edi+k] is the expected bit:
sites[addr] = (k, j) # from capstone: movzx edx,[edi+k] then optional shr edx,j
...
uc, eax = emu.call(code, 0, [INPUT, 31], hook=record_eax_at_sites)
for (k, j), b in bits.items(): out[k] |= b << j
check sites found: 248
arg1 = b'V01c3S_F1ll_My_H34d_W1th_No1ses'
verify lvl1(arg1) = 1
4. Level 2: 24 arithmetic constraints, handed to z3
Decrypting with arg1 gives 1437 bytes that open with a length gate of 0x18 and a callback pointer:
c: cmp DWORD PTR [ebp+0xc],0x18 ; len(arg2) == 24
16: cmp DWORD PTR [ebp+0x10],0x0 ; callback must be non-null
42: movzx eax,BYTE PTR [esi+0x16]
46: imul eax,eax,0x3
49: add eax,0xb
4c: cmp eax,0x158
51: setne dl
57: or edi,edx ; failures accumulate into edi
...
585: xor eax,eax
587: test edi,edi
589: sete al ; return edi == 0
24 of those, mixing imul/add/sub/xor/shl over two or three input bytes, interleaved with junk jg/je/jl triples that all converge and jecxz/loop guards over ud2. Three things perturb the arithmetic, all through one register:
16d: mov eax,fs:0x30
173: mov eax,DWORD PTR [eax+0x18] ; PEB->ProcessHeap
176: mov ecx,DWORD PTR [eax+0x40] ; Flags
179: and ecx,0xfffffffd
17c: mov edx,DWORD PTR [eax+0x44] ; ForceFlags
188: imul eax,eax,0x31
18b: add ebx,eax ; ebx = poison
plus two probes through the host callback — sub_401440, which answers *out = (len == 0x1f) — asked once with 0x1f and once with 0x1e, so the honest answers are 1 and 0 and both contribute zero. On a clean run ebx is 0 throughout, which makes every constraint a plain equation. Walk the instruction stream, fix ebx = 0, skip anything writing to it, and emit the comparisons to z3:
constraints: 24 unhandled mnemonics: []
arg2 = b'Dr41n_M3_oFf_Th1S_PO1son'
unique in printable ASCII: True
Then re-run the untouched blob in Unicorn with a faithful callback, rather than trusting the model:
lvl2(arg2) = 1
lvl2("A"*24) = 0
5. Level 3: the dispatcher is SEH
Level 3 is decrypted into .lvl3 at its own section VA, so unlike the first two it is position-dependent and has to be emulated in the real image layout. The prologue is ordinary enough — cmp [ebp+0xc],0x21, so len(arg3) == 33, and a call/pop/sub delta pins esi = 0x42c000 — and then it stops looking like code:
156d: mov eax,DWORD PTR fs:[0]
1573: mov DWORD PTR [ebx+0x8],eax ; save the old chain head
d20: push ebx
d21: lea ecx,[esi+0x16aa] ; handler 1
d27: push ecx
1592: push eax
124a: lea ecx,[esi+0x414] ; handler 2
1911: lea ecx,[esi+0x1934] ; handler 3
1346: xor eax,eax
1348: mov DWORD PTR fs:[eax],edx ; install the chain
dab: mov eax,0x7bd889d5
db0: xor edx,edx
db2: mov ecx,DWORD PTR [edx+0x1] ; fault on purpose
Three handlers, then a deliberate access violation. The handlers dispatch on the exception code, and their tail is the giveaway:
1763: inc DWORD PTR [edx+0x10]
1766: mov ecx,DWORD PTR [esp+0x4] ; CONTEXT *
176a: mov edi,DWORD PTR [ecx+0xc0] ; CONTEXT.EFlags
1770: and edi,0x100 ; trap flag -> single-stepping?
1779: mov edi,DWORD PTR [ecx+0x4] ; Dr0
177c: or edi,DWORD PTR [ecx+0x8] ; Dr1
177f: or edi,DWORD PTR [ecx+0xc] ; Dr2
1782: or edi,DWORD PTR [ecx+0x10] ; Dr3 -> hardware breakpoints?
1788: mov ecx,DWORD PTR fs:0x30
178f: movzx edi,BYTE PTR [ecx+0x2] ; BeingDebugged
1793: mov ecx,DWORD PTR [ecx+0x68]
1796: and ecx,0x70 ; NtGlobalFlag heap bits
179e: mov ecx,DWORD PTR [edx+0x14] ; accumulated detections
17a1: neg ecx
17a3: sbb ecx,ecx ; 0 if clean, else 0xFFFFFFFF
17a5: mov edi,DWORD PTR [edx+0x4]
17a8: add edi,0x1945 ; the decoy continuation
17ae: xor edi,eax
17b0: and edi,ecx
17b2: xor eax,edi ; clean -> real target, else swap
17b8: mov DWORD PTR [ecx+0xb8],eax ; CONTEXT.Eip := target
5c1: popa
7bc: xor eax,eax ; ExceptionContinueExecution
The next instruction pointer is computed in the handler from five debugger tells at once, and a debugger doesn’t just fail the check — it silently routes execution somewhere else. There is no branch to patch.
That means emulating the CPU is not enough; the dispatcher has to exist. About 120 lines on top of Unicorn: build a CONTEXT and an EXCEPTION_RECORD below ESP, walk fs:[0], call each handler cdecl as handler(rec, frame, ctx, disp), and on a 0 return reload the (handler-modified) CONTEXT and resume at its Eip.
Two Unicorn quirks cost time and are worth writing down. Its intno is not the architectural vector — the same div ecx with ecx = 0 arrived once as 0 and once as 8 — so classify from the faulting opcode instead. And it will occasionally halt on a faulting instruction without firing any hook, so treat “stopped, and EIP is not the magic return address” as an exception too. With both handled:
lvl3("A"*33) -> ret 0, 33 exceptions
lvl3("A"*33), debugged -> ret 0, 1 exception
33 exceptions for 33 input bytes, and a single exception once BeingDebugged is set — the divergence is immediate.
6. The decoy
One of those handler tails does something different:
d945: mov edi,DWORD PTR [ebx+0x4]
d948: add edi,0x1991
d95d: mov ecx,0x10
d962: mov al,BYTE PTR [edi]
d964: xor al,0xa7 ; decrypt 16 bytes
d972: mov ecx,0xf ; compare 15
d977: mov dl,BYTE PTR [esi]
d979: xor dl,BYTE PTR [edi]
d97b: or al,dl
d983: sete al ; and return 1
$ python3 -c "print(bytes(b ^ 0xa7 for b in blob(0x1991, 16)))"
b'N1ce_Try_But_n0\x00'
An input starting N1ce_Try_But_n0 makes level 3 return success — and then feeds a different SHA256(arg1|arg2|arg3), so the last sub_401220 decrypts 79 bytes of noise. The trap is that the gate and the flag are keyed independently: passing the check is not the same as being right.
7. A per-byte oracle beats 95^33
Every check in level 3 ends the same way:
60c: setne al
60f: movzx eax,al
6a9: or DWORD PTR [edx+0xc],eax ; one hit per input byte
Hooking that single instruction turns the one boolean the function returns into 33 independent pass/fail bits. And the checks are position-independent — changing input[0] leaves the other 32 bits untouched — so a uniform input resolves every position that happens to equal that character, and the whole string falls out in one sweep of the printable range:
for c in range(0x20, 0x7f):
for i, bit in enumerate(flags(bytes([c]) * 33)):
if bit == 0: sol[i] = c
sweep 0.9s
arg3 = b'I_W1ll_N3v3R_LeT_Th3m_S3e_My_F4c3'
verify ret = 1
8. The flag
$ ./solve.py
arg1 (31) = V01c3S_F1ll_My_H34d_W1th_No1ses
arg2 (24) = Dr41n_M3_oFf_Th1S_PO1son
arg3 (33) = I_W1ll_N3v3R_LeT_Th3m_S3e_My_F4c3
sha256 = 2102c38296a8c261988d649e1d5f5cb3440af6b3412a46256a1f07d0fc781321
CATF{AnD_i_W1ll_d1E_4_s1l3nT_StR4Nger_e1627f4536b5bf47989810fa4afa47e02cbd3fa8}
Full script: solve.py, with the harness in emu.py and the SEH dispatcher in seh.py. It goes from the shipped launcher.exe to the flag in about a second, with no patched copy anywhere.
Confirmed against the untouched binary:
$ wine ./launcher.exe "V01c3S_F1ll_My_H34d_W1th_No1ses" \
"Dr41n_M3_oFf_Th1S_PO1son" \
"I_W1ll_N3v3R_LeT_Th3m_S3e_My_F4c3"
CATF{AnD_i_W1ll_d1E_4_s1l3nT_StR4Nger_e1627f4536b5bf47989810fa4afa47e02cbd3fa8}
The three arguments read as a verse, which is the “silent stranger” of the title.
Flag: CATF{AnD_i_W1ll_d1E_4_s1l3nT_StR4Nger_e1627f4536b5bf47989810fa4afa47e02cbd3fa8}
Takeaways
- When anti-debug is a computed jump target rather than a check, patching is the wrong tool. Here five tells —
BeingDebugged,NtGlobalFlag, heapFlags/ForceFlags,EFlags.TFandDr0-Dr3— are OR’d into one word thatneg/sbbturns into an all-ones mask over the nextCONTEXT.Eip. There is no branch tonop, and a debugger produces a wrong answer rather than an obvious failure. Emulating with a clean synthetic TEB/PEB sidesteps the whole class. - SEH is a legitimate dispatcher and emulators don’t implement it. A
CONTEXT+EXCEPTION_RECORD+fs:[0]walk is roughly 120 lines on top of Unicorn and turns an unreadable blob into something you can run 3000 times a second — far cheaper than statically untangling six exception-mediated jumps. - Find the accumulator, not the verdict. Each level ORs its per-item failures into one word before the final
test; hooking that one instruction converts a single boolean into a per-byte oracle, which is the difference between a 95^33 search and 95 emulations. - A check that passes is not an answer that is right. Level 3’s decoy returns success for
N1ce_Try_But_n0…but the flag is keyed by a hash of all three arguments, so the gate and the payload can disagree. Always derive the flag from the real key material rather than stopping at “the binary said Correct”.