Exploit4Food I

Hace un par de semanas, la gente de 48bits pensó en hacer un reto al que decidieron llamar Exploit4Food. El reto consistía en encontrar un bug en un software determinado, explotarlo y redactar unas cuantas líneas explicando el proceso seguido para conseguirlo. Una de las soluciones publicadas ha sido la de uno de nuestros pandas y, mientras en 48bits ya preparan la segunda edición del reto, aquí hemos pensado dejar una versión corregida (y mínimamente ampliada) del report presentado. Consideradlo un aperitivo de cara a las prequals del CTF que empiezan mañana ;D
Netopia SW DC Server Remote Blowflow (Blowfish Overflow)
Herramientas
Las dos herramientas principales utilizadas para localizar el bug y ayudar al desarrollo del correspondiente exploit han sido:
- IDA Pro 5.1
- OllyDbg
Y de forma opcional:
- Python
- Metasploit (para la shellcode)
Descripcion del bug
Estaba claro, por la descripción del reto y por el tipo de software que debíamos analizar, que el bug a buscar/encontrar era remoto. Por lo tanto el mejor sitio para empezar era estudiar las llamadas a recv() y recvfrom().
Las llamadas a recv() son las mas interesantes y las que, desde el punto de vista de funcionamiento del software (como veremos mas adelante) tiene mas sentido analizar. Todas las llamadas arecv() pasan por una función que hace las veces de wrapper (lo típico, una función que se encarga de hacer el control de errores, de llamar a recv() las veces que haga falta para llenar el buffer, etc.)
A partir de aquí, podemos decir que tenemos dos tipos de llamadas a recv(), la primera es a través de 0×4077E0, función que podríamos llamar recv_dword() ya que lo que hace es precisamente leer 4 bytes del socket y tratarlos como un entero sin signo (uint).
La segunda llamada a recv() se produce a través del método de una clase y la podemos identificar como call [reg+14h].
Precisamente monitorizando las llamadas a estas funciones es como llegamos a la parte interesante del programa, concretamente a lafunción 0×418AA0. Veamos un pequeño resumen del decompilado de esta función:
ReceiveDword(&pDword); if (pDword > 1 ) ErrorHandling(15000, 269484032); ReceiveDword(&v19); ReceiveDword(&v20); if ( v20 == 1 || v20 == 2 || v20 == 8 || v20 == 6 || v20 == 9 ) { sub_4188F0(&struct); if ( !(unsigned __int8)sub_40F890(&struct) ) { v17 = 1; sub_407B40(&;v17); v17 = 0; sub_407B40(&v17); sub_407B40(&v20); v17 = 15015; return sub_407B40(&v17); } } switch ( v20 - 1 ) { case 0: result = DoCommand01(*(_DWORD *)(v2 + 20)); goto LABEL_14; case 1: result = DoCommand02(*(_DWORD *)(v2 + 20)); *(_BYTE *)(v2 + 24) = 1; break; case 7: result = DoCommand08(*(_DWORD *)(v2 + 20)); *(_BYTE *)(v2 + 24) = 1; break; case 8: result = DoCommand09(*(_DWORD *)(v2 + 20)); *(_BYTE *)(v2 + 24) = 1; break; case 2: result = DoCommand03(*(_DWORD *)(v2 + 20)); break; case 3: result = DoCommand04(*(_DWORD *)(v2 + 20)); break; case 4: result = DoCommand05(*(_DWORD *)(v2 + 20)); break; case 5: result = DoCommand06(*(_DWORD *)(v2 + 20)); LABEL_14: *(_BYTE *)(v2 + 28) = 1; break; default: result = ErrorHandling(15000, 269484032); break; |
A partir de aquí podemos extraer el modo de funcionamiento del programa. Como vemos, se llama varias veces a recv_dword() y se comprueba el valor devuelto para comprobar que cumple unas determinadas características. De esta forma, el primer dword leído puede ser 0×0 o 0×1, el segundo se ignora y en el tercero ya empieza lo divertido.
Sobre el tercer dword leído se comprueba si es igual a 1, 2, 6, 8 ó 9 para llamar a unas funciones previas, pero al final hay un switch para cada valor (hasta un valor máximo de 9) y en cada switch se llama a una función diferente. Este comportamiento nos indica que este tercer valor corresponde al identificador de comando/función que está solicitando el cliente.
En nuestro caso vamos a suponer que el comando/función nos lleva a ejecutar esas funciones previas. En la primera función que llama (0×4188F0), vemos que tras un par de subrutinas de inicialización tenemos otra llamada arecv_dword() y tras ella, si el valor leído no es 0×0 hay una llamada indirecta a call [reg+14h]
Esta llamada indirecta nos lleva al wrapper de recv() que antes comentamos. Si nos fijamos en el código, vemos que el valor leído vía recv_dword() se pasa como parámetro… Exacto, el valor leído se utiliza como tamaño para el siguiente recv(), algo bastante habitual en protocolos y estructuras binarias tener campos de tamaño variable precedidos de un campo fijo que especifica su tamaño.
El detalle está en que en ningún momento se comprueba este tamaño y el buffer sobre el que se lee tiene un tamaño fijo de 0×100 (256 bytes) y para el recv() se utiliza una tamaño límite fijo de 10000 bytes. Pues ya tenemos un overflow bastante claro, ahora veamos cómo continua la función.
Para ver qué más tenemos en la función, primero explicaremos cómo se supone que debería funcionar el software en condiciones normales y luego veremos cómo nos complica el overflow y cómo aprovecharnos de sus peculiaridades.
Después de hacer el recv() el software llama a 0×420850. Si le damos un vistazo, vemos que tiene toda la pinta de ser una función criptográfica. A partir de las constantes que utiliza, víafindcrypt (plugin de IDA) o simplemente utilizando un buscador encontramos que son propias de Blowfish y Haval. Dado que uno de los parámetros pasados a la función es una cadena fija, es prácticamente seguro que se trata de Blowfish y que la cadena es la clave de cifrado. En estos casos en que podemos tener dudas lo más recomendable es bajar una implementación de cada (Blowfish y Haval), estudiar sus diferencias y ver cuál se aproxima más a las funciones que estamos analizando. Tengo que reconocer que me costó admitir que se trataba deBlowfish por culpa del tamaño de la clave. La cadena que utiliza es de 96 bytes y “se supone” que el tamaño máximo de clave en Blowfish es de 56…
Una vez identificado el algoritmo (Blowfish), vemos que se compone de dos funciones (que son las que utilizan las constantes): Blowfish_SetKey (0×420850) que sirve de función de inicialización, y Blowfish_Decrypt (0×420C70) que se encarga de descifrar. Es interesante comprobar que a lo largo de todo el software siempre se llaman juntas, y nuestra función vulnerable no es una excepción.
Bien, hasta ahora tenemos un buffer de 0×100 bytes que descifra con Blowfish utilizando una clave fija. Siguiendo con el proceso, se vuelve a llamar a recv_dword() y se vuelve a utilizar el valor leído como el tamaño de los datos que vuelven a continuación. Al igual que antes los datos se leen en unbuffer de tamaño fijo (256 bytes) y se descifran con Blowfish, pero utilizando como clave
los 16 primeros bytes del primer buffer descifrado!! ¿Cuál es el sentido de todo esto? Probablemente se trate de una especie de negociado de clave de sesión cutre: en el primer bloque se especifica la clave de sesión cifrada con la clave fija, y en el segundo se envían los datos ya cifrados con la clave de sesión… lamentable
Una vez pasadas las funciones de Blowfish, se preparan los datos que devuelve la función (básicamente los 16 primeros bytes del buffer descifrado), se comprueba el CanaryStack para ver que no hemos sido malos y acabamos.
int __thiscall BlowfishStuff(int this,int OutParamStruct)
{
int _this; // esi@1
void *v4; // eax@5
int ret_addr; // [sp+360h] [bp+4h]@1
int canary_cookie; // [sp+348h] [bp-14h]@1
char Blowfish_ctx; // [sp+1Ch] [bp-340h]@1
_BYTE cryptobuf[256]; // [sp+144h] [bp-218h]@1
_BYTE plain_key[256]; // [sp+244h] [bp-118h]@1
int sz_cryptobuf; // [sp+18h] [bp-344h]@1
_BYTE plaintext[256]; // [sp+44h] [bp-318h]@3
__int128 SourceStruct; // [sp+24h] [bp-338h]@5
__int128 DestStruct; // [sp+34h] [bp-328h]@5
_this = this;
canary_cookie = ret_addr ^ Canary;
Blowfish_Alloc_CTX(&Blowfish_ctx);
memset(cryptobuf, 0, sizeof(cryptobuf));
memset(plain_key, 0, sizeof(plain_key));
ReceiveDword((int)&sz_cryptobuf);
if ( sz_cryptobuf )
recv(sz_cryptobuf, cryptobuf, 10000);
Blowfish_SetKey("651020Bald ist Weihnachten und der Nikolaus kommt um die boesen Progammierer zu bestrafen.560425", 96);
Blowfish_Decrypt(cryptobuf, plain_key, sz_cryptobuf);
memset(cryptobuf, 0, sizeof(cryptobuf));
memset(plaintext, 0, sizeof(plaintext));
ReceiveDword((int)&sz_cryptobuf);
if ( sz_cryptobuf )
recv(sz_cryptobuf, cryptobuf, 10000);
Blowfish_SetKey((int)plain_key, 16);
Blowfish_Decrypt(cryptobuf, plaintext, sz_cryptobuf);
*(_QWORD *)&SourceStruct = (_QWORD)plaintext;
*((_QWORD *)&SourceStruct + 1) = *(_QWORD *)&plaintext[8];
Struct_CopyFrom(&SourceStruct, &DestStruct);
Struct_CopyFrom(v4, (void *)OutParamStruct);
Blowfish_Free_CTX();
return CanaryCheck();
}
|
Desarrollo del exploit
Aunque no lo parezca, hay varias formas de explotar esta función. Veremos un par de intentos (que en mi caso resultaron fallidos) y después el que lleva implementado elexploit.
Primer intento
El primer feeling nos llevó directamente a utilizar las funciones que preparan los datos que devuelve la función. Estas funciones lo que hacen es copiar una estructura/objeto de 16 bytes a la dirección de uno de los parámetros (digamos que es un parámetro de salida). El truco está en que utilizando el overflow podemos sobreescribir el puntero perteneciente al parámetro de salida (arg0) de forma que cuando el software copie los datos lo hará en la dirección que nosotros queramos. Esto nos permite sobreescribir 16 bytes (los 16 primeros del segundo bloque de llamadas aBlowfish) en la dirección de memoria que nos dé la gana. El problema es que entre las llamadas a estas funciones y la comprobación del CanaryStack (donde se nos vería el plumero) no encontramos la forma de provocar un salto a la shellcode… a no ser que evitemos pasar por esa comprobación ;D
Segundo intento (SEH)
Podemos escribir en “cualquier” dirección de memoria, lo que también incluye direcciones que provoquen excepciones por no ser válidas o no tener permisos. El caso es que mientras depurábamos el overflow vimos que era posible sobreescribir el primer valor de laSEH-Chain (establecido al principio de la función en 0×418911). Es posible tomar el control sobre el primer eslabón de la SEH-Chain y provocar una excepción, con lo que todo debería estar hecho pero dada nuestra poca experiencia haciendo maldades con SEH no fuimos capaces de encontrar la forma de saltarnos las comprobaciones que el kernel hace sobre los valores del SEH antes de pasarle el control
Así que tocaba buscar una alternativa…
NOTA: Mientras escribimos este report y repasamos las notas, hemos visto dónde está el problema. No nos habíamos fijado en que al saltar la excepción saltábamos a KiUserExceptionDispatcher que tenía esta pinta:
7C90EAEC $ 8B4C24 04 MOV ECX, DWORD PTR SS:[ESP+4]
7C90EAF0 . 8B1C24 MOV EBX, DWORD PTR SS:[ESP]
7C90EAF3 . 51 PUSH ECX
7C90EAF4 . 53 PUSH EBX
7C90EAF5 .- E9 42158783 JMP 0018003C
Obviamente el salto incondicional de 0×7C90EAF5 indica que la función tiene un hook que en nuestro caso pertenecía a un conocido producto antivirus. Curiosamente si nos saltamos este hook, ntdll.RtlIsValidHandler() no falla y podemos explotar el bug vía SEH.
A la tercera…
Llegados a este punto teníamos un problema de autoestima. Podíamos controlar el SEH pero no nos servía para nada. También podíamos sobreescribir 16 bytes de cualquier dirección y tampoco nos valía por culpa del CanaryCheck… oops!! Si el problema es el Canary, ¿porqué no cargarselo? Veamos un pequeño repaso a cómo funciona el Canary:
- Al inicio de la ejecución del software se obtiene un dword pseudo-aleatorio i se guarda como Canary en 0×489284.
- Como parte del prólogo de cada función protegida, se guarda en una variable local el valor del Canary xoreado con lo que haya en [esp+4]. A esta variable la llamaremos cookie.
- En el epílogo de cada función la cookie se xorea con [esp+4] y se compara con el Canary. Si no son iguales es que se ha producido un overflow.
De esta forma, si aprovechando el overflow sobreescribimos la cookie y [esp+4] con el mismo valor, el xor dará 0×0. Sólo tenemos que aprovechar el hecho de que podemos sobreescribir 16 bytes para hacer que el Canary también sea 0×0 y habremos evitado la detección, pudiendo aprovechar el retn para saltar a nuestra shellcode.
NOTA: Este método tiene el pequeño inconveniente de afectar a las funciones protegidas con Canary que se ejecutaron antes del overflow y que acaben después, ya que el Canary habrá variado y fallarán en la comprobación final. Por suerte sólo pasa en la función principal, y el problema sale a la luz cuando paramos el servicio deNetopia. Si esto fuera un elemento crítico a tener en cuenta podríamos intentar parchear la función de comprobación del Canary desde la shellcode para que no diera problemas.
Detalles de la shellcode
La shellcode está sacada de metasploit, es un simple WinExec(calc.exe)+ExitThread() y un “sub esp, 0×100” al principio para evitar que los pushes nos machaquen la shellcode. Hay que recordar que los objetivos del reto en cuanto a la shellcodes era simplemente ejecutar algo, y que para ver el efecto calculadora será necesario permitir que el servicio de Netopia pueda interactuar con el escritorio.
En cuanto a la dirección de retorno hemos optado por el típico “jmp esp” (desde kernel32.dll o ntdll.dll) para saltar a la pila y de allí al inicio de la shellcode.
Ahora queda tener claros los offsets dentro del buffer para direcciones de retorno y demás, y por supuesto cifrarlo todo con blowfish. El esquema del buffer que aprovecharemos para explotar la vulnerabilidad del servicio es el siguiente:
| Buffer Offset | Descripción |
|---|---|
| [0x0..0xFC] | Datos que no llegan a provocar el overflow. Aquí tendremos la shellcode |
| [0x100] | Sobreescribimos Blowfish_ctx con cualquier cosa (no importa, es padding) |
| [0x104] | Sobreescribimos el canary_cookie con la dirección de retorno |
| [0x108..0x118] | Más padding |
| [0x11C] | Sobreescribimos la dirección de retorno (mismo valor que en 0×104!!) |
| [0x120] | Sobreescribimos el puntero a la estructura que recibirá los datos de salida de la función. En nuestro caso utilizamos la dirección del CanaryStack (0×489290) – 0xC bytes para cargarnos lo menos posible. Como la función acaba con un “retn 4″ este dword será descartado y no nos molesta para hacer el “jmp esp”. |
| [0x124..] | Aquí es donde aterrizaremos después de hacer el “jmp esp”, así que aquí pondremos un JMP $-X (donde X es el offset al inicio de la shellcode) |
Ahora bien, tenemos un efecto colateral por culpa del Blowfish y de la situación de los buffers. Dado que el buffer cifrado al pasarse de los 0×100 bytes ocupa parte del buffer donde se copian los datos descifrados, tan pronto como Blowfish_Decrypt empiece a descifrar, las posiciones de nuestro “cryptobuffer” que ocupan el espacio de “plain_key” serán sobreescritas (precisamente todas las
que nos interesan para el overflow – offset > 0×100 -)
La solución puede parecer un poco extraña pero se entiende perfectamente si depuramos el proceso. Sólo hay que colocar los datos que necesitamos para el overflow al principio delbuffer en vez de al final ¿? Sí, de esta forma se descifrarán sobre las posiciones que nos interesan (0×100 y posteriores). El único detalle es que cuandoBlowfish_Decrypt llegue a 0×100 seguirá
descifrando… otra vez! Por lo tanto estos datos los tenemos que cifrar dos veces.
Por ejemplo, si nuestro cryptobuf tiene 0×130 bytes una vez se han descifrado los 0×100 primeros podemos considerar que “plain_key” está lleno y el siguiente valor a descifrar será cryptobuf[0x100], que viene a ser lo mismo que plain_key[0x0] que a su vez es Blowfish_Decrypt(cryptobuf[0x0]). De esta forma en nuestro ejemplo de 0×130 bytes, los 0×30 primeros bytes del buffer deben ir cifrados dos veces y los últimos 0×30 realmente no nos servirán de mucho.
Conclusiones
La cosa está clara, ¿no?:
- No usar software de Netopia.
- Repasar los exploits vía SEH para evitar que se me vuelva a ir la olla de esta forma xDD
- Acostumbrarse a que los sexy pandas siempre tienen un “special trick” guardado bajo la manga ^_^
Nos leemos pronto! Y esperamos que sea para dar buenas noticias. Go Pandas!!
