Ir al contenido
  1. Writeups/

HackTheBox - Espresso

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

CHALLENGE DESCRIPTION

Someone leaked the new Espresso firmware, can you try to figure out what it does?

Datos iniciales:

  • firmware.bin: Archivo de firmware, de momento desconocemos más información.

Enumeración inicial
#

De momento, intentamos buscar cualquier información disponible.

Ejecutamos file pero obtenemos poca información.

1
2
$ file firmware.bin
firmware.bin: data

Buscando strings
#

Miramos strings. En este caso uso un script custom que:

  • Dice si las strings están en Little o Big Endian en función de lo que dice file. Como aquí no ha dicho nada tendremos que mirarlo manualmente.
  • Mira strings ASCII, UTF-16 y UTF-32 en la correspondiente codificación (LE/BE)

Si ejecutamos el binario strings original en Big Endian:

1
2
3
4
5
6
$ /bin/strings -e B firmware.bin     
HHHHHHH
D`HX

$ /bin/strings -e b firmware.bin
P P

No hay ningún string relevante, así que muy posiblemente el programa esté en Little Endian. Ahora ejecutamos mi script custom y encontramos, entre todo, algunas cosas interesantes (strings en LE):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Strings en ASCII
v6.1-dev-2748-g490691bc61
Feb 28 2026 03:10:31
Hl`dhTX\480<LPptx|
Assert failed in %s, %s:%d (%s)
abort() was called at PC 0x%08x
boot
E (%lu) %s: load partition table error!
 is not bootable
 # ... [SNIP]...
 ESP_OK
 ESP_ERR_NO_MEM
 ESP_ERR_INVALID_ARG
 ESP_ERR_INVALID_STATE
 ESP_ERR_INVALID_SIZE
 ESP_ERR_NOT_FOUND
 ESP_ERR_NOT_SUPPORTED
 ESP_ERR_TIMEOUT
 ESP_ERR_INVALID_RESPONSE
 # ... [SNIP]...

Si buscamos v6.1-dev-2748-g490691bc61 en Internet, encontraremos bastantes páginas acerca del ESP32. Esto concuerda bastante con todos los nombres de señales que se listan también, que comienzan con ESP_. Podemos asumir con bastante certeza que se trata de un archivo de firmware del ESP32. Además, el nombre del challenge (Espresso) coincide bastante con la empresa fabricante de los ESP32, Espressif.

Además, si buscamos algo con flag:

1
2
3
4
$ strings firmware.bin| grep -n flag
448:!((vd->flags & VECDESC_FL_SHARED) && (vd->flags & VECDESC_FL_NONSHARED))
450:E (%lu) %s: No free interrupt inputs for %s interrupt (flags 0x%X)
599:flag did not generate correctly. # <-- Interesante

Y echamos un vistazo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ strings firmware.bin| grep "flag did not" --context 5   
vfs != NULL
//IDF/components/vfs/vfs.c
strncmp(src_path, vfs->path_prefix, vfs->path_prefix_len) == 0
/dev/null
I (%lu) %s: %s
flag did not generate correctly.
It seems you are running the firmware on cloned hadware.
E (%lu) %s: %s
Buy the real hardware, or perhaps try to emulate it. ;)
Unhandled interrupt %d on cpu %d!
flash_hal

En los strings aparece: “flag did not generate correctly. It seems you are running the firmware on cloned hadware. Buy the real hardware, or perhaps try to emulate it. ;)”.

Nota: Archivo bin

Aunque se indique “flag did not generate correctly. It seems you are running the firmware on cloned hadware…”, hay que tener en cuenta que el archivo firmware.bin es código compilado. No es un log, son mensajes definidos por el programador en el código fuente. Si el código se ejecuta y detecta que es “hardware clonado”, imprimirá ese string, pero no indica que ese error “haya pasado ya” y nosotros estemos viendo el resultado.

Enum. específica ESP32
#

Antes de emular nada, vamos a buscar herramientas específicas para el ESP32 que permitan sacar más información del binario. Tras una búsqueda, encuentro esp32_image_parser.

Lo ejecutamos primero para buscar particiones.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ python3 esp32_image_parser.py show_partitions ../firmware.bin
reading partition table...
entry 0:
  label      : nvs
  offset     : 0x9000
  length     : 24576
  type       : 1 [DATA]
  sub type   : 2 [WIFI]

entry 1:
  label      : phy_init
  offset     : 0xf000
  length     : 4096
  type       : 1 [DATA]
  sub type   : 1 [RF]

entry 2:
  label      : factory
  offset     : 0x10000
  length     : 1048576
  type       : 0 [APP]
  sub type   : 0 [FACTORY]

MD5sum: 
f4ad4f4538564b5d7435b62c75b69524
Done

En teoría, cada una de las particiones sirve para lo siguiente:

  • nvs: Almacenamiento no volátil. Sirve como base de datos para info. que no debe perderse al reiniciar. Guarda configuraciones, credenciales, etc. Si hay números de serie o claves que se comprueban para determinar si el hardware es “clonado”, posiblemente estén aquí.
  • phy_init: Datos y parámetros de bajo nivel para inicializar antenas wifi y bluetooth. No relevante aquí.
  • factory: De tipo [APP], es la aplicación que arranca por defecto cuando inicia el ESP32. Contiene todo el código compilado y los datos estáticos.

Con el script descargado podemos convertir la partición factory a ELF.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ python3 esp32_image_parser.py create_elf ../firmware.bin -partition factory -output factory.elf
Dumping partition 'factory' to factory_out.bin
Traceback (most recent call last):
  File "/home/user/downloads/esp32_image_parser/esp32_image_parser.py", line 281, in <module>
    main()
    ~~~~^^
  File "/home/user/downloads/esp32_image_parser/esp32_image_parser.py", line 264, in main
    image2elf(dump_file, output_file, verbose)
    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/downloads/esp32_image_parser/esp32_image_parser.py", line 41, in image2elf
    image = LoadFirmwareImage('esp32', filename)

$ ls factory_out.bin
factory_out.bin

Al parecer el script ha fallado y además ha ignorado completamente el nombre que queríamos darle al output, pero igualmente se ha creado un archivo. Vamos a comprobar si realmente nos ha dado algo útil o al menos reconocible.

1
2
$ file factory_out.bin
factory_out.bin: ESP-IDF application image for ESP32, project name: "espresso", version 2c1ec8fd-dirty, compiled on Feb 28 2026 03:10:39, IDF version: v6.1-dev-2748-g490691bc61, entry address: 0x400814AC

Y vemos que sí.

Descompilando código
#

Ahora lo intentamos abrir con Ghidra. La aplicación lo detecta como Raw Binary y nos pide introducir el lenguaje a usar. Como el ESP32 usa Tensilica Xtensa, lo buscamos y seleccionamos. Elegimos Xtensa 32-bit little-endian.

El problema es que Ghidra de primeras asume que el código empieza en la línea 0 porque no conoce el entry point (que como ha dicho file es 0x400814AC), por lo que las instrucciones no tienen sentido para él. Como se ve en la imagen, la mayoría de instrucciones en ensamblador se reconocen como ?? o como saltos undefined.

Para solucionar esto, la idea es cargar un plugin para Ghidra que le permita entender directamente estos binarios. Si usas la versión v1.1.0 de Releases, ten en cuenta que necesitarás instalar Ghidra 11.3.1 y que no irá con las versiones más nuevas.

Una vez instalado, lo importamos. Ahora sí que detecta directamente el lenguaje como Xtensa:LE:32:default:default. Lo abrimos.

Para ver la función en la que se hacían comprobaciones y se creaba el flag, vamos a Window -> Defined Strings y buscamos “flag”. Ahí pulsamos en el string “flag did not generate correctly.”. Vemos que el string está en la dirección 0x3f4036a4.

Pulsar en el string nos lleva a la función en la que se usa: “FUN_40088e74”. Si vamos a esa función:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void FUN_40088e74(uint param_1, int param_2, undefined4 param_3, undefined4 param_4, undefined4 param_5, undefined4 param_6){
  undefined4 *puVar1;
  int iVar2;
  undefined1 auStack_40 [12];
  undefined4 uStack_34;
  undefined4 uStack_30;
  undefined4 uStack_2c;
  undefined1 auStack_20 [32];
  
  if (((param_1 & 7) != 0) &&
     (uStack_34 = param_4, uStack_30 = param_5, uStack_2c = param_6,
     iVar2 = FUN_400d27dc(param_1 & 7,param_2), iVar2 != 0)) {
    puVar1 = (undefined4 *)Ram40080d5c;
    (*(code *)*puVar1)(param_3,auStack_20,auStack_40,0xc);
  }
  return;
}

Además, si miramos referencias a la dirección del string (0x3f4036a4), encontramos otra, FUN_400d5c34:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void FUN_400d5c34(void){
  int iVar1;
  undefined4 uVar2;
  undefined4 uVar3;
  bool bVar4;
  uint uVar5;
  char local_40 [64];
  
  bVar4 = FUN_400d5c04();
  if (bVar4) {
    FUN_400d5cb0((int)local_40,0x20);
    if (local_40[0] == '\0') {
      uVar5 = FUN_40088f4c();
      iVar1 = Ram400d07c0;
      uVar3 = Ram400d07c8;
      uVar2 = Ram400d07c4;
      FUN_40088e74(3,iVar1,uVar2,uVar5,iVar1,uVar3);
    }
    else {
      uVar5 = FUN_40088f4c();
      iVar1 = Ram400d07c0;
      uVar2 = Ram400d07c4;
      FUN_40088e74(3,iVar1,uVar2,uVar5,iVar1,local_40);
    }
  }
  else {
    uVar5 = FUN_40088f4c();
    uVar2 = Ram400d07cc;
    iVar1 = Ram400d07c0;
    uVar3 = Ram400d07d0;
    FUN_40088e74(1,iVar1,uVar3,uVar5,iVar1,uVar2);
    uVar5 = FUN_40088f4c();
    uVar3 = Ram400d07d4;
    iVar1 = Ram400d07c0;
    uVar2 = Ram400d07d0;
    FUN_40088e74(1,iVar1,uVar2,uVar5,iVar1,uVar3);
  }
  return;
}

Vemos que en esta última función se llama a la anterior en múltiples ocasiones. Además, en cada llamada se le pasa un múmero diferente como primer argumento. Si el if se cumple, se le pasa 3, si no, se le pasa 1.

Respecto a los demás argumentos, se pasan punteros a memoria (RamABCDABCD;). Si vamos a esas direcciones (p.ej 400d07c0), veremos que todo se interpreta como Bad Instruction, por lo que no son instrucciones.

Si vamos específicamente a 0x400d07cc (por Ram400d07cc en el else), encontraremos lo siguiente:

1
2
                        DAT_400d07cc                     XREF[1]:     FUN_400d5c34:400d5c84(R)  
400d07cc a4             ??              A4h                   ?  ->  3f4036a4

Y aquí vemos que precisamente 0x400d07cc es un puntero hacia 0x3f4036a4, es decir, hacia el string “flag did not generate correctly.”. Posiblemente se trate de una literal pool puesta cerca de las funciones, por eso Ghidra no nos muestra los strings directamente.

De hecho, si ahora hacemos lo mismo para Ram400d07d4, veremos que nos lleva hacia 0x3f4036a4, que es el string “It seems you are running the firmware on cloned hadware.

Esto significa que si no se cumple la condición del if, no se generará el flag.

Analizando el if
#

Ahora sabemos que para conseguir generar el flag, el if debe cumplirse, por lo que podemos centrarnos en la primera rama únicamente y dejar de lado el else.

Además, sabiendo que a FUN_40088e74 se le pasan strings de debug o de error y códigos (1/3), es posible que se trate simplemente de una función de registro que muestre en pantalla el último argumento de todos (sexto).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int iVar1;
undefined4 uVar2;
undefined4 uVar3;
bool bVar4;
uint uVar5;
char local_40 [64];

bVar4 = FUN_400d5c04();
if (bVar4) {
    FUN_400d5cb0((int)local_40,0x20);
    if (local_40[0] == '\0') {
        uVar5 = FUN_40088f4c();
        iVar1 = Ram400d07c0;
        uVar3 = Ram400d07c8;
        uVar2 = Ram400d07c4;
        log(3,iVar1,uVar2,uVar5,iVar1,uVar3);
    }
    else {
        uVar5 = FUN_40088f4c();
        iVar1 = Ram400d07c0;
        uVar2 = Ram400d07c4;
        log(3,iVar1,uVar2,uVar5,iVar1,local_40);
    }
}

Tenemos que comprobar varias cosas:

  • COMPROBACIÓN DE HARDWARE: Qué comprueba FUN_400d5c04?
    • Es la función de la que depende todo el if principal (y cuyo return va hacia bVar4).
  • CREACIÓN DE FLAG: Qué hace FUN_400d5cb0 sobre local_40?
    • Este local_40 es importante porque es un string de 64 caracteres. Posiblemente el flag.

Si entendemos cómo se genera el flag, posiblemente ni necesitemos emular el hardware del ESP32 y por tanto no necesitemos saber cómo se comprueba el hardware, así que vamos a por la segunda función.

Creación del flag
#

Miramos la llamada a la función sin más.

1
2
3
4
// Llamada a la función y datos relevantes
// 0x20 = 32 en decimal.
char local_40 [64];
FUN_400d5cb0(local_40,0x20);

Y por otro lado la función por dentro.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void FUN_400d5cb0(int param_1,uint param_2){
  int iVar1;
  uint uVar2;
  
  if (0x1f < param_2) {
    for (uVar2 = 0; uVar2 < 0x1f; uVar2 = uVar2 + 1) {
      iVar1 = Ram400d07d8;
      *(byte *)(param_1 + uVar2) = *(byte *)(iVar1 + uVar2) ^ 0x42;
    }
    *(undefined1 *)(param_1 + 0x1f) = 0;
  }
  return;
}

A esto podemos cambiarle nombres:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void generar_flag(char *string,uint tamaño){
  int iVar1;
  if (31 < tamaño) {
    for (int i = 0; i < 31; i++) {
      iVar1 = Ram400d07d8;
      string[i] = *(byte *)(iVar1 + i) ^ 66;
    }
    string[31] = '\0';
  }
  return;
}

Y como el segundo argumento en la función caller es 32, siempre mayor que 31, podemos incluso quitar el if en este caso para abstraerlo del todo.

1
2
3
4
5
6
7
8
9
void generar_flag(char *string){
  int iVar1;
  for (int i = 0; i < 31; i++) {
    iVar1 = Ram400d07d8;
    string[i] = *(byte *)(iVar1 + i) ^ 66; // 66 en binario: 01000010
  }
  string[31] = '\0';
  return;
}

Además, echamos un ojo a 0x400d07d8. Ghidra nos muestra lo siguiente:

1
2
                    LAB_400d07d8                  XREF[1]:     generar_flag:400d5cbd(R)  
400d07d8 88 76      l32i.n                        a8,a6,0x1c 

Y aquí Ghidra se está equivocando de nuevo. En realidad esto sería un puntero junto con los otros 2 bytes siguientes, pero Ghidra ha cogido solo los dos primeros (0x3f40) y ha visto que coinciden con la instrucción de Xtensa l32i.n, por lo que los ha interpretado como instrucción. Para solucionar esto seleccionamos l32i.n, pulsamos C (Clear) y luego P (Pointer). Ahora hemos obtenido la dirección 0x3f407688.

Vamos a esa dirección y nos encontramos esto.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
DAT_3f407688                                    XREF[2]:     400d07d8(*), 
                                                             generar_flag:400d5cc2(R)  
3f407688 0a              undefin    0Ah
3f407689 16              ??         16h
3f40768a 00              ??         00h
3f40768b 39              ??         39h    9
3f40768c 71              ??         71h    q
3f40768d 2f              ??         2Fh    /
3f40768e 37              ??         37h    7
3f40768f 2e              ??         2Eh    .
3f407690 76              ??         76h    v
3f407691 36              ??         36h    6
3f407692 2b              ??         2Bh    +
3f407693 2c              ??         2Ch    ,
3f407694 25              ??         25h    %
3f407695 1d              ??         1Dh
3f407696 2a              ??         2Ah    *
3f407697 35              ??         35h    5
3f407698 1d              ??         1Dh
3f407699 2b              ??         2Bh    +
3f40769a 31              ??         31h    1
3f40769b 1d              ??         1Dh
3f40769c 31              ??         31h    1
3f40769d 72              ??         72h    r
3f40769e 1d              ??         1Dh
3f40769f 21              ??         21h    !
3f4076a0 72              ??         72h    r
3f4076a1 72              ??         72h    r
3f4076a2 2e              ??         2Eh    .
3f4076a3 63              ??         63h    c
3f4076a4 63              ??         63h    c
3f4076a5 63              ??         63h    c
3f4076a6 3f              ??         3Fh    ?

Tiene bastante sentido: El flag no ha sido creado todavía.

Al final, la función para crear el flag queda:

1
2
3
4
5
6
7
8
void generar_flag(char *string){
  byte *array = (byte *)0x3f407688; 

  for (int i = 0; i < 31; i++) {
    string[i] = array[i] ^ 66; 
  }
  string[31] = '\0';
}

Creando el flag
#

Ahora que tenemos todo lo necesario, podemos crear el flag nosotros mismos con un script sencillo.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 31 bytes extraídos de memoria
array = [
    0x0a, 0x16, 0x00, 0x39, 0x71, 0x2f, 0x37, 0x2e, 
    0x76, 0x36, 0x2b, 0x2c, 0x25, 0x1d, 0x2a, 0x35, 
    0x1d, 0x2b, 0x31, 0x1d, 0x31, 0x72, 0x1d, 0x21, 
    0x72, 0x72, 0x2e, 0x63, 0x63, 0x63, 0x3f
]

# clave XOR hardcodeada
clave_xor = 66

flag_descifrada = []
for byte in array:
    caracter = chr(byte ^ clave_xor)
    flag_descifrada.append(caracter)
flag = "".join(flag_descifrada)

print(flag)

Lo ejecutamos y…

1
2
$ python3 solve.py
HTB{d0_n0t_c0py_p4st3_run_1t!!}

Hemos acabado.

Relacionados

HackTheBox - Cyberpsychosis

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

HackTheBox - Gavel

·25 mins
OS: Linux | Dificultad: Medium | Conceptos: SQLi, PHP PDO, Reversing, Custom Binary