Executive Summary
- Challenge: Thread of Doom
- Category: Reverse Engineering
- Flags:
NHK26{VirtualProtect_Overwritten} - Binary:
NHK_CrackMe_V3.exe(PE32, x86, 43520 bytes)
Overview
Thread of Doom is a Windows crackme that presents a dialog with a “Demo” button. Clicking the button displays an error: “Tu n’es pas premium ! Prix : 2 BTC”. The goal is to understand the binary’s protection mechanisms and extract the hidden flag.
The flag is XOR-encrypted in memory with a single-byte key (0x55). The binary only decrypts it at runtime when several anti-tampering checks pass, but since both the ciphertext and key are visible in the decompilation, we can extract it statically without running the binary at all.
Step 1: Initial Reconnaissance
File identification
$ file NHK_CrackMe_V3.exe
NHK_CrackMe_V3.exe: PE32 executable for MS Windows 6.00 (GUI), Intel i386, 9 sections
$ sha256sum NHK_CrackMe_V3.exe
9c38c99c62459d3ac27274fba43e2f138b26ab7afd0dd415751e8a3a3aef3554 NHK_CrackMe_V3.exe
32-bit Windows GUI executable, targeting Vista+. The file output says “GUI” (not “console”), so we’re dealing with dialog boxes and WinMain rather than a console crackme. 9 sections is more than a typical release build (usually 4-5), which already hints at a debug build. No packing - packers like UPX would reduce section count and change names.
PE header inspection
$ xxd NHK_CrackMe_V3.exe | head -16
00000000: 4d5a 9000 0300 0000 0400 0000 ffff 0000 MZ..............
00000010: b800 0000 0000 0000 4000 0000 0000 0000 ........@.......
00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000030: 0000 0000 0000 0000 0000 0000 e800 0000 ................
00000040: 0e1f ba0e 00b4 09cd 21b8 014c cd21 5468 ........!..L.!Th
00000050: 6973 2070 726f 6772 616d 2063 616e 6e6f is program canno
00000060: 7420 6265 2072 756e 2069 6e20 444f 5320 t be run in DOS
00000070: 6d6f 6465 2e0d 0d0a 2400 0000 0000 0000 mode....$.......
00000080: 7518 17ef 3179 79bc 3179 79bc 3179 79bc u...1yy.1yy.1yy.
00000090: 7af3 78bd 3379 79bc 7af3 7dbd 3c79 79bc z.x.3yy.z.}.<yy.
000000a0: 7af3 7cbd 2879 79bc 48f8 78bd 3679 79bc z.|.(yy.H.x.6yy.
000000b0: 3179 78bc 7479 79bc bdf2 7cbd 3679 79bc 1yx.tyy...|.6yy.
000000c0: bdf2 86bc 3079 79bc 3179 eebc 3079 79bc ....0yy.1y..0yy.
000000d0: bdf2 7bbd 3079 79bc 5269 6368 3179 79bc ..{.0yy.Rich1yy.
000000e0: 0000 0000 0000 0000 5045 0000 4c01 0900 ........PE..L...
000000f0: b27c 9d69 0000 0000 0000 0000 e000 0201 .|.i............
MZ at offset 0x00, PE\x00\x00 at 0xe8, then 4c01 (i386) and 0900 (9 sections). The Rich header at 0xd0 is a Microsoft linker artifact, so this was built with MSVC (not MinGW or Borland). Headers are intact and standard - no obfuscation or packing at the format level.
Section layout
$ objdump -x NHK_CrackMe_V3.exe | grep -A20 "^Sections:"
Sections:
Idx Name Size VMA LMA File off Algn
0 .textbss 00010000 00401000 00401000 00000000 2**2
ALLOC, LOAD, CODE
1 .text 00005c05 00411000 00411000 00000400 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
2 .rdata 00002431 00417000 00417000 00006200 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .data 00000200 0041a000 0041a000 00008800 2**2
CONTENTS, ALLOC, LOAD, DATA
4 .idata 00000b6c 0041b000 0041b000 00008a00 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
5 .msvcjmc 000003a1 0041c000 0041c000 00009600 2**2
CONTENTS, ALLOC, LOAD, DATA
A few things stand out:
.textbss(64KB) has CODE + ALLOC but no CONTENTS - writable and executable. Looks suspicious at first, but it’s actually MSVC’s Edit-and-Continue section for debug builds. Red herring..text(24KB) is the real code section, read-only + executable. All the functions we care about live here..data(512 bytes) is tiny. The program has very few globals, but they turn out to matter a lot: the function pointer, hash value, and timer state all live here..msvcjmcis the “Just My Code” section, another debug build marker. This confirms__CheckForDebuggerJustMyCode()calls will appear in every function.
Import table
$ objdump -x NHK_CrackMe_V3.exe | grep "DLL Name" -A20 | head -50
DLL Name: KERNEL32.dll
0001b000 <none> 029f GetModuleHandleW
0001b004 <none> 0603 VirtualAlloc
0001b008 <none> 0336 GetTickCount64
0001b00c <none> 02d6 GetProcAddress
0001b010 <none> 01ce FreeLibrary
0001b014 <none> 060b VirtualQuery
0001b04c <none> 03ad IsDebuggerPresent
DLL Name: USER32.dll
0001b094 <none> 0345 SetDlgItemTextW
0001b098 <none> 00f8 EndDialog
0001b09c <none> 0295 MessageBoxW
0001b0a0 <none> 00be DialogBoxParamW
DLL Name: VCRUNTIME140D.dll
DLL Name: ucrtbased.dll
From KERNEL32: VirtualAlloc allocates memory at runtime (interesting), GetTickCount64 measures time (timing check?), GetModuleHandleW gets the module base (caller validation?), IsDebuggerPresent is a well-known anti-debug call, though it might just be pulled in by the CRT.
From USER32: DialogBoxParamW creates a modal dialog, MessageBoxW shows results. Standard dialog-based UI.
The trailing D in VCRUNTIME140D.dll means “Debug” - release builds link against VCRUNTIME140.dll. Same for ucrtbased.dll. Definitely a debug build.

No networking, no file I/O, no crypto APIs. The flag decryption has to be implemented inline.
String extraction
$ strings -e l NHK_CrackMe_V3.exe | head -20
DIRECT CALL BLOCKED
Error
Tu n'es pas premium ! Prix : 2 BTC
Fail
TOO LATE
Success
Demo
...
Nuit du Hack 2026
MS Shell Dlg
Check Licence
Licence statut :
Demo
Enter your license key
The -e l flag extracts UTF-16LE (wide) strings. These map to four program states:
| String | Meaning |
|---|---|
"Tu n'es pas premium ! Prix : 2 BTC" | Error path, shown when the default function pointer is called |
"Success" | Title of the flag MessageBox, our target |
"DIRECT CALL BLOCKED" | Anti-tampering message, shown if decryption is called from outside the module or without a timer |
"TOO LATE" | Time gate, shown if decryption takes longer than 5 seconds |
"Demo" | Button label |
"Nuit du Hack 2026" | Challenge event name in the dialog resource |
The flag itself doesn’t appear in the strings output. It’s either encrypted or constructed at runtime. The “DIRECT CALL BLOCKED” string tells us the decryption function has its own protections beyond the dialog handler’s integrity check.
Step 2: Static Analysis with Ghidra
Ghidra headless analysis
$ analyzeHeadless /tmp ghidra_project -import NHK_CrackMe_V3.exe \
-postScript ExportAll.java -scriptPath ./ghidra_scripts
INFO Using Language/Compiler: x86:LE:32:default:windows
INFO ANALYZING all memory and code: NHK_CrackMe_V3.exe
INFO Skipping PDB processing: failed to locate PDB file
INFO ExportAll.java> Decompiled 138 functions
INFO ExportAll.java> Exported 301 functions
INFO ExportAll.java> Exported 123 strings
INFO Total Time 6 secs
Ghidra auto-detected x86 LE 32-bit with Windows compiler spec. No PDB found, so all function names are auto-generated (FUN_XXXXXXXX). Out of 301 functions, 163 are thunks to DLL imports or CRT functions. The 138 user-defined functions are what we need to look at, though most of those are MSVC CRT boilerplate. The crackme logic is about 10 functions.
Summary output
Binary Analysis Summary
=======================
File: NHK_CrackMe_V3.exe
Architecture: x86
Address Size: 32 bit
Compiler: windows
Functions:
Total: 301
Thunks: 163
User-defined: 138
Memory Sections:
.textbss: 00401000 - 00410fff (65536 bytes) [X] [W] [R]
.text: 00411000 - 00416dff (24064 bytes) [X] [R]
.rdata: 00417000 - 004195ff (9728 bytes) [R]
.data: 0041a000 - 0041a3cf (976 bytes) [W] [R]
Step 3: Understanding the Program Flow
The following diagram shows the complete program flow from startup to flag display, including all protection checks:
Initialization (FUN_00411d70)
Ghidra decompilation:
/* FUN_00411d70 @ 00411d70 -- WinMain equivalent */
void FUN_00411d70(HINSTANCE param_1)
{
__CheckForDebuggerJustMyCode(&DAT_0041c1bc);
DAT_0041a308 = &LAB_004112ad; // code address to monitor
DAT_0041a30c = 0x100; // code size (256 bytes)
DAT_0041a304 = thunk_FUN_00411f60(0x4112ad, 0x100); // compute hash
DAT_0041a300 = VirtualAlloc(NULL, 4, 0x3000, 0x40); // MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE
if (DAT_0041a300 != NULL) {
*DAT_0041a300 = thunk_FUN_00411ac0; // store ptr to ERROR handler
}
DialogBoxParamW(param_1, (LPCWSTR)0x8, NULL, (DLGPROC)&LAB_0041102d, 0);
}
Before showing the dialog, this function does three things:
- Stores the address
0x4112adand size0x100in globals, then computes a hash of that 256-byte code region. This hash gets checked every time the button is clicked. - Calls
VirtualAlloc(NULL, 4, 0x3000, 0x40)to allocate just 4 bytes (one pointer) withPAGE_EXECUTE_READWRITEpermissions. Only 4 bytes. RWX.
3. Writes the address of the error handler into that pointer: *DAT_0041a300 = thunk_FUN_00411ac0.
So instead of calling error_handler(hwnd) directly, the code reads a pointer from heap memory and calls whatever it points to. The flag name VirtualProtect_Overwritten gives the game away: the intended solution is to overwrite this pointer. And since it lives on the heap, not in the .text section the integrity check monitors, there’s nothing stopping us.
Dialog Procedure (FUN_00411c00)
/* FUN_00411c00 @ 00411c00 -- Dialog message handler */
undefined4 FUN_00411c00(HWND param_1, int param_2, short param_3)
{
__CheckForDebuggerJustMyCode(&DAT_0041c1bc);
if (param_2 == 0x110) { // WM_INITDIALOG
SetDlgItemTextW(param_1, 0x3ea, L"Demo");
return 1;
}
if (param_2 == 0x111) { // WM_COMMAND
if (param_3 == 2) { // Close button
EndDialog(param_1, 0);
return 1;
}
if (param_3 == 0x3eb) { // "Demo" button (control ID 1003)
bVar1 = thunk_FUN_004120b0(); // integrity check
if (bVar1 == 0 || DAT_0041a300 == NULL) {
thunk_FUN_00411ac0(param_1); // error path
} else {
local_14 = *DAT_0041a300; // read function pointer
(*local_14)(param_1); // INDIRECT CALL
}
return 1;
}
}
return 0;
}
Standard Win32 dialog procedure. On WM_INITDIALOG, it sets the button text to “Demo”. When the button is clicked (control ID 0x3EB = 1003):
- Run the integrity check (
FUN_004120b0). If it fails, show the error. - Check
DAT_0041a300isn’t NULL. - Dereference the pointer and call whatever function it points to.
That indirect call at (*local_14)(param_1) is where the runtime exploit happens. Overwrite *DAT_0041a300 with 0x00411b30 (success handler) instead of 0x00411ac0 (error handler), click the button, done. The integrity check doesn’t verify the heap pointer.
Error Handler (FUN_00411ac0)
/* FUN_00411ac0 @ 00411ac0 -- Default "not premium" error */
void __cdecl FUN_00411ac0(HWND param_1)
{
__CheckForDebuggerJustMyCode(&DAT_0041c0ea);
MessageBoxW(param_1, L"Tu n'es pas premium ! Prix : 2 BTC", L"Error", 0x10);
}
Just shows the error MessageBox (0x10 = MB_ICONERROR). This is what gets called by default.

Success Handler (FUN_00411b30)
/* FUN_00411b30 @ 00411b30 -- Flag display path */
void __cdecl FUN_00411b30(HWND param_1)
{
__CheckForDebuggerJustMyCode(&DAT_0041c0ea);
thunk_FUN_00411e60(); // start timer (GetTickCount64)
uVar3 = thunk_FUN_004117e0(); // decrypt flag (XOR 0x55)
iVar1 = thunk_FUN_00411ff0(); // check elapsed < 5001ms
if (iVar1 == 0) {
MessageBoxW(param_1, L"TOO LATE", L"Fail", 0);
} else {
MessageBoxW(param_1, flag_string, L"Success", 0x40);
}
}
Three steps: start a timer, decrypt the flag, check if it all happened within 5 seconds. Even if we redirect execution here, the decryption function has its own protections (timer, caller validation). But for static analysis we just need to read the decompilation - we don’t need to execute any of this.
Step 4: Protection Mechanisms
The following diagram shows how the protections layer and where the bypass opportunities are:
Protection 1: Indirect Function Pointer via VirtualAlloc
The button dispatches through a function pointer in heap memory (RWX). Default target is the error handler. To reach the success path, overwrite this pointer to FUN_00411b30.
This prevents the simplest attack - patching a CALL or JMP in .text. The call target comes from writable heap memory, so a static binary patch can’t change it (the heap address is different each run). But the heap pointer itself is unprotected. The integrity check watches .text code, not heap contents. A debugger, WriteProcessMemory, or Cheat Engine can overwrite those 4 bytes.
Protection 2: Code Integrity Check (FUN_004120b0)
/* FUN_004120b0 @ 004120b0 -- Integrity verification */
bool FUN_004120b0(void)
{
__CheckForDebuggerJustMyCode(&DAT_0041c27e);
if (DAT_0041a308 == 0 || DAT_0041a30c == 0) {
return false;
}
// Scan for INT3 breakpoints (0xCC) in first 0x20 bytes
iVar2 = thunk_FUN_00411ed0(DAT_0041a308, 0x20);
if (iVar2 == 0) {
// Verify code hash matches startup value
uVar1 = thunk_FUN_00411f60(DAT_0041a308, DAT_0041a30c);
return uVar1 == DAT_0041a304;
}
return false;
}
This runs every time the button is clicked, not just at startup. Two checks in sequence:
- Scan the first
0x20(32) bytes of the monitored region at0x4112adfor0xCCbytes.0xCCis the INT3 instruction debuggers use for software breakpoints. - If no breakpoints found, recompute the hash of the full
0x100(256) byte region and compare against the startup value.
This stops you from setting breakpoints in the monitored code or patching instructions there. But the monitored region is only 256 bytes, and it doesn’t cover the heap where the function pointer lives.
Protection 3: Breakpoint Scanner (FUN_00411ed0)
/* FUN_00411ed0 @ 00411ed0 -- Scan for INT3 (0xCC) bytes */
undefined4 __cdecl FUN_00411ed0(int param_1, uint param_2)
{
__CheckForDebuggerJustMyCode(&DAT_0041c27e);
for (i = 0; i < param_2; i++) {
if (*(char *)(param_1 + i) == -0x34) // -0x34 signed = 0xCC unsigned = INT3
return 1; // breakpoint detected!
}
return 0;
}
Byte-by-byte scan for 0xCC. Ghidra shows -0x34 because it’s treating the comparison as signed char, but -0x34 in two’s complement is 0xCC. Only scans the first 0x20 bytes. Misses hardware breakpoints entirely (DR0-DR3 registers are invisible to memory reads).

Protection 4: Hash Function (FUN_00411f60)
/* FUN_00411f60 @ 00411f60 -- Rolling hash: h = h * 33 ^ byte */
uint __cdecl FUN_00411f60(int param_1, uint param_2)
{
__CheckForDebuggerJustMyCode(&DAT_0041c27e);
local_c = 0;
for (i = 0; i < param_2; i++) {
local_c = local_c * 0x21 ^ (uint)*(byte *)(param_1 + i);
}
return local_c;
}
A DJB2 variant (Dan Bernstein’s hash). Multiplier 0x21 = 33 is the characteristic DJB2 constant. Changing even a single byte in the 256-byte region at 0x4112ad produces a different hash and fails the integrity check. No way to patch code there without detection.
Protection 5: Caller Validation (FUN_004119d0)
/* FUN_004119d0 @ 004119d0 -- Verify caller is within module */
undefined1 FUN_004119d0(void)
{
__CheckForDebuggerJustMyCode(&DAT_0041c0ea);
local_c = return_address; // captured from stack
pHVar1 = GetModuleHandleW(NULL); // module base address
if (pHVar1 == NULL) return 0;
end = base + PE_header->SizeOfImage; // module end address
if (local_c < pHVar1 || end <= local_c)
return 0; // caller is outside the module!
return 1;
}
Grabs its own return address off the stack and checks whether it falls within [module_base, module_base + SizeOfImage). Called from the decryption function. If you call the decryptor from injected shellcode in another memory region, this returns 0 and you get “DIRECT CALL BLOCKED”. Doesn’t matter for static analysis since we never need to call it.
Protection 6: Time Gate (FUN_00411ff0)
/* FUN_00411ff0 @ 00411ff0 -- 5-second time window */
undefined4 FUN_00411ff0(void)
{
__CheckForDebuggerJustMyCode(&DAT_0041c27e);
if (DAT_0041a310 == 0) return 0; // timer not started
GetTickCount64();
elapsed = now - start_time;
if (elapsed < 0x1389) { // 0x1389 = 5001 milliseconds
return 1;
}
DAT_0041a310 = 0; // one-shot: permanently disable
return 0;
}
Uses GetTickCount64() (millisecond-resolution). If more than 5001ms have passed, the timer gets permanently disabled (DAT_0041a310 = 0). Can’t retry without restarting. Anti-debugging measure: single-stepping through the success handler would easily blow the 5-second window. Bypassable with hardware breakpoints, by hooking GetTickCount64, or by just not debugging this path.
Protection 7: Timer Start (FUN_00411e60)
/* FUN_00411e60 @ 00411e60 -- Record start timestamp */
void FUN_00411e60(void)
{
__CheckForDebuggerJustMyCode(&DAT_0041c27e);
DAT_0041a310 = 1; // timer_started = true
GetTickCount64();
_DAT_0041a318 = result_low; // 64-bit timestamp (low dword)
_DAT_0041a31c = result_high; // 64-bit timestamp (high dword)
}
Stores the current GetTickCount64 value as two 32-bit DWORDs (32-bit binary, 64-bit value) and arms the timer.
Step 5: Flag Decryption Function (FUN_004117e0)
This is where the flag actually gets decrypted. Ghidra decompilation:
/* FUN_004117e0 @ 004117e0 -- XOR decryption with key 0x55 */
undefined8 FUN_004117e0(void)
{
__CheckForDebuggerJustMyCode(&DAT_0041c0ea);
// Check timer is running (anti-direct-call)
iVar3 = thunk_FUN_00411ff0();
if (iVar3 == 0) {
return L"DIRECT CALL BLOCKED";
}
// Verify caller is within module (anti-injection)
cVar1 = FUN_004119d0();
if (cVar1 == '\0') {
return L"DIRECT CALL BLOCKED";
}
// Decrypt only once (flag cached after first decryption)
if (DAT_0041a2c8 == '\0') {
// 33 hardcoded encrypted bytes loaded onto the stack
local_2c[0] = 0x1b; local_2c[1] = 0x1d; local_2c[2] = 0x1e;
local_2c[3] = 0x67; local_2c[4] = 0x63; local_2c[5] = 0x2e;
local_2c[6] = 0x03; local_2c[7] = 0x3c; local_2c[8] = 0x27;
local_2c[9] = 0x21; local_2c[10] = 0x20; local_2c[11] = 0x34;
local_2c[12] = 0x39; local_2c[13] = 0x05; local_2c[14] = 0x27;
local_2c[15] = 0x3a; local_2c[16] = 0x21; local_2c[17] = 0x30;
local_2c[18] = 0x36; local_2c[19] = 0x21; local_2c[20] = 0x0a;
local_2c[21] = 0x1a; local_2c[22] = 0x23; local_2c[23] = 0x30;
local_2c[24] = 0x27; local_2c[25] = 0x22; local_2c[26] = 0x27;
local_2c[27] = 0x3c; local_2c[28] = 0x21; local_2c[29] = 0x21;
local_2c[30] = 0x30; local_2c[31] = 0x3b; local_2c[32] = 0x28;
// XOR each byte with 0x55, store as UTF-16LE wide chars
for (i = 0; i < 0x21; i++) {
*(ushort *)(flag_buffer + i * 2) = local_2c[i] ^ 0x55;
}
// After decryption, overwrite the encrypted bytes in reverse order
// (anti-memory-dump: clears the ciphertext from the stack)
DAT_0041a2c8 = 1; // mark as already decrypted
}
return flag_buffer;
}
Three layers of defense before it produces the flag:
FUN_00411ff0()must return non-zero, meaning the timer was started and less than 5 seconds have passed. Call this directly without the success handler starting the timer and you get “DIRECT CALL BLOCKED”.FUN_004119d0()checks the return address is within the module. Call from injected code and you get “DIRECT CALL BLOCKED”.- The flag is only decrypted once (guarded by
DAT_0041a2c8). Afterward, the encrypted bytes get overwritten in reverse on the stack - an anti-dump technique.
The decryption itself is simple: 33 bytes XORed with 0x55, stored as UTF-16LE wide characters. The encrypted bytes aren’t contiguous in the binary - they’re loaded as individual MOV instructions (stack immediates), so strings or a hex search won’t find the ciphertext as a block.
But none of that matters. We have the 33 encrypted bytes as literal constants, the XOR key 0x55 as a literal constant, and the algorithm. That’s everything we need.

Step 6: Flag Extraction (Static)
The following diagram shows the memory layout and attack surface:
Python decryption script
We have the encrypted bytes and the XOR key from the decompilation. No need to run the binary:
#!/usr/bin/env python3
enc = [0x1b, 0x1d, 0x1e, 0x67, 0x63, 0x2e, 0x03, 0x3c,
0x27, 0x21, 0x20, 0x34, 0x39, 0x05, 0x27, 0x3a,
0x21, 0x30, 0x36, 0x21, 0x0a, 0x1a, 0x23, 0x30,
0x27, 0x22, 0x27, 0x3c, 0x21, 0x21, 0x30, 0x3b, 0x28]
flag = ''.join(chr(b ^ 0x55) for b in enc)
print(flag)
$ python3 solve.py
NHK26{VirtualProtect_Overwritten}
Quick sanity check: 0x1b ^ 0x55 = 0x4E = 'N', 0x1d ^ 0x55 = 0x48 = 'H', 0x1e ^ 0x55 = 0x4B = 'K' - that’s the expected flag prefix. 0x2e ^ 0x55 = 0x7b = '{' at index 5 and 0x28 ^ 0x55 = 0x7d = '}' at index 32 confirm the flag format.
The flag name tells us the intended runtime solution: overwrite the VirtualAlloc’d function pointer. We got it through static analysis instead, bypassing all the runtime protections at once.
Byte-by-byte breakdown
| Index | Encrypted | XOR 0x55 | ASCII |
|---|---|---|---|
| 0x00 | 0x1B | 0x4E | N |
| 0x01 | 0x1D | 0x48 | H |
| 0x02 | 0x1E | 0x4B | K |
| 0x03 | 0x67 | 0x32 | 2 |
| 0x04 | 0x63 | 0x36 | 6 |
| 0x05 | 0x2E | 0x7B | { |
| 0x06 | 0x03 | 0x56 | V |
| 0x07 | 0x3C | 0x69 | i |
| 0x08 | 0x27 | 0x72 | r |
| 0x09 | 0x21 | 0x74 | t |
| 0x0A | 0x20 | 0x75 | u |
| 0x0B | 0x34 | 0x61 | a |
| 0x0C | 0x39 | 0x6C | l |
| 0x0D | 0x05 | 0x50 | P |
| 0x0E | 0x27 | 0x72 | r |
| 0x0F | 0x3A | 0x6F | o |
| 0x10 | 0x21 | 0x74 | t |
| 0x11 | 0x30 | 0x65 | e |
| 0x12 | 0x36 | 0x63 | c |
| 0x13 | 0x21 | 0x74 | t |
| 0x14 | 0x0A | 0x5F | _ |
| 0x15 | 0x1A | 0x4F | O |
| 0x16 | 0x23 | 0x76 | v |
| 0x17 | 0x30 | 0x65 | e |
| 0x18 | 0x27 | 0x72 | r |
| 0x19 | 0x22 | 0x77 | w |
| 0x1A | 0x27 | 0x72 | r |
| 0x1B | 0x3C | 0x69 | i |
| 0x1C | 0x21 | 0x74 | t |
| 0x1D | 0x21 | 0x74 | t |
| 0x1E | 0x30 | 0x65 | e |
| 0x1F | 0x3B | 0x6E | n |
| 0x20 | 0x28 | 0x7D | } |
Intended Runtime Solution
The flag name describes the intended approach. To solve this dynamically:
- Overwrite the function pointer at
*DAT_0041a300with0x00411b30(success handler) instead of0x00411ac0(error handler) - Don’t touch the
.textcode - the integrity hash covers0x100bytes at0x004112ad, and any patch there gets detected - The pointer lives in heap memory (VirtualAlloc), outside the hashed region, so writing to it is invisible to the integrity check
- Click the button within 5 seconds of the timer starting
Ways to do it: set the value in a debugger after initialization, use WriteProcessMemory from an external tool, or use Cheat Engine to find and modify the pointer at runtime.
To conclude
- XOR encryption with a static key is not encryption. The key (
0x55) and ciphertext are both in the binary. Static extraction takes about 30 seconds. - Anti-debug checks don’t help if you can avoid the guarded path entirely. The flag data exists in
.textregardless of whether the runtime checks pass. - Function pointer indirection adds a layer of complexity but both the pointer target and the success handler are right there in the decompilation.
- The challenge stacks five protections (indirect call, integrity hash, breakpoint scan, caller validation, time gate) but they all become irrelevant when the XOR key is recoverable statically. The weakest link - single-byte XOR - collapses the entire scheme.
