Ir al contenido
  1. Writeups/

HackTheBox - Behind the Scenes

·8 mins
Nicolás Seral
Autor
Nicolás Seral
bla bla bla bla

CHALLENGE DESCRIPTION

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.

Información inicial
#

Partimos del binario behindthescenes. Ejecutamos file y strings para ver si contiene algo interesante.

1
2
$ file behindthescenes 
behindthescenes: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=e60ae4c886619b869178148afd12d0a5428bfe18, for GNU/Linux 3.2.0, not stripped
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ strings behindthescenes
...[SNIP]...
_ITM_registerTMCloneTable
u+UH
[]A\A]A^A_
./challenge <password>
> HTB{%s}
:*3$"
GCC: (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
crtstuff.c
deregister_tm_clones
__do_global_dtors_aux

...[SNIP]...

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.

Si lo ejecutamos:

1
2
3
4
5
6
7
$ ./behindthescenes 
./challenge <password> # Devuelve status code 1

$ ./behindthescenes 12
$ ./behindthescenes 3213123
$ ./behindthescenes ABCD
$ ./behindthescenes 6647321FDF6497381AFDSA2648FDS739216487FDSA7216438767FDSA8723... (muchos caracteres)

Pasa lo mismo en todos los casos, no devuelve nada y el código de estado es 0, así que va a tocar descompilarlo para ver qué necesita.

Descompilando
#

Lo abrimos con Ghidra y vemos qué hace. Nos encontramos una función main():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void main(void) {
  code *pcVar1;
  long in_FS_OFFSET;
  sigaction local_a8;
  undefined8 local_10;
  
  local_10 = *(undefined8 *)(in_FS_OFFSET + 0x28);
  memset(&local_a8,0,0x98);
  sigemptyset(&local_a8.sa_mask);
  local_a8.__sigaction_handler.sa_handler = segill_sigaction;
  local_a8.sa_flags = 4;
  sigaction(4,&local_a8,(sigaction *)0x0);
                    /* WARNING: Does not return */
  pcVar1 = (code *)invalidInstructionException();
  (*pcVar1)();
}

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:

1
local_10 = *(undefined8 *)(in_FS_OFFSET + 0x28);

Podemos ignorarlo también.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
int main(void){
  code *pcVar1;
  sigaction local_a8;
  
  memset(&local_a8,0,0x98);
  sigemptyset(&local_a8.sa_mask);
  local_a8.__sigaction_handler.sa_handler = segill_sigaction;
  local_a8.sa_flags = 4;
  sigaction(4,&local_a8,(sigaction *)0x0);
                    /* WARNING: Does not return */
  pcVar1 = (code *)invalidInstructionException();
  (*pcVar1)();
}

Sigaction
#

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.
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

En este caso, en la llamada se hace lo siguiente:

1
2
3
4
5
6
memset(&sigact,0,0x98);
sigemptyset(&sigact.sa_mask);
sigact.__sigaction_handler.sa_handler = segill_sigaction;
sigact.sa_flags = 4;

sigaction(4,&local_a8,(sigaction *)0x0);

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:

1
void segill_sigaction(int signum, siginfo_t *info, ucontext_t *context);

Por lo que la función queda:

1
2
3
4
void segill_sigaction(int signum,siginfo_t *info,ucontext *ucontext){
  (ucontext->uc_mcontext).gregs[0x10] = (ucontext->uc_mcontext).gregs[0x10] + 2;
  return;
}

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
typedef struct ucontext_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
    struct ucontext_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
int main(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.

Parte oculta
#

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
void UndefinedFunction_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
void UndefinedFunction_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();
  }
  return 1;
}

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
void UndefinedFunction_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
void UndefinedFunction_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
void UndefinedFunction_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
void UndefinedFunction_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
void UndefinedFunction_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
void UndefinedFunction_001013e3(void){
  long unaff_RBP;
  printf("> HTB{%s}\n",*(undefined8 *)(*(long *)(unaff_RBP + -0xb0) + 8));
  check_stack_and_exit(0);
}

Así que si lo ejecutamos:

1
2
$ ./behindthescenes Itz_0nLy_UD2
> HTB{Itz_0nLy_UD2}

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í:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int main(int argc, char* argv[]){
    if (argc != 2){
        printf("./challenge <password>\n");
        return 1;
    }

    char* password = "Itz_0nLy_UD2";
    if (strncmp(argv[1], password, 12) == 0){
        printf("> HTB{%s}\n", argv[1]);
    }
    return 0;
}

Relacionados

HackTheBox - Cyberpsychosis

·10 mins
OS: Linux | Dificultad: Easy | Conceptos: Rootkit (diamorphine), Reversing, Ghidra