After struggling to secure our secret strings for a long time, we finally figured out the solution to our problem: Make decompilation harder. It should now be impossible to figure out how our programs work!
Datos iniciales:
behindthescenes: ELF 64-bit LSB pie executable, x86-64.
Aquí vemos una cadena HTB{%s} con el placeholder de string (%s) en C. No aparece nada que aparente ser lo que corresponde a ese placeholder, así que es posible que se cree en runtime.
Sabemos que main() tiene que devolver valores al SO al terminar, y que además nos devolvía códigos como 1 o 0, así que es imposible que main() devuelva void. Lo cambiamos.
Además, esta línea es una firma clásica de un stack canary:
Ahora nos fijamos en lo relativo a señales: sigaction.
La función sigaction sirve para cambiar la acción que hace un proceso cuando recibe una señal específica del sistema operativo. Los parámetros que recibe son estos:
1
2
3
4
// signum = Número de la señal a capturar (puede ser cualquier señal válida excepto SIGKILL y SIGSTOP)
// act = Estructura que contiene la información del handler a asociar a la señal. Puede ser NULL para usar el actual
// oldact = Estructura donde se almacenará la información del antiguo handler instalado para esa señal. Puede ser NULL si no se necesita.
intsigaction(int signum, conststruct sigaction *act, struct sigaction *oldact);
Se llena sigact todo con ceros (posiblemente 0x98 sea el tamaño del struct), se asigna la función segill_sigaction como handler y se usa como Signum el valor 4, que corresponde a SIGILL (Illegal Instruction).
Es decir, cuando el programa llegue a una instrucción ilegal, se pasará el control a la función segill_sigaction. Antes de nada, miramos la estructura esperada para esta función, que suele ser:
El struct ucontext, que es lo principal en esta función, según internet es algo así:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedefstructucontext_t {
// Cuando la función asociada a este contexto termina, la ejecución sigue
// en el contexto siguiente al que apunta uc_link. Si es NULL el proceso termina
// al finalizar el contexto
structucontext_t* uc_link;
// Máscara de señales bloqueadas durante el contexto
sigset_t uc_sigmask;
// Describe la pila
stack_t uc_stack;
// IMPORTANTE: Contiene el estado completo de la CPU (RIP,RSP,RAX,RBX,etc.)
mcontext_t uc_mcontext;
} ucontext_t;
La función sigill_sigaction simplemente suma 2 a uc_mcontext.gregs[0x10]. Si miramos a qué corresponde el índice 0x10 (16) en el struct gregs, veremos que es el RIP (Program Counter).
Lo que hace el programa cuando recibe una instrucción ilegal es sumar 2 al program counter. Es decir, que posiblemente todo lo que haga esta función sea saltarse la instrucción ilegal y seguir con el resto del programa.
Por eso mismo el programa terminaba bien (código de estado 0), si nos fijamos, el flujo del programa es:
1
2
3
4
intmain(void){
config_señal(); // Configurar SIGILL para saltarse la instrucción ilegal
inst_ilegal(); // Producir una instrucción ilegal
}
Con esto, el programa ha conseguido que Ghidra no descompile el resto del programa, lo que posiblemente sea la parte útil que genera el flag, puesto que asume que, al llegar a una instrucción ilegal, el programa debería haber crasheado.
Para conseguir la parte restante, tenemos que ir a lo que Ghidra reconoce como invalidInstructionException(), saltar 2 bytes, e intentar descompilar lo que haya.
Si vamos hacia allá, encontramos esto:
El resto de instrucciones no han sido descompiladas, pero ahora pulsamos D (Disassemble) y ya tenemos la función:
1
2
3
4
5
6
7
8
9
10
11
voidUndefinedFunction_001012e8(void){ // FUNCIÓN PRINCIPAL PARTE OCULTA
code *pcVar1;
long unaff_RBP;
if (*(int*)(unaff_RBP +-0xa4) !=2) {
pcVar1 = (code *)invalidInstructionException(); // A
(*pcVar1)();
}
pcVar1 = (code *)invalidInstructionException(); // B
(*pcVar1)();
}
Ahora hacemos lo mismo con A y B. Si vamos a A:
1
2
3
4
5
6
7
voidUndefinedFunction_001012f3(void){ // A
code *pcVar1;
puts("./challenge <password>");
pcVar1 = (code *)invalidInstructionException(); // C
(*pcVar1)();
}
Y ahora entramos a C, aunque podemos imaginar que va a salir del programa.
1
2
3
4
5
6
7
8
9
undefined8 UndefinedFunction_00101301(void){ // C
long unaff_RBP;
long in_FS_OFFSET;
if (*(long*)(unaff_RBP +-8) !=*(long*)(in_FS_OFFSET +0x28)) {
__stack_chk_fail();
}
return1;
}
Entonces tenemos que C comprueba el stack canary e inmediatamente sale (return 1), lo podemos llamar check_stack_and_exit(1). Ahora volvemos a la función superior, A:
1
2
3
4
voidUndefinedFunction_001012f3(void){ // A
puts("./challenge <password>");
check_stack_and_exit(1);
}
Y de nuevo subimos a la función principal:
1
2
3
4
5
6
7
8
9
10
11
voidUndefinedFunction_001012e8(void){ // FUNCIÓN PRINCIPAL PARTE OCULTA
code *pcVar1;
long unaff_RBP;
if (*(int*)(unaff_RBP +-0xa4) !=2) {
puts("./challenge <password>");
check_stack_and_exit(1);
}
pcVar1 = (code *)invalidInstructionException(); // B
(*pcVar1)();
}
Sabemos que argc tiene que ser 2 (./challenge <password>), así que unaff_RBP + -0xa4 es argc. Ahora vamos a la instrucción ilegal B.
A partir de aquí, como Ghidra descompone el código principal en funciones separadas debido a las instrucciones indefinidas en medio, vamos a tener que ir saltando de una función a la siguiente, pero en realidad todo esto iría seguido en el programa principal.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
voidUndefinedFunction_0010130d(void) // B
{
code *pcVar1;
size_t sVar2;
long unaff_RBP;
sVar2 =strlen(*(char**)(*(long*)(unaff_RBP +-0xb0) +8));
if (sVar2 ==0xc) {
/* WARNING: Does not return */ pcVar1 = (code *)invalidInstructionException(); // D
(*pcVar1)();
}
/* WARNING: Does not return */ pcVar1 = (code *)invalidInstructionException(); // E
(*pcVar1)();
}
Para no alargar esto:
E comprueba el stack canary y sale con código 0. (check_stack_and_exit(0))
D contiene otro if:
1
2
3
4
5
6
7
8
9
10
11
12
voidUndefinedFunction_0010132f(void){ // D
code *pcVar1;
int iVar2;
long unaff_RBP;
iVar2 =strncmp(*(char**)(*(long*)(unaff_RBP +-0xb0) +8),"Itz",3);
if (iVar2 ==0) {
pcVar1 = (code *)invalidInstructionException(); // F
(*pcVar1)();
}
check_stack_and_exit(0);
}
Entramos a F:
1
2
3
4
5
6
7
8
9
10
11
12
13
voidUndefinedFunction_0010135b(void)
{
code *pcVar1;
int iVar2;
long unaff_RBP;
iVar2 =strncmp((char*)(*(long*)(*(long*)(unaff_RBP +-0xb0) +8) +3),"_0n",3);
if (iVar2 ==0) {
pcVar1 = (code *)invalidInstructionException();
(*pcVar1)();
}
check_stack_and_exit(0);
}
Y aquí ya empezamos a ver un patrón. En D se comprobaba que el inicio de argv[1] fuese Itz, luego se comprueba que lo siguiente fuese _0n. Si vamos entrando a G,H,I… llegaremos a Itz_0nLy_UD2.
Al finalizar las comprobaciones, si la contraseña es correcta, se hace esto:
1
2
3
4
5
voidUndefinedFunction_001013e3(void){
long unaff_RBP;
printf("> HTB{%s}\n",*(undefined8 *)(*(long*)(unaff_RBP +-0xb0) +8));
check_stack_and_exit(0);
}
Y ya tenemos el flag, que hace una mención clara a que este reto está hecho simplemente a base de instrucciones indefinidas, o como las denomina Ghidra: ud2.
Además, por curiosidad, el programa, unido con el main(), sin ofuscar, e ignorando algunas cosas, quedaría así: