Une nuit pour hacker 2026: Thread of Doom

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.
  • .msvcjmc est 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.

Disaster Girl - “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 :

ChaineSignification
"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&apos;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 :

  1. Elle stocke l’adresse 0x4112ad et la taille 0x100 dans 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.
  2. Elle appelle VirtualAlloc(NULL, 4, 0x3000, 0x40) pour allouer seulement 4 octets (un pointeur) avec les permissions PAGE_EXECUTE_READWRITE. Seulement 4 octets. RWX.

C’est ca, la protection memoire ? 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) :

  1. Execution de la verification d’integrite (FUN_004120b0). En cas d’echec, affichage de l’erreur.
  2. Verification que DAT_0041a300 n’est pas NULL.
  3. 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.

Tais-toi et prends mes 2 BTC

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 :

  1. Scan des 0x20 (32) premiers octets de la region surveillee a 0x4112ad a la recherche d’octets 0xCC. 0xCC est l’instruction INT3 utilisee par les debogueurs pour les breakpoints logiciels.
  2. 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).

Expanding brain : de IsDebuggerPresent a ignorer l’existence des breakpoints materiels

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 :

  1. 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”.
  2. FUN_004119d0() verifie que l’adresse de retour se trouve dans le module. Appeler depuis du code injecte donne “DIRECT CALL BLOCKED”.
  3. 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.

Flex Tape : 7 couches de protection a l’execution patchees par la cle XOR 0x55 codee en dur


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

IndexChiffreXOR 0x55ASCII
0x000x1B0x4EN
0x010x1D0x48H
0x020x1E0x4BK
0x030x670x322
0x040x630x366
0x050x2E0x7B{
0x060x030x56V
0x070x3C0x69i
0x080x270x72r
0x090x210x74t
0x0A0x200x75u
0x0B0x340x61a
0x0C0x390x6Cl
0x0D0x050x50P
0x0E0x270x72r
0x0F0x3A0x6Fo
0x100x210x74t
0x110x300x65e
0x120x360x63c
0x130x210x74t
0x140x0A0x5F_
0x150x1A0x4FO
0x160x230x76v
0x170x300x65e
0x180x270x72r
0x190x220x77w
0x1A0x270x72r
0x1B0x3C0x69i
0x1C0x210x74t
0x1D0x210x74t
0x1E0x300x65e
0x1F0x3B0x6En
0x200x280x7D}

Solution prevue a l’execution

Le nom du flag decrit l’approche prevue. Pour resoudre ce challenge de maniere dynamique :

  1. Ecraser le pointeur de fonction a *DAT_0041a300 avec 0x00411b30 (gestionnaire de succes) au lieu de 0x00411ac0 (gestionnaire d’erreur)
  2. Ne pas toucher au code de .text - le hash d’integrite couvre 0x100 octets a 0x004112ad, et tout patch y serait detecte
  3. Le pointeur reside en memoire tas (VirtualAlloc), en dehors de la region hashee, donc y ecrire est invisible pour la verification d’integrite
  4. 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

  1. 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.
  2. Les verifications anti-debug ne servent a rien si l’on peut eviter entierement le chemin protege. Les donnees du flag existent dans .text independamment de la reussite ou non des controles a l’execution.
  3. 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.
  4. 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.

Effet domino : la cle XOR mono-octet visible dans la decompilation fait tomber les 7 protections