Synthese
- Challenge : Thread of Doom
- Categorie : Reverse Engineering
- Flag :
NHK26{VirtualProtect_Overwritten} - Binaire :
NHK_CrackMe_V3.exe(PE32, x86, 43520 octets)
Vue d’ensemble
Thread of Doom est un crackme Windows qui affiche une boite de dialogue avec un bouton “Demo”. Cliquer sur ce bouton affiche une erreur : “Tu n’es pas premium ! Prix : 2 BTC”. L’objectif est de comprendre les mecanismes de protection du binaire et d’extraire le flag cache.
Le flag est chiffre en memoire par un XOR avec une cle d’un seul octet (0x55). Le binaire ne le dechiffre qu’a l’execution lorsque plusieurs verifications anti-falsification sont validees, mais comme le texte chiffre et la cle sont visibles dans la decompilation, on peut l’extraire de maniere statique sans meme executer le binaire.
Etape 1 : Reconnaissance initiale
Identification du fichier
$ 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
Executable Windows GUI 32 bits, ciblant Vista+. La sortie de file indique “GUI” (et non “console”), on a donc affaire a des boites de dialogue et un WinMain plutot qu’un crackme en console. 9 sections, c’est plus qu’un build release classique (generalement 4-5), ce qui laisse deja supposer un build debug. Pas de packing - des packers comme UPX reduiraient le nombre de sections et changeraient leurs noms.
Inspection de l’en-tete PE
$ 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 a l’offset 0x00, PE\x00\x00 a 0xe8, puis 4c01 (i386) et 0900 (9 sections). L’en-tete Rich a 0xd0 est un artefact du linker Microsoft, le binaire a donc ete compile avec MSVC (ni MinGW ni Borland). Les en-tetes sont intacts et standards - aucune obfuscation ni packing au niveau du format.
Disposition des sections
$ 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
Plusieurs elements attirent l’attention :
.textbss(64 Ko) a les attributs CODE + ALLOC mais pas de CONTENTS - inscriptible et executable. Ca parait suspect au premier abord, mais c’est en realite la section Edit-and-Continue de MSVC pour les builds debug. Fausse piste..text(24 Ko) est la vraie section de code, en lecture seule + executable. Toutes les fonctions qui nous interessent s’y trouvent..data(512 octets) est minuscule. Le programme a tres peu de variables globales, mais elles s’averent determinantes : le pointeur de fonction, la valeur de hash et l’etat du timer y resident..msvcjmcest la section “Just My Code”, un autre marqueur de build debug. Cela confirme que des appels a__CheckForDebuggerJustMyCode()apparaitront dans chaque fonction.
Table des imports
$ 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
Cote KERNEL32 : VirtualAlloc alloue de la memoire a l’execution (interessant), GetTickCount64 mesure le temps (verification temporelle ?), GetModuleHandleW recupere l’adresse de base du module (validation de l’appelant ?), IsDebuggerPresent est un classique de l’anti-debug, bien qu’il soit peut-etre simplement importe par le CRT.
Cote USER32 : DialogBoxParamW cree une boite de dialogue modale, MessageBoxW affiche les resultats. Interface standard a base de boites de dialogue.
Le D final de VCRUNTIME140D.dll signifie “Debug” - les builds release lient contre VCRUNTIME140.dll. Idem pour ucrtbased.dll. C’est indeniablement un build debug.

Pas de reseau, pas d’E/S fichier, pas d’API de chiffrement. Le dechiffrement du flag doit etre implemente en dur dans le code.
Extraction des chaines
$ 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
Le flag -e l extrait les chaines UTF-16LE (wide). Celles-ci correspondent a quatre etats du programme :
| Chaine | Signification |
|---|---|
"Tu n'es pas premium ! Prix : 2 BTC" | Chemin d’erreur, affiche quand le pointeur de fonction par defaut est appele |
"Success" | Titre de la MessageBox du flag, notre cible |
"DIRECT CALL BLOCKED" | Message anti-falsification, affiche si le dechiffrement est appele depuis l’exterieur du module ou sans timer |
"TOO LATE" | Barriere temporelle, affiche si le dechiffrement prend plus de 5 secondes |
"Demo" | Libelle du bouton |
"Nuit du Hack 2026" | Nom de l’evenement dans la ressource de dialogue |
Le flag lui-meme n’apparait pas dans les chaines extraites. Il est soit chiffre, soit construit a l’execution. La chaine “DIRECT CALL BLOCKED” nous indique que la fonction de dechiffrement a ses propres protections au-dela de la verification d’integrite du gestionnaire de dialogue.
Etape 2 : Analyse statique avec Ghidra
Analyse headless Ghidra
$ 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 a automatiquement detecte x86 LE 32 bits avec la specification du compilateur Windows. Pas de PDB trouve, donc tous les noms de fonctions sont generes automatiquement (FUN_XXXXXXXX). Sur les 301 fonctions, 163 sont des thunks vers des imports DLL ou des fonctions CRT. Les 138 fonctions definies par l’utilisateur sont celles qu’il faut examiner, bien que la plupart soient du boilerplate CRT MSVC. La logique du crackme tient en une dizaine de fonctions.
Resume de l’analyse
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]
Etape 3 : Comprendre le flux du programme
Le diagramme suivant montre le flux complet du programme, du demarrage a l’affichage du flag, incluant toutes les verifications de protection :
graph TD
subgraph Initialization ["Initialisation (FUN_00411d70)"]
I1[VirtualAlloc 4 octets RWX] --> I2[Calcul du hash de la region de code]
I2 --> I3["Stockage du pointeur vers le gestionnaire d'erreur<br/>*DAT_0041a300 = FUN_00411ac0"]
I3 --> I4[DialogBoxParamW]
end
I4 --> DP
subgraph DP ["Procedure de dialogue (FUN_00411c00)"]
D1{Type de message}
D1 -->|WM_INITDIALOG| D2["SetDlgItemTextW('Demo')"]
D1 -->|WM_COMMAND| D3{ID du bouton}
D3 -->|Fermer| D4[EndDialog]
D3 -->|"Demo (0x3EB)"| D5["Verification d'integrite<br/>(FUN_004120b0)"]
end
D5 -->|"Echec ou pointeur NULL"| ERR
D5 -->|"Reussite"| IND
IND["Appel indirect<br/>(*DAT_0041a300)(hwnd)"]
IND -->|"Par defaut : gestionnaire d'erreur"| ERR["Gestionnaire d'erreur<br/>'Tu n'es pas premium !'"]
IND -->|"Ecrase : gestionnaire de succes"| SUC
subgraph SUC ["Gestionnaire de succes (FUN_00411b30)"]
S1[Demarrage du timer<br/>GetTickCount64] --> S2[Dechiffrement du flag<br/>XOR 0x55]
S2 --> S3{Temps ecoule < 5s ?}
S3 -->|Oui| S4["Affichage du flag<br/>'Success'"]
S3 -->|Non| S5["'TOO LATE'"]
end
Initialisation (FUN_00411d70)
Decompilation Ghidra :
/* 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);
}
Avant d’afficher la boite de dialogue, cette fonction fait trois choses :
- Elle stocke l’adresse
0x4112adet la taille0x100dans des variables globales, puis calcule un hash de cette region de code de 256 octets. Ce hash est verifie a chaque clic sur le bouton. - Elle appelle
VirtualAlloc(NULL, 4, 0x3000, 0x40)pour allouer seulement 4 octets (un pointeur) avec les permissionsPAGE_EXECUTE_READWRITE. Seulement 4 octets. RWX.
3. Elle ecrit l’adresse du gestionnaire d’erreur dans ce pointeur : *DAT_0041a300 = thunk_FUN_00411ac0.
Ainsi, au lieu d’appeler error_handler(hwnd) directement, le code lit un pointeur depuis la memoire du tas et appelle ce vers quoi il pointe. Le nom du flag VirtualProtect_Overwritten vend la meche : la solution prevue consiste a ecraser ce pointeur. Et puisqu’il reside sur le tas, pas dans la section .text surveillee par la verification d’integrite, rien ne nous en empeche.
Procedure de dialogue (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;
}
Procedure de dialogue Win32 classique. A WM_INITDIALOG, elle definit le texte du bouton a “Demo”. Quand le bouton est clique (control ID 0x3EB = 1003) :
- Execution de la verification d’integrite (
FUN_004120b0). En cas d’echec, affichage de l’erreur. - Verification que
DAT_0041a300n’est pas NULL. - Dereferencement du pointeur et appel de la fonction vers laquelle il pointe.
Cet appel indirect a (*local_14)(param_1) est le point ou l’exploitation a l’execution se produit. Il suffit d’ecraser *DAT_0041a300 avec 0x00411b30 (gestionnaire de succes) au lieu de 0x00411ac0 (gestionnaire d’erreur), de cliquer sur le bouton, et c’est regle. La verification d’integrite ne controle pas le pointeur sur le tas.
Gestionnaire d’erreur (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);
}
Affiche simplement la MessageBox d’erreur (0x10 = MB_ICONERROR). C’est ce qui est appele par defaut.

Gestionnaire de succes (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);
}
}
Trois etapes : demarrage d’un timer, dechiffrement du flag, verification que le tout s’est execute en moins de 5 secondes. Meme si l’on redirige l’execution ici, la fonction de dechiffrement possede ses propres protections (timer, validation de l’appelant). Mais pour l’analyse statique, il suffit de lire la decompilation - pas besoin d’executer quoi que ce soit.
Etape 4 : Mecanismes de protection
Le diagramme suivant montre comment les protections s’empilent et ou se trouvent les possibilites de contournement :
graph TD
P1["1. Pointeur de fonction indirect<br/>via VirtualAlloc (tas RWX)"]
P2["2. Verification d'integrite du code<br/>(hash de 256 octets a 0x4112ad)"]
P3["3. Scanneur de breakpoints<br/>(detection de 0xCC dans les 32 premiers octets)"]
P4["4. Fonction de hash DJB2<br/>(h = h * 33 ^ octet)"]
P5["5. Validation de l'appelant<br/>(adresse de retour dans les limites du module)"]
P6["6. Barriere temporelle<br/>(fenetre de 5 secondes via GetTickCount64)"]
P7["7. Demarrage du timer<br/>(usage unique, desactivation permanente)"]
P1 --> P2 --> P3 --> P4 --> P5 --> P6 --> P7
B1(["Contournement : ecraser le pointeur sur le tas<br/>(hors de la region hashee)"])
B2(["Contournement : utiliser des breakpoints materiels<br/>(invisibles au scan memoire)"])
B3(["Contournement : analyse statique<br/>(la cle XOR 0x55 est un litteral)"])
B1 -.-> P1
B2 -.-> P3
B3 -.-> P5
B3 -.-> P6
B3 -.-> P7
Protection 1 : Pointeur de fonction indirect via VirtualAlloc
Le bouton effectue un dispatch a travers un pointeur de fonction en memoire tas (RWX). La cible par defaut est le gestionnaire d’erreur. Pour atteindre le chemin de succes, il faut ecraser ce pointeur pour qu’il pointe vers FUN_00411b30.
Cela empeche l’attaque la plus simple - patcher un CALL ou JMP dans .text. La cible de l’appel provient de la memoire tas inscriptible, donc un patch statique du binaire ne peut pas la modifier (l’adresse du tas change a chaque execution). Mais le pointeur sur le tas lui-meme n’est pas protege. La verification d’integrite surveille le code de .text, pas le contenu du tas. Un debogueur, WriteProcessMemory ou Cheat Engine peut ecraser ces 4 octets.
Protection 2 : Verification d’integrite du code (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;
}
Cette verification s’execute a chaque clic sur le bouton, pas seulement au demarrage. Deux controles en sequence :
- Scan des
0x20(32) premiers octets de la region surveillee a0x4112ada la recherche d’octets0xCC.0xCCest l’instruction INT3 utilisee par les debogueurs pour les breakpoints logiciels. - Si aucun breakpoint n’est trouve, recalcul du hash de la region complete de
0x100(256) octets et comparaison avec la valeur calculee au demarrage.
Cela empeche de poser des breakpoints dans le code surveille ou d’y patcher des instructions. Mais la region surveillee ne fait que 256 octets, et elle ne couvre pas le tas ou reside le pointeur de fonction.
Protection 3 : Scanneur de breakpoints (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;
}
Scan octet par octet a la recherche de 0xCC. Ghidra affiche -0x34 car il traite la comparaison comme un signed char, mais -0x34 en complement a deux donne 0xCC. Ne scanne que les 0x20 premiers octets. Passe completement a cote des breakpoints materiels (les registres DR0-DR3 sont invisibles aux lectures memoire).

Protection 4 : Fonction de hash (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;
}
Une variante de DJB2 (le hash de Dan Bernstein). Le multiplicateur 0x21 = 33 est la constante caracteristique de DJB2. Modifier ne serait-ce qu’un seul octet dans la region de 256 octets a 0x4112ad produit un hash different et fait echouer la verification d’integrite. Impossible de patcher le code a cet endroit sans etre detecte.
Protection 5 : Validation de l’appelant (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;
}
Recupere sa propre adresse de retour depuis la pile et verifie si elle se situe dans l’intervalle [module_base, module_base + SizeOfImage). Appelee depuis la fonction de dechiffrement. Si l’on appelle le dechiffreur depuis du shellcode injecte dans une autre region memoire, cela retourne 0 et on obtient “DIRECT CALL BLOCKED”. Sans importance pour l’analyse statique puisqu’on n’a jamais besoin de l’appeler.
Protection 6 : Barriere temporelle (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;
}
Utilise GetTickCount64() (resolution en millisecondes). Si plus de 5001 ms se sont ecoulees, le timer est definitivement desactive (DAT_0041a310 = 0). Impossible de reessayer sans redemarrer. Mesure anti-debug : l’execution pas-a-pas du gestionnaire de succes ferait facilement exploser la fenetre de 5 secondes. Contournable avec des breakpoints materiels, en hookant GetTickCount64, ou en n’utilisant tout simplement pas le debogueur sur ce chemin.
Protection 7 : Demarrage du timer (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)
}
Stocke la valeur courante de GetTickCount64 sous forme de deux DWORDs 32 bits (binaire 32 bits, valeur 64 bits) et arme le timer.
Etape 5 : Fonction de dechiffrement du flag (FUN_004117e0)
C’est ici que le flag est reellement dechiffre. Decompilation Ghidra :
/* 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;
}
Trois couches de defense avant de produire le flag :
FUN_00411ff0()doit retourner une valeur non nulle, ce qui signifie que le timer a ete demarre et que moins de 5 secondes se sont ecoulees. Appeler cette fonction directement sans que le gestionnaire de succes ait demarre le timer donne “DIRECT CALL BLOCKED”.FUN_004119d0()verifie que l’adresse de retour se trouve dans le module. Appeler depuis du code injecte donne “DIRECT CALL BLOCKED”.- Le flag n’est dechiffre qu’une seule fois (protege par
DAT_0041a2c8). Ensuite, les octets chiffres sont ecrases en ordre inverse sur la pile - une technique anti-dump.
Le dechiffrement lui-meme est simple : 33 octets XORes avec 0x55, stockes sous forme de caracteres larges UTF-16LE. Les octets chiffres ne sont pas contigus dans le binaire - ils sont charges via des instructions MOV individuelles (immediats sur la pile), donc strings ou une recherche hexadecimale ne trouvera pas le texte chiffre en bloc.
Mais tout cela n’a aucune importance. Nous disposons des 33 octets chiffres sous forme de constantes litterales, de la cle XOR 0x55 comme constante litterale, et de l’algorithme. C’est tout ce dont nous avons besoin.

Etape 6 : Extraction du flag (statique)
Le diagramme suivant montre la disposition memoire et la surface d’attaque :
graph TD
subgraph Sections ["Sections PE"]
TB[".textbss<br/>00401000 - 00410FFF<br/>RWX (Edit-and-Continue)"]
TX[".text<br/>00411000 - 00416DFF<br/>RX (integrite surveillee :<br/>256 octets a 0x4112ad)"]
RD[".rdata<br/>00417000 - 004195FF<br/>R (donnees en lecture seule)"]
DA[".data<br/>0041A000 - 0041A3CF<br/>RW (variables globales)"]
end
subgraph Globals ["Variables globales .data"]
G1["DAT_0041a300 : pointeur vers le tas"]
G2["DAT_0041a304 : hash d'integrite"]
G3["DAT_0041a308 : adresse du code surveille"]
G4["DAT_0041a310 : etat du timer"]
end
subgraph Heap ["Tas (VirtualAlloc)"]
H1["4 octets, RWX<br/>Pointeur de fonction<br/>Defaut : FUN_00411ac0 (erreur)<br/>Cible : FUN_00411b30 (succes)"]
end
DA --> Globals
G1 -->|"pointe vers"| H1
H1 -->|"cible par defaut"| TX
TX -.->|"la verification d'integrite ne couvre<br/>que 256 octets"| DA
Script Python de dechiffrement
Nous disposons des octets chiffres et de la cle XOR issus de la decompilation. Pas besoin d’executer le binaire :
#!/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}
Verification rapide : 0x1b ^ 0x55 = 0x4E = 'N', 0x1d ^ 0x55 = 0x48 = 'H', 0x1e ^ 0x55 = 0x4B = 'K' - c’est le prefixe attendu du flag. 0x2e ^ 0x55 = 0x7b = '{' a l’index 5 et 0x28 ^ 0x55 = 0x7d = '}' a l’index 32 confirment le format du flag.
Le nom du flag nous revele la solution prevue a l’execution : ecraser le pointeur de fonction alloue par VirtualAlloc. Nous l’avons obtenu par analyse statique, contournant ainsi toutes les protections d’un seul coup.
Decomposition octet par octet
| Index | Chiffre | 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 | } |
Solution prevue a l’execution
Le nom du flag decrit l’approche prevue. Pour resoudre ce challenge de maniere dynamique :
- Ecraser le pointeur de fonction a
*DAT_0041a300avec0x00411b30(gestionnaire de succes) au lieu de0x00411ac0(gestionnaire d’erreur) - Ne pas toucher au code de
.text- le hash d’integrite couvre0x100octets a0x004112ad, et tout patch y serait detecte - Le pointeur reside en memoire tas (VirtualAlloc), en dehors de la region hashee, donc y ecrire est invisible pour la verification d’integrite
- Cliquer sur le bouton dans les 5 secondes suivant le demarrage du timer
Moyens pour y parvenir : modifier la valeur dans un debogueur apres l’initialisation, utiliser WriteProcessMemory depuis un outil externe, ou utiliser Cheat Engine pour trouver et modifier le pointeur a l’execution.
Pour conclure
- Le chiffrement XOR avec une cle statique n’est pas du chiffrement. La cle (
0x55) et le texte chiffre sont tous deux dans le binaire. L’extraction statique prend environ 30 secondes. - Les verifications anti-debug ne servent a rien si l’on peut eviter entierement le chemin protege. Les donnees du flag existent dans
.textindependamment de la reussite ou non des controles a l’execution. - L’indirection par pointeur de fonction ajoute une couche de complexite, mais la cible du pointeur et le gestionnaire de succes sont directement visibles dans la decompilation.
- Le challenge empile cinq protections (appel indirect, hash d’integrite, scan de breakpoints, validation de l’appelant, barriere temporelle), mais elles deviennent toutes sans objet lorsque la cle XOR est recuperable statiquement. Le maillon le plus faible - le XOR mono-octet - fait s’effondrer l’ensemble du dispositif.
