duo
- Category: rev
“REV challenges doesn’t need a description.” The handout is 37 KB of challenge.exe and nothing else.
The name is the hint. file calls it a .NET assembly, diec calls it an MSVC C++ binary, and both are right — it is a C++/CLI mixed-mode assembly. The flag check crosses the managed/native boundary four times, and one of its stages is not in the call graph at all: it is an inline hook on kernel32!GetTickCount.
Solution:
1. One file, two toolchains
$ file challenge.exe
PE32+ executable for MS Windows 6.00 (console), x86-64 Mono/.Net assembly, 6 sections
$ diec challenge.exe
PE64
Compiler: Microsoft Visual C/C++(19.50.35228)[C++]
Library: .NET Framework(CLR 4.0.30319)
The section table settles it, and adds a section that is not part of any normal layout:
.text va=0x1000 vs=0x2326 raw=0x2400
.nep va=0x4000 vs=0x50 raw=0x200 <- executable, 80 bytes
.rdata va=0x5000 vs=0x62b0 raw=0x6400
.data va=0xc000 vs=0x8dc raw=0x200
Data directory 14 points at a real CLR header, and the metadata behind it is real too:
CLR hdr cb=72 ver=2.5 meta rva=0x5670 size=20900 entrytok=0x600002f
meta b'BSJB\x01\x00\x01\x00...v4.0.30319'
But a pure C# assembly has a .text of about eight bytes — a jump thunk into mscoree!_CorExeMain — and imports nothing else. This one has 8998 bytes of native code, VCRUNTIME140.dll, the whole api-ms-win-crt-* set, VirtualAlloc, IsDebuggerPresent, and mscoree.dll!_CorExeMain. The metadata string heap names the toolchain outright:
$ strings -a challenge.exe | grep -E 'cppcli|CppImpl|gcroot'
vc.cppcli.modopts
<CppImplementationDetails>
gcroot<System::String ^>
So: C++/CLI. And .nep — native entry point — is exactly five 16-byte thunks:
eb 08 jmp +8
0f 0b ud2
ff 25 f6 7f 00 00 jmp QWORD PTR [rip+0x7ff6]
ff 25 f8 7f 00 00 jmp QWORD PTR [rip+0x7ff8]
0x50 bytes for five of them, the __unep@?_0xca1 / __unep@?_0xdb2 stubs the CLR fills in at load. They are how the native half calls back into managed code, and the chain below crosses that bridge twice.
This shape breaks the usual first move. r2 dispatches on the CLR header, sets asm.arch to cil, and gives up:
$ r2 -A -q -c "afl~?" challenge.exe
ERROR: asm.arch: cannot find 'cil'
0
$ kuna functions ./challenge.exe --json | jq '.count'
83
Neither half is optional — the managed side needs ILSpy, the native side needs a native decompiler, and the answer only falls out of both.
2. The managed half, and a free symbol table
$ ilspycmd challenge.exe > managed.cs
Four classes, and the chain is legible end to end:
public static int Main(string[] args) {
Console.Write("Enter the flag: ");
string text = Console.ReadLine();
_cls_a._m0(); // writes %TEMP%\34412.txt
if (!string.IsNullOrEmpty(text) && text.Length == 41) {
IntPtr hglobal = Marshal.StringToHGlobalAnsi(text);
<Module>._0xbd9((sbyte*)hglobal.ToPointer(), __unep@?_0xca1);
...
public static void _m3(string flag) { // _0xca1 lands here
byte[] bytes = Encoding.ASCII.GetBytes(flag);
for (int i = 0; i < bytes.LongLength; i++) {
byte b = (byte)(bytes[i] ^ 0xF5);
bytes[i] = (byte)((byte)(b << 3) | (b >>> 5)); // rol8(c ^ 0xF5, 3)
}
<Module>._0x5d3(ptr, bytes.LongLength, __unep@?_0xdb2);
}
public static void _m2() { // _0xdb2 lands here
uint tickCount = <Module>.GetTickCount();
byte[] array = new byte[48];
Marshal.Copy(&<Module>._c_bytes, array, 0, 48);
string text = BitConverter.ToString(array).Replace("-", "").ToLower();
string text2 = "a247e0a5158e91f45445b3d68d409e8ec84c0a3962e46c74afcab03475e9b24f"
+ "2d8d16a900b758a035e3eac031e7b02e";
Console.WriteLine(text == text2 ? "Correct!" : "nope");
}
41 input bytes, 48 output bytes — three blocks with seven bytes of padding.
The real gift is that in a mixed-mode assembly the native globals are declared as <Module> fields, so the metadata carries their names and their initializers. No RVA hunting:
| field | bytes |
|---|---|
_sbox_data |
63 7C 77 7B F2 6B ... BB 16 — the standard AES S-box |
_rcon_data |
00 01 02 04 08 10 20 40 80 1B 36 — standard Rcon |
_m_key |
24 bytes, 0C 6C 11 56 7D 50 ... |
_m_iv |
16 bytes, 54 11 50 5B 7D 61 ... |
_sc_key |
46 35 5F 49 44 41 = "F5_IDA" |
_enc_sc |
21 bytes |
_sc_data1 |
125 bytes, starting 48 8D 0D 03 00 00 00 FF E1 90 |
_k_enc |
0E 00 17 0B 00 09 76 77 |
An S-box plus an Rcon is AES; a 24-byte key is AES-192. _sc_data1 opening with lea rcx,[rip+3] / jmp rcx is shellcode.
3. The native half is AES-192-CBC, keyed with a XOR-0x22 string
_0x5d3 is sub_140001660, and kuna renders it cleanly:
v1 = 0;
do { v6[v1] = *(unsigned char *)(v1 + 0x140005350) ^ 0x22; v1 += 1; } while (v1 < 0x18);
v9 = s_140005440._0_4_ ^ dat_140005368; // iv, also ^ 0x22
...
v4 = 0x10 - (unsigned long long)((unsigned int)v11 & 0xf);
memcpy(v7,a0,v11);
memset(&v7[v11], v4 & 0xffffffff, v4); // PKCS#7
sub_140001824(v6,v8); // key schedule -> 208 bytes
do {
v3 = 0x10; v10 = &v7[v12]; v2 = v10;
do { *v2 = *v2 ^ v2[v1]; ... } while (v3); // XOR the previous ciphertext
sub_14000190c(v10,v8); // encrypt block
memcpy(&v9,v10,0x10); // ... which becomes the next IV
v12 += 0x10; v1 -= 0x10;
} while (v12 < v4 + v11);
memcpy(0x14000c108,v7,0x30); // -> _c_bytes
s_140005440 is sixteen bytes of 0x22, so key and IV are just the stored blobs XORed with 0x22:
_m_key ^ 0x22 = ".N3t_r3v3rs3_1s_h4rd????" (24 bytes -> AES-192)
_m_iv ^ 0x22 = "v3ry_C00l_1V_343"
The schedule expands to 0xd0 = 208 bytes (13 round keys) and sub_14000190c runs while (v8 <= 0xc) with MixColumns gated on if (v8 <= 0xb) — twelve rounds, MixColumns skipped on the last. Textbook AES-192-CBC; the ((char)x >> 7) * '\xe5' in the decompiled MixColumns is just xtime’s 0x1b reduction written as a multiply.
4. _sc_data1 self-decrypts, checks for a debugger, and installs a hook
_0xbd9 is thin — copy 0x7c bytes to an RWX page and call it with (0, input, callback):
v1 = (void *)VirtualAlloc(0,0x7c,0x3000,0x40);
memcpy(v1,0x1400053a0,0x7c);
(*v1)(0,a0,a1);
VirtualFree(v1,0,0x8000);
Linear disassembly of the blob desyncs, because most of it is ciphertext:
0: lea rcx,[rip+0x3]
7: jmp rcx ; skip the 0x90
a: push rdi ... push rsi
11: mov rbx,rdx ; rbx = input
14: mov rsi,r8 ; rsi = callback
1b: call 0x5d ; push 0x20 as the "return address"
20: xor BYTE PTR [rdi+rcx*1],r14b <- decrypt loop
24: add r14b,BYTE PTR [rdi+rcx*1]
28: loop 0x20
2a: <51 bytes of ciphertext>
5d: mov rdi,QWORD PTR [rsp] ; 0x20
61: add rdi,0x9 ; 0x29
65: mov r14b,0xda
68: mov ecx,0x33 ; 51 bytes
6d: mov rax,QWORD PTR gs:0x60
76: movzx r15d,BYTE PTR [rax+0x2] ; PEB->BeingDebugged
7b: ret ; -> 0x20, run the decrypt loop
A rolling XOR whose key is fed by its own plaintext: p = c ^ k; k = (k + p) & 0xff, starting at k = 0xDA and walking [0x2A..0x5C] backwards. Undo it and stage 2 appears:
2e: test r15d,r15d
31: je 0x35
33: ud2 ; debugger -> crash
35: mov rax,QWORD PTR gs:0x60
3e: mov rax,QWORD PTR [rax+0x10] ; PEB->ImageBaseAddress
42: add rax,0x1114
48: call rax ; sub_140001114
4a: mov rcx,rbx ; input
4d: mov rax,rsi
50: add rsp,0x28 ... pop rdi
5b: jmp rax ; tail-call _0xca1
sub_140001114 walks the PEB loader list looking for a name stored as _k_enc ^ 0x45 — "KERNEL32" — then resolves two exports by hash. The hash (sub_1400017cc) is CRC32C over the uppercased name, poly 0x82f63b78, init 0, no final XOR:
$ python3 -c 'print(hex(crc32c("VirtualProtect")), hex(crc32c("GetTickCount")))'
0xf01db58 0xa43b0e1
Exactly the two constants sub_140001114 searches for. It then VirtualProtects the second one and writes twelve bytes over its prologue:
v5 = 0xb848; // mov rax, imm64
v6 = sub_1400010a4;
v7 = 0xe0ff; // jmp rax
memcpy(v1,&v5,0xc);
From here on, every GetTickCount() in the process runs sub_1400010a4.
5. The hook is a transform, not an anti-debug trick
void sub_1400010a4(void) {
v3 = 0;
if (dat_14000c100) {
do { ... *v1 = "F5_IDA"[v3 % 6] ^ _enc_sc[v3]; v3 += 1; } while (v3 < 0x15);
(*dat_14000c100)(0x14000c108,0x30); // _c_bytes, 48
}
GetTickCount64(); // tail-call, so callers still work
}
21 bytes XORed with "F5_IDA" gives a fifth stage:
85 D2 test edx, edx
7E 10 jle +0x10
8A 01 mov al, [rcx]
2C 10 sub al, 0x10
C0 C8 03 ror al, 3
88 01 mov [rcx], al
48 FF C1 inc rcx
FF CA dec edx
75 F0 jnz -0x10
C3 ret
That is the piece with no call edge to it. _cls_b._m2 calls GetTickCount() on the line before it copies _c_bytes out, so the ciphertext gets one extra pass of y = ror8(x - 0x10, 3) between being written and being compared.
Exactly one pass, too. The other GetTickCount() in the program is in _cls_a._m0, which Main calls before _0xbd9 — so it runs while the hook is still uninstalled and _c_bytes is still zeroed, and _0x5d3 overwrites it afterwards anyway.

r2 finds nothing here and even the native decompiler cannot draw the numbered path: steps 2, 9 and 10 run out of VirtualAlloced pages, and step 8 is an edge that only exists after the import table has been overwritten at run time. The graph is hand-built from the analysis above.
6. Run the four stages backwards
TARGET = bytes.fromhex("a247...b02e") # 48 bytes
KEY = bytes(b ^ 0x22 for b in M_KEY) # b'.N3t_r3v3rs3_1s_h4rd????'
IV = bytes(b ^ 0x22 for b in M_IV) # b'v3ry_C00l_1V_343'
ct = bytes((rol8(y, 3) + 0x10) & 0xFF for y in TARGET) # undo the hook stub
pt = AES.new(KEY, AES.MODE_CBC, IV).decrypt(ct) # undo AES-192-CBC
pt = pt[:-pt[-1]] # PKCS#7, pad byte 0x07
flag = bytes(ror8(b, 3) ^ 0xF5 for b in pt) # undo _cls_c._m3
$ ./solve.py
AES-192 key : b'.N3t_r3v3rs3_1s_h4rd????'
AES iv : b'v3ry_C00l_1V_343'
padded pt : b5a50d9d...070707070707 (pad byte 0x07)
len : 41
FLAG : CATF{S1ngl3_l4ngu4g3_n0t_3n0ugh_4ny_m0r3}
41 characters, which is the length Main demanded. Full script: solve.py
7. Confirm against the binary
Running the chain forwards reproduces the stored hex exactly, and wine’s mono is happy to load a mixed-mode assembly:
$ echo "CATF{S1ngl3_l4ngu4g3_n0t_3n0ugh_4ny_m0r3}" | wine ./challenge.exe
Enter the flag: Correct!
Time taken to solve challenge: 0 hr 0 min 0 s
Flag: CATF{S1ngl3_l4ngu4g3_n0t_3n0ugh_4ny_m0r3}
Takeaways
- A CLR header does not mean “stop, use ILSpy”. C++/CLI produces one file with native code and metadata, and tools that branch on the header alone will hand back nothing —
r2 -Areported zero functions here purely because it believed thecilarch tag. Check.text’s size against what a pure managed assembly would have (about eight bytes) before trusting the classification. - In mixed mode the metadata is a free symbol table for the native side.
_sbox_data,_m_key,_m_iv,_sc_keyand their contents all came out of the<Module>field initializers, which is faster and less error-prone than chasing RVAs through the decompilation. - An IAT/inline hook can be an algorithm step rather than an anti-analysis trick. The final
ror8(x - 0x10, 3)never appears on any call path — it runs because the comparison function happens to callGetTickCount()first, which is also why the hook tail-callsGetTickCount64so nothing downstream notices.