UAM Futurama 3 parte 2 partial writeup: ROP contra servidor web sin LEAKS

Introducción

Primero de todo, este no es un write-up completo. Describiré mi exploit para lograr la flag en la segunda parte del reto de Futurama 3 de este mes de la UAM. Se considera el reversing del binario “carl” ya completado y las respectivas vulnerabilidades encontradas para provocar el Buffer Overflow.

El servidor web nos muestra una caja de texto donde podemos introducir el parámetro que recogerá el binario vulnerable carl, y su salida estándar se nos mostrará en el textarea de debajo. Siendo un reto llamado “SSRF”, parecería lógico llegar a imprimir la flag en el textarea tras explotar correctamente el BOF encontrado durante la fase de reversing.

No leaks

Este reto se podía resolver de una manera relativamente habitual en los CTFs de exploiting de BOF: primero, leak. Luego, llamada a system. Sin embargo, y parcialmente inspirado por el reto de c0rn0n4con “Twicat”, me puse como objetivo lograr imprimir la flag con una sola ejecución del payload, sin leaks. Reverseando el binario nos encontrábamos claramente con las llamadas dlsym, dlopen y la cadena “system”. Así pues, llegar a ejecutar system(); sin ningún leak era totalmente factible. Además, el binario estaba repleto de llamadas a funciones que son ideales como primitivas write-what-where (sprintf, strcpy, strncpy, strcat…), por lo que todo apuntaba a que debía ser factible lograr mi objetivo. Como principal problema a superar debía considerar 2 cosas:

Dirección donde escribir el comando a ejecutar

Siendo un binario no PIE, las direcciones siempre son las mismas. A simple vista, la dirección 0x604150 parecía una muy buena candidata. De hecho, en el offset 0x00400f6c de carl veíamos el siguiente código:

Snippet de codigo que mueve a RDI el valor 0x604150 y luego hace un jump a rax.

Este código era ideal para cargar la dirección 0x604150 sobre RDI (único argumento de system) y, teniendo en RAX la dirección de system (gracias a dlsym), ejecutar system con el comando deseado.  Sin embargo, tras el salto a RAX (ejecución de system), carl ejecutaría correctamente el comando pero acabaría con un segfault. El servidor web nos devolvería un “Internal Server Error” sin mostrarnos la salida estándar de carl en el textarea, y como mi intención era mostrar la flag directamente, esta vía estaba descartada.

Dirección del comando a ejecutar y primitiva write-what-where

Para lograr escribir el comando deseado sobre una dirección de mi elección dentro de una sección rw- del binario, busqué primero ROP gadgets del estilo mov [registro], registro. Este tipo de gadget me permitiría hacer cosas del estilo:

# mov [ r14 ], r15
payload += p64(_pop_r14_r15_ret)
payload += p64(0x604150)
payload += “ls l/; \x00”
payload += p64(_write_mem_ret)

Sin embargo, no encontré ninguno que me sirviera. Así que descartada esta opción, seguí buscando, y encontré esto en la función fcn.00400ff7:

Una primitiva write-what-where sobre 0x604180 (nibble) + 0x60184 (otro nibble).

Esto me permitía escribir hasta 8 bytes en dos pasadas, aunque debía luego compensar RBP en el stack dos veces. Llegó a funcionar aunque entonces me quedé sin espacio para mis otros ROP gadgets. En aquel momento no me percaté de que dlopen no era necesaria en absoluto, y que llamando a  dlsym directamente pasandole un NULL en rax como handle (gracias @GNLZ) también retornaba correctamente la dirección de system sobre RAX. Con esto, me habría ahorrado los 3 gadgets de la llamada a dlopen y hubiera tenido suficientes gadgets para lograr el objetivo sobreescribiendo mi comando en 0x604180. Y digo 3 y no 5 gadgets porque yo sí me percaté que dlopen sigue funcionando a pesar de tener un valor indeterminado en RSI (flags) ;-).

Así que sólo me quedaba introducir el comando a ejecutar en la pila del programa y buscar la manera de referenciar dicha dirección dinámica (recordemos, ASLR) usando ROP. Lo primero era fácil, aprovechar el “padding”. Rellené mi padding con algunos patterns básicos “abcdef…ABCDEF…0123..” hasta EIP, y lancé mi exploit dentro de gdb, parando la ejecución de mi payload en el primer gadget y observé los offsets respecto de mi padding y el valor de RSP.  Como es lógico, los OFFSET no varían. Por supuesto, por la manera en que se apilan los datos en el stack, era evidente que mi padding estaría cerca de RSP:

Offset respecto del valor de RSP del padding.

Descubrí que podía referenciar mi padding con algunas instrucciones presentes en el propio binario del estilo [ rbp – OFFSET ]. Pero para ello, claro, debía inicializar RBP al valor de RSP. Encontré un gadget ideal para esto:

mov rbp, rsp; mov rdi, rsi; ret;

Lo siguiente era encontrar o bien un ROP, o bien código legítimo del binario, que me permitiera ejecutar un strcpy, strncpy o similar, rellenando los valores adecuados sobre cada uno de los registros mediante referencias del estilo: mov registro [rbp – OFFSET], teniendo en cuenta que OFFSET debía cumplir el requisito de “caer” dentro de mi padding. Lancé la búsqueda de gadgets (sin éxito), así que busqué en el propio código del binario. Necesitaba que, tras la llamada legítima a la función de escritura de memoria, la función regresara (con LEAVE o RET) para no romper mi ROP chain que venía luego. Me encontré con esta joya:

Primitiva write-what-where con referencias al stack y OFFSETS dentro de mi padding.

Todos los OFFSET presentes caían dentro de mi padding. Podía simplemente escribir valores en mi padding y estos de cargarían sobre los registros RAX, RDX y RCX. RDX tendría la longitud del comando. Bastaría con escribir en el OFFSET correcto de mi padding la longitud del comando como p64(len(cmd)). El comando a ejecutar lo escribiría tal cual directamente sobre el padding, en el OFFSET correcto para que RCX cargase bien dicha dirección [ rbp – 0xb0]. Finalmente, RAX debía acabar computando una dirección dentro del binario que tuviera el valor QWORD adecuado (una dirección dentro de una sección rw- donde poder escribir). El valor 0x604150 que he comentado al principio era ideal, pero el código mov rax, qword [rax+0x30] lee un QWORD. Así que busqué el patrón 0x0000000000604150 sin encontrarlo:

gef➤ search-pattern 0x0000000000604150
[+] Searching ‘\x50\x41\x60\x00\x00\x00\x00\x00’ in memory

Seguí buscando, empezando con la .got.plt:

En la dirección 0x603ef8 tenemos el valor QWORD de la .got.plt

Bien, siempre que mi comando sólo fuera como máximo 8 bytes de largo y no sobreescribiera la siguiente dirección, en un principio podía cargar sobre RAX el valor 0x603ef8-0x30 y así acabaría teniendo la dirección 0x603ef8 sobre RAX. Este valor, 0x00603ec8, debía colocarlo en mi padding en el OFFSET adecuado.

Así, con un primer ROP mov rbp, rsp; mov rdi, rsi; ret; tenía acceso a mi padding pero junto con el slash “/”. No podía controlar el “/”, que se convertía en 0x2f como valor numérico sobre RAX, estropeándome el ataque por completo. Así que ejecuté un segundo ROP idéntico para desplazarme 8 bytes dentro de mi padding y poder controlar la totalidad del valor a escribir sobre RAX. En el exploit, podéis ver claramente 7 As de “prepend”, y luego el valor sobre RAX.

El comando

Sin leaks, ya podía escribir cualquier cosa de hasta 8 bytes de longitud en mi padding y obtener RCE en el servidor. Pero tras mi comando, había parte de morralla de la siguiente dirección concatenada con mi string dentro de la .got.plt y system se quejaba. Así que la única solución era reducir la longitud del comando a 7 bytes y añadir “;” al final. Probando contra el servidor de la UAM, se ejecutaba el primer comando, system se quejaba del segundo pero saliendo con un ROP exit(0) el servidor devolvía la salida de carl en el textarea. Para obtener la flag directamente por pantalla no tenía suficientes caracteres, pero gracias a la shell eso da igual. Algo tan simple como cat /f*; era más que suficiente para lograr mi objetivo!

La flag

Tras pulir un poco mi script y lanzarlo, sin leaks y con una sola ejecución, la tan ansiada flag apareció por pantalla. Fijaos en el padding de la siguiente captura; entre las “B”s y las “C”s se puede ver claramente el comando a ejecutar: cat /f*;. Entre las “A”s y las “B”s el valor que se escribirá sobre RAX:

Lanzando el exploit…

… y consiguiendo la FLAG sin LEAKS y en SSRF!

Podéis bajar el exploit AQUÍ.

@socialkas

Defeating an ELF32 binary with absolutely no leaks without using the ret2_dlresolve technique

 The binary

I was presented with an ELF32 binary with the following protections:

ch77 protections

Disassembling the binary with r2, I quickly recognized a classic stack overflow by abusing the call to read:

There’s a buffer overflow in the read function.

read() would read up to 0x64 bytes into the buffer pointed to by *buf, clearly overwriting EBP and RET. Apart from this more than obvious stack overflow, this binary did not have any interesting functions to analyse. As you can imagine, there was no feasible way to leak data, and we had to assume that ASLR on the remote server was enabled. With no puts, write, printf or alike calls within the binary, we were supposed to use the ret2_dlresolve technique. This, of course, would be the only technique giving us a 100% chance of success, no matter which version of the GNU/Libc6 the remote server was running, and without the need of leaking some data from the program (which, in turn, was not an obvious thing to accomplish).

So I read about this technique and wrote the exploit. But then I sort of though: what if I can leak some data anyways? Wouldn’t that be a good way of improving my knowledge about powning, even if my new exploit was obscenely a lot less reliable? I mean, why not?

Write-what-where

Let’s suppose that the binary we were provided with was running on the very same system where it was originally built. I started by reading the data put into the .comment section of the binary by gcc itself:

readelf -p .comment ch77

String dump of section ‘.comment’:
[ 0] GCC: (Ubuntu 4.8.5-4ubuntu8) 4.8.5

A quick search on Google gave me that that version of the compiler shipped with Ubuntu Xenial Xerus (16.04), and the GNU/Libc6 version for this GNU/Distro was 2.23. So I downloaded this version of the library by using the libc database. Well, at this point I knew that in order to leak data, I needed to replace the call to read with a call to, maybe, write. But of course you cannot do that by just overwriting the .got.plt entry for read() with the OFFSET of the write() function. Then I though: but of course you can, as long as that offset fits within one byte! So I checked both OFFSETS:

[*] Libc6 read offset: 0xd4350
[*] Libc6 write offset: 0xd43c0

These OFFSETS where obtained from the  libc6-i386 package on Xenial Xerus; when it came to the libc6:i386 package (after adding the i386 architecture with dpkg –add-architecture i386), I got the following offsets:

[*] Libc6 read offset: 0xd5b00
[*] Libc6 write offset: 0xd5b70

I tried this on a Debian Jessie LTS with libc6 version 2.19 as well and I got the following OFFSETS:

[*] Libc6 read offset: 0xc8810
[*] Libc6 write offset: 0xc8880

So yes; by overwriting the LSB of the .got.plt entry for read() with the right byte offset of write(), I would have a call to write() any time I forced the binary to execute call read@plt. Thanks to the first read call, I had a write-what-where primitive, so overwriting the LSB was a piece of cake:

ROP to overwrite LSB of .got.plt for read() with the LSB of write’s OFFSET.

After that, the leak was easy too: because now read() was in fact a call to write(), all I had to do was to call read@plt yet again, forging write() arguments so that I could get write’s libc6 address leaked into stdout:

ROP to leak to stdout the address of write.

By running the exploit I had so far, my leak was working fine and I could get all the interesting addresses (system(), the string “/bin/sh”) and so on. But I had a new problem now: because the call to read had been overwritten with write(), now I had no way of inputting more data to the program!

Leaking write’s address right off the binary’s memory.

Restoring read()

The total number of bytes read by the call to read() was 0x64. I needed 20 bytes of padding in order to reach EBP and then RET. Which means I was left with a total of 0x64 -0x18 = 0x4c bytes (76 in decimal) for my ROP chain. Because I had already wasted 0x28 bytes to overwrite read@got.plt and then call write to leak its own address to stdout, I was in fact left with 0x24 bytes of a ROP chain. My idea was, of course, to yet again overwrite LSB of read@got.plt but this time with its original value. This would restore the read() function and so I would be able to input a classic ROP-shell attack to spawn a shell and powned the program. But, how?

I started looking for ROP gadgets within the binary. I sort of though that a good way of restoring the original byte would be to XOR it. So I looked for gadgets that would allow me to XOR something with an address of my control. I found these:

ROP gadgets to XOR a value on memory.

The second ROP gadget (skipping the first instruction, push cs), seemed like a good choice. I needed to make sure that EBP held the value read@got.plt-0xe, and CL the right byte (key) used for the XOR operation in order to restore the original LSB of read(). Which byte that should be? Easy; I computed a XOR key like this:

xor_byte_cl = libc6_read_byte ^ libc6_write_byte

I kept looking for ROP gadgets that would allow me to write the XOR key to CL, or ECX, to no avail. Putting the address read@got.plt-0xe into EBP was easy; a pop ebp; ret gadget would do it. I had that ROP, so I focused all my attention to get the XOR key into CL. After a while, I realized the following truth: if you do not have the proper ROP gadget that suits you, go abuse a legitimate call to a function that will do all the dirty job for you. Internally, the parameters passed to a libc6 function by the stack (on 32 bits) will eventually ended up into registers when making a system call (int 0x80). So for the call to write I had the following:

Before making a system call, the parameters are passed into registers. ECX will hold the buffer to output from.

The third parameter to write, i.e., the total number of bytes to write to, would be put into the EDX register. The buffer where to read data from would be put into the ECX register. The file descriptor to output data to would be put into the EBX register and finally EAX will hold the system call id, 0x4, for write. What I found out by debugging my exploit was that the value put into ECX would hold right after calling write. So by forging a new ROP chain after the leak that would make a new call to write with the second parameter set to a valid address within the program’s memory ending with the XOR key, I would have the XOR key in the CL register.

I wrote this ROP chain:

rop_cl_90 = p32(binary_read_plt)
rop_cl_90 += p32(clean_3_args_stack)
rop_cl_90 += p32(0x1) + p32(patch_address) + p32(0x4)

The offset patch_address was computed this way:

patch_address = 0x804a000 + xor_byte_cl

The only problem with this ROP chain was that I was left with a total of 12 bytes. I still needed to put read@got.plt-0xe into EBP by popping off the stack its value. That meant 8 more bytes (4 for the ROP gadget pop ebp; ret) and 4 more bytes for the actual address I wanted: 0x8049ffe. Then, I needed 4 more bytes to call the ROP gadget that would XOR the LSB of read@got.plt. So it was obvious that I could indeed restore read with those 12 left-over bytes of ROP magic, but from that point on I would not be able to call main() again to get another chance of inputting my ROP-shell! Stuck again.

Let’s clean 4 registers instead of 3

I needed to save 4 bytes and that would be all. Instead of using a ROP gadget that would pop 3 registers, I needed one that would pop 4 registers (the three write parameters already on the stack plus a new one, the address I wanted into EBP). For this to work, of course, EBP should be the last register in the ROP gadget sequence of pops. I found that ROP gadget:

0x080484b8: pop ebx; pop esi; pop edi; pop ebp; ret;

So I rewrote my original ROP like this:

The final ROP chain to restore read’s LSB.

I crafted the third parameter of write to be that of a valid memory address within the program (as a matter of fact, the .bss symbol workaround ;-)) because this value would be popped into EDI, and then the ROP gadget I was about to use to XOR the LSB of .got.plt had a second instruction that might segfault the program:

The second instruction could segfault the program if EDI+0xe was pointing to a non-valid memory address.

At the same time, this was a large value. Because it was used first as the third parameter of write, that meant write will read up to 0x804a040 (workaround address within the .bss segment) bytes of data. As long as this was not segfaulting, I did not care because I could just discard any junk coming from the program.

So the last part of my first ROP attack would be to put the desired value of EBP into the stack, call the ROP gadget that would XOR the key with the LSB of read@got.plt and then call back to main in order to get a second chance of inputting my ROP-shell:

The last part of my ROP attack to restore read().

Trying it out

Once my first ROP attack was written, I tried it out by sending my exploit to the program, waiting for the second call to read and then sending it the LSB of the write function. After that, the program should be sending me 4 bytes back, the address of libc6’s write:

exploit = “A”*padding + p32(0xdeadbeef) + rop_overwrite + rop_write + rop_cl_90 + rop_restore_read
r.send(exploit)
r.send(p8(libc6_write_byte))
leaked_write = int(r.recv(4)[::-1].encode(“hex”),16)

After this first attack, performed in a single shot, the program was back to main and thus waiting for me to input more data. This time, my ROP-shell that was finally populated with the right addresses for system() and the string “/bin/sh”:

Sending my ROP-shell and gaining a remote shell.

Once my exploit was completed, I ran it against Xenial Xerus and Debian Jessie LTS with success:

Running my exploit and gaining a shell without ret2_dlresolve.

You can get the binary and my exploit and try it out yourselves. Of course, this exploit is not reliable when you do not know the libc6 version of the remote server. Besides, it only works with libc6 versions where the offset between write and read are less than 0xff bytes. The intended way was to use ret2_dlresolve, which is 100% reliable for this case. But anyway, I think it’s been interesting to pull this off and I tried harder, I think. No doubt there may be some other ROP gadgets or some other possible combinations so that I could do the same thing with even less bytes, I’m sure. So feel free to let me know!

Desclavando espinas 2/3: UAD360 go4fun writeup

El reto

Revisamos la información básica sobre el binario:

file go4fun.uu
go4fun.uu: ELF 32-bit MSB executable, MIPS, MIPS32 version 1 (SYSV), statically linked, not stripped

Estamos ante un binario ELF (Linux), con arquitectura MIPS de 32 bits. Además, es Big Endian (MSB).

Ejecutando el binario con arm_now

Durante la UAD360, no conocía el proyecto arm_now. @julianjm me lo chivó (luego también lo haría @oreos_ES). Basta con instalar arm_now y luego levantar una instancia emulada con qemu de una máquina MIPS32 Big Endian:

arm_now start mips32 –sync

El binario nos pide una clave, luego parece estar haciendo algún tipo de comprobación y finalmente acaba sin ningún mensaje de error:

Ejecutando el binario MIPS32

Reverseando

Ejecutamos r2 y lo primero que nos sorprende es la cantidad de funciones que tiene este binario. Se ve claramente que estamos ante un binario escrito en golang, compilado para MIPS32. Leemos un poco sobre binarios Go para saber dónde comenzar nuestra búsqueda. La primera función que realmente nos interesa es sym.main.main, que a su vez llama a sym.main.check_key. Pedimos a r2 que nos muestre el callgraph desde sym.main.check_key:

El callgraph generado nos muestra claramente el flujo del programa con una llamada a Sleep (seguramente el “Please Wait….”) y una llamada a Decrypt. Luego, el binario desencripta probablemente la flag, que estará encriptada y almacenada en el propio binario, a partir de la key que se nos pide.

La function callgraph para check_key

En la función sym.main.main encontramos la flag cifrada:

La flag cifrada.

Recordemos que esto es Go, y las cadenas no terminan con \x00. Si observamos claramente la imagen, en realidad la flag cifrada está compuesta de los siguientes bytes:

7fd4000ff0ae3bdd14ff3bd70aa12ce69636366c77c1a7298ae9f5aeab69a0f4739f4f069561c44b2d6597bfc4834236f1756290ae

Si miramos el código de la función Decrypt, vemos claras llamadas a las funciones en Go crypto_aes.NewCipher y crypto_cipher.NewGCM:

[0x0010050c]> s sym.main.decrypt
[0x000ff758]> pdf
Do you want to print 747 lines? (y/N) 
[0x000ff758]> pdf~jal
| |: 0x000ff770 0c01b2cc jal sym.runtime.morestack_noctxt
| 0x000ff798 0c03fd8a jal sym.main.createHash
| 0x000ff7b8 0c016a6b jal sym.runtime.stringtoslicebyte ; sym.runtime.rawstringtmp+0xc4
| 0x000ff7d8 0c0222e1 jal sym.crypto_aes.NewCipher
| | 0x000ff800 0c01d9fa jal sym.crypto_cipher.NewGCM

Así que ya sabemos lo que hace el código a groso modo. A partir de nuestra entrada, hace algunas comprobaciones sobre la misma. Si la key introducida es válida, entonces genera un hash en MD5 de la clave (llamada a createHash) y la utiliza como clave AES en modo GCM (llamadas a sym.crypto_aes.NewCipher y sym.crypto_cipher.NewGCM) . Con esto descifra la flag y entonces se hace una llamada a show_flag que la imprime por pantalla.

Leyendo un poco sobre Go y AES en modo GCM, vemos que se suele concatenar el nonce delante del texto cifrado. Dentro del binario tenemos claramente la flag cifrada, pero el nonce no lo vemos por ninguna parte. Al final, con ayuda de r2, vemos claramente que hay una llamada a gcm.NonceSize() justo aquí:

0x000ff828 8c230010 lw v1, 0x10(at)
| || 0x000ff82c afa20004 sw v0, (var_4h)
| || 0x000ff830 0060f809 jalr v1; gcm.NonceSize()

Así que la flag cifrada tiene seguramente el nonce concatenado al principio. La siguiente llamada es la que ejecuta el descifrado:

0x000ff8c0 0080f809 jalr a0

La documentación sobre AES en modo GCM indica que el número de bytes para el nonce es de 12 (0xc en hexadecimal). Así pues, consideramos los 12 primeros bytes de la flag como el nonce, y el resto como la flag cifrada:

nonce =7fd4000ff0ae3bdd14ff3bd7

flag  = 0aa12ce69636366c77c1a7298ae9f5aeab69a0f4739f4f069561c44b2d6597bfc4834236f1756290ae

La única manera de descifrar la flag es, pues, con la key válida de AES. Por si acaso, revisando el código del binario en MIPS, nos encontramos con que la función createHash, que entendemos calcula el MD5 de la clave AES (aparentemente, la que introducimos como entrada), tiene un código que llama la atención.

La falsa clave AES

Si observamos el código con r2 de la función createHash, vemos un snippet de código que acaba generando 32 bytes hexadecimales (que podría perfectamente ser una clave AES válida):

0x67452301efcdab8998badcfe10325476

Las operaciones lui cargan inmediatos en el registro v0, y las operaciones ORI hacen el or con el mismo registro y otro inmediato, almacenando el resultado en el mismo registro v0. Tras cada operación, se guardan 4 bytes en at + offset. Tras el código mostrado en la siguiente captura, $at contendrá los 32 bytes del falso hash:

Este código genera un hash falso de 32 bytes.

Como ya sabemos que el código descifra la flag, cifrada con AES en modo GCM; tenemos la flag y el nonce, sabemos que la clave será un hash md5. Con todo esto, probamos el hash generado con las operaciones OR con un código Go típico para descifrar. El resultado no puede ser más desolador:

Nope, @oreos_ES nos ha jodido bien …

Por desgracia, no hay más remedio que enfrentarse a la lógica de la función check_key … A base de mucho café, claro.

Sobre check_key, retdec y otros quebrantos

Mis conocimientos de MIPS32 son bastante nulos, aunque han mejorado tras este reto :). Usando r2, el código de comprobación de la clave se me hizo bastante infumable, así que tiré de retdec para intentar obtener una representación a más alto nivel de la función. Tras instalar retdec, de-compilé la función check_key:

retdec-decompiler.py -a mips -e big –select-functions main.check_key go4fun.uu

El archivo de código C resultante mejoró considerablemente, aunque seguía siendo algo duro de roer. Más bien tedioso en su lectura e interpretación, aunque a simple vista ya se podía ver que se estaba usando una variable como base y diferentes constantes numéricas como OFFSET para realizar ciertas comprobaciones sobre lo que parecían ser las diferentes posiciones de nuestra clave. Por ejemplo:

Primera comprobación de las posiciones key[15] y key[4]

En el código anterior, v2 contendrá el valor entero de la posición 15 de la clave, o sea, key[15]. v6, en cambio, contendrá el valor entero de la posición 4 de la clave, es decir, key[4]. La primera comprobación es, si substituimos v6 por key[15] y v2 por key[4], la siguiente:

key[15] + key[4] != 12

Evidentemente, si esto se cumple se retorna y no se comprueban el resto de posiciones de la clave, con lo que no es válida y no se procede a descifrar la flag. Por lo tanto, nuestra clave debe satisfacer todo lo contrario:

key[15] + key[4] == 12

Si seguimos mirando el resto del código, podemos ver que esta realizando más comprobaciones con diferentes posiciones de la clave. Esto cada vez más parece un sistema de ecuaciones dónde diferentes operaciones sobre diferentes posiciones de la clave deben dar un valor concreto para seguir con la enésima comprobación. Si cualquiera de estas operaciones no da el resultado correspondiente, se retorna y no se llama a decrypt. Tras armarme de paciencia, llegué a identificar hasta 24 ecuaciones con diferentes posiciones de la clave. El archivo en código C generado con retdec y analizado y documentado lo podéis ver aquí.

Todas las ecuaciones se podían identificar con relativa facilidad gracias a retdec, aunque en algunos casos era algo más obtuso. También es cierto que he simplificado algunas, porque por ejemplo esto:

((v12 > 9 ? -1 : v12) – (v13 > 9 ? -1 : v13) ^ 8) == 0

es totalmente equivalente a esto:

((v12 > 9 ? -1 : v12) – (v13 > 9 ? -1 : v13) ) == 8

Entra z3 (gracias @julianjm)

Estas son todas las ecuaciones (24) que identifiqué leyendo el código de-compilado de la función check_key (os recomiendo mirar el archivo check_key.c):

int(key[4]) + int(key[15]) == 12
int(key[1]) * int(key[18]) == 15
int(key[15]) / int(key[9]) ^ 1 == 0 => int(key[15]) / int(key[9]) == 1
int(key[17]) – int(key[0]) ^ 8 == 0 => int(key[17]) – int(key[0]) == 8
int(key[5]) – int(key[17]) == -1
int(key[15]) – int(key[1]) == 6
int(key[10]) * int(key[1]) == 24
int(key[13]) + int(key[8]) == 11
int(key[8]) * int(key[18]) == 15
int(key[11]) * int(key[4]) == 18
int(key[8]) + int(key[9]) == 12
int(key[12]) – int(key[19]) == -4
int(key[9]) % int(key[17]) == 1
int(key[16]) * int(key[14]) == 2
int(key[7]) – int(key[4]) == 1
int(key[6]) + int(key[0]) == 0
int(key[2]) – int(key[16]) == -1
int(key[4]) – int(key[6]) == -3
int(key[0]) % int(key[5] == 0
int(key[11]) * int(key[5]) == 42
int(key[10]) % int(key[15]) ^ 8 == 0 => int(key[10]) % int(key[15]) == 8
int(key[11]) / int(key[3]) == 2
int(key[14]) – int(key[13]) == -7
int(key[19]) + int(key[18] == 12

Necesitaba algo que resolviera todas estas ecuaciones. Entonces fue cuando @julianjm me dijo que existía el z3 solver. Así que lo instalé, y escribí el solver a partir de las ecuaciones anteriores. El archivo del solver en z3 lo podéis descargar aquí. Para resolver estas ecuaciones, las introduje una a una y ejecuté el solver:

[s[8] = 3,
s[4] = 3,
s[19] = 7,
s[17] = 8,
s[16] = 2,
s[2] = 1,
s[9] = 9,
s[1] = 3,
s[3] = 3,
s[15] = 9,
s[11] = 6,
s[10] = 8,
s[12] = 3,
s[18] = 5,
s[0] = 0,
s[14] = 1,
s[7] = 4,
s[6] = 0,
s[5] = 7,
s[13] = 8]

El resultado me daba el valor numérico de cada posición de la clave. Por lo tanto, re-ordenando las soluciones se obtenía la clave:

03133704398638192857

Tras mi análisis de lo que hacía el código, era evidente que había que calcular el hash MD5 de la clave y usarla como clave de 32 byres AES de descifrado en mi programa escrito en go “decrypt.go”. El resultado:

The flag is mine!

Reto superado, eso sí, cerca de 72 horas más tarde de lo esperado …