1 puntos por GN⁺ 2025-04-16 | Aún no hay comentarios. | Compartir por WhatsApp
  • Para controlar directamente desde Home Assistant un purificador de aire basado en ESP32, atado a la app del fabricante y a la nube, se hizo ingeniería inversa de la ruta de control remoto y se la reemplazó por un servidor local
  • Mediante análisis de la app, desvío de DNS y capturas con Wireshark, se confirmó que el dispositivo enviaba paquetes UDP a smartdeviceep.---.com:41014 y usaba un protocolo propio en lugar de DTLS estándar
  • A través de una conexión UART y un volcado de la flash de 4 MB, se obtuvieron dev_key.key, certificados, configuración del servidor y configuración WiFi, y se analizó la estructura del firmware con Ghidra y esp32knife
  • Los paquetes combinaban una cabecera de 13 bytes y un CRC-16 en los últimos 2 bytes, generación de claves con ECDH/HKDF, AES-128-CBC y serialización con MessagePack; al parchear el firmware para imprimir el secreto compartido en el log serial, se logró descifrarlos
  • La configuración final quedó compuesta por un proxy MITM, un servidor local y un puente MQTT basado en Mosquitto, con lo que se controló de forma estable durante varias semanas la energía y la velocidad del ventilador mediante MQTT Fan de Home Assistant

Convertir un purificador de aire dependiente de la nube en uno con control local

  • El objetivo era controlar desde Home Assistant un purificador de aire que solo se conectaba a la app móvil del fabricante y a una cuenta en la nube
  • Al alternar Bluetooth, WiFi y 5G en el teléfono, se comprobó que la app controlaba el dispositivo únicamente a través de una conexión a internet, no por Bluetooth o WiFi local
  • Como entre el dispositivo y el servidor en la nube circulaban valores de control como la velocidad del ventilador, el tramo de red se volvió el punto clave de ataque
    • Si se intercepta el tráfico y se cambian los valores, es posible controlar el dispositivo
    • Si se emulan las respuestas del servidor, puede hacerse funcionar sin internet ni la nube del fabricante
  • El contenido de ingeniería inversa es con fines educativos, y la información sensible específica del producto, como claves privadas, dominios y endpoints de API, fue ofuscada o eliminada
  • Modificar el dispositivo puede anular la garantía o dañarlo de forma permanente

Análisis de la app y captura de tráfico UDP

  • Se extrajo el .apk de Android y se abrió classes.dex con dex2jar y jd-gui para inspeccionar su interior
  • En MainActivity.class se confirmó que la app estaba basada en React Native, y en assets/index.android.bundle se encontró una conexión WebSocket segura
    • El código de ejemplo incluía una conexión a wss://smartdeviceapi.---.com
  • Con la función de consulta de DNS de Pi-hole se identificó el dominio del servidor en la nube al que se conectaba el dispositivo
  • Usando la función Local DNS de Pi-hole, ese dominio se redirigió a la estación de trabajo local 192.168.0.10, y en Wireshark se filtró el tráfico de la IP del dispositivo 192.168.0.61
  • El dispositivo estaba enviando paquetes UDP al puerto 41014 de la estación de trabajo

Configuración del relay y pistas sobre el protocolo propietario

  • Como el DNS local hacía que el dominio de la nube resolviera hacia la estación de trabajo, la IP real del servidor se consultó con el resolver DNS de Cloudflare 1.1.1.1
  • Con node-udp-forwarder, la estación de trabajo pasó a actuar como relay UDP entre el dispositivo y el servidor en la nube
  • Se capturó el primer paquete al arrancar y la respuesta del servidor, pero al verse como bytes aleatorios sin cadenas legibles, se consideró que podían estar cifrados
  • Wireshark no reconocía los paquetes como DTLS, y el formato de cabecera de la especificación DTLS tampoco coincidía con los paquetes capturados
  • Como no parecía ser un protocolo estándar, hubo que hacer ingeniería inversa manualmente de la estructura de los paquetes y del método de cifrado

Desmontaje del ESP32 y acceso serial

  • Al desmontar el dispositivo, quedaron a la vista la PCB principal, el puerto de conexión del ventilador y el cable plano del panel de control frontal
  • El controlador principal estaba marcado como ESP32-WROOM-32D, un microcontrolador de la familia ESP32 con funciones de WiFi y Bluetooth
  • Se consultó el repositorio ESP32-reversing como referencia sobre ingeniería inversa de ESP32
  • En la hoja de datos del ESP32 se identificaron los pines TXD0 y RXD0, y siguiendo las pistas conectadas a los agujeros de depuración de la PCB se encontró el punto de conexión serial
  • Se configuró una conexión UART con el USB-UART Bridge de Flipper Zero
    • El TX de Flipper Zero se conectó al RX del ESP32
    • El RX de Flipper Zero se conectó al TX del ESP32
    • GND se conectó a GND
  • Al conectarse en Putty con COM7 y velocidad 115200, apareció el log de arranque

Archivos y configuración del servidor revelados en el log de arranque

  • El log serial mostraba que el ESP32 era un chip con 2 núcleos de CPU, WiFi/BT/BLE y 4 MB de flash externa
  • La aplicación se estaba ejecutando desde la partición factory
  • Se montó el sistema de archivos FAT y se mostraron 122 KiB de espacio total y 0 KiB de espacio disponible
  • La aplicación estaba leyendo los siguientes archivos
    • serial
    • dev_key.key
    • SmartDevice-root-ca.crt
    • SmartDevice-signer-ca.crt
    • server_config
  • La configuración del servidor incluía smartdeviceep.---.com:41014

Volcado de la flash y estructura de particiones

  • Para arrancar el ESP32 en modo Download Boot, se encendió con el pin IO0 conectado a GND
  • Se volcó la flash completa de 4 MB usando esptool
    • El comando fue esptool -p COM7 -b 115200 read_flash 0 0x400000 flash.bin
  • El volcado se realizó varias veces para confirmar una lectura correcta, y además se guardó como respaldo para poder reflashear si surgía algún problema
  • Se analizó el volcado con esp32knife y se obtuvo partitions.csv
  • La estructura de particiones incluía los siguientes elementos
    • nvs: almacén clave-valor de 16K
    • otadata: datos OTA de 8K
    • phy_init: datos PHY de 4K
    • factory: partición de aplicación de 768K
    • ota_0, ota_1: particiones de aplicación OTA de 768K cada una
    • storage: partición de datos FAT de 1M
  • Según el aporte de un lector, este volcado de flash podría haber estado protegido si el cifrado de flash hubiera estado habilitado, pero en este dispositivo no lo estaba

Claves y certificados encontrados en el almacenamiento

  • En el estado más reciente de la partición nvs estaban el SSID y la contraseña de WiFi, y en el historial también se veían credenciales de WiFi usadas anteriormente
  • La partición FAT storage se revisó montándola como disco virtual con OSFMount
  • En el almacenamiento estaban los siguientes archivos
    • dev_info
    • dev_key.key
    • serial
    • server_config
    • SmartDevice-root-ca.crt
    • SmartDevice-signer-ca.crt
    • wifi_config
  • dev_key.key comenzaba con -----BEGIN EC PRIVATE KEY-----, es decir, una clave privada de curva elíptica, y se verificó con openssl ec -in dev_key.key -text -noout
  • Los dos archivos .crt comenzaban con -----BEGIN CERTIFICATE-----, es decir, certificados, y se verificaron con openssl x509
  • Como los certificados y la clave del dispositivo estaban almacenados en el propio dispositivo, era muy probable que se usaran para cifrar los datos de los paquetes UDP

Configuración del entorno de análisis en Ghidra

  • Se abrió la imagen de la partición factory en ejecución en el CodeBrowser de Ghidra para analizarla.
  • Como el ESP32 usa el conjunto de instrucciones Xtensa, se seleccionó el lenguaje Tensilica Xtensa 32-bit little-endian.
  • Como la imagen de partición en bruto no reflejaba correctamente el mapeo de memoria virtual, se generó part.3.factory.elf con esp32knife y se volvió a importar.
  • También se publicó un commit que modifica esp32knife para dar soporte al segmento RTC_DATA.
  • Con SVD-Loader-Ghidra se cargaron la estructura de periféricos y el mapa de memoria del ESP32.
  • Con SymbolImportScript de Ghidra se importaron etiquetas de funciones ROM del ESP32 para facilitar la identificación de funciones ROM comunes como printf.

Pistas de cifrado encontradas por cadenas

  • En Defined Strings de Ghidra se rastrearon las cadenas vistas en los logs seriales y las cadenas cercanas.
  • Entre las cadenas cercanas estaban las siguientes pistas.
    • Message CRC error
    • Seed Error
    • PRNG fail
    • ECDH setup failed
    • mbedtls_ecdh_gen_public failed
    • mbedtls_ecdh_compute_shared failed
    • MBED HKDF failed
    • Write ECC conn packet
  • mbedtls es una librería de código abierto que implementa primitivas criptográficas, manejo de certificados X509 y SSL/TLS y DTLS.
  • Como se usan directamente funciones ECDH y HKDF, y no DTLS, se concluyó que el intercambio y derivación de claves estaban implementados dentro de un protocolo propio.
  • La cadena ECC conn packet mostraba que el paquete de conexión inicial estaba relacionado con el proceso de intercambio de claves ECDH.

Parche de firmware para eliminar la dependencia del panel de control

  • Como era incómodo analizar la PCB conectada al ventilador y al panel de control, se separó el panel, pero durante el arranque se producía un pánico junto con el log No Cap device found!.
  • Como la función cercana a la cadena No Cap device found! imprimía CapSense Init, se interpretó que correspondía a la lógica de inicialización de entrada capacitiva del panel frontal.
  • En Ghidra, a esa función se le dio el nombre InitCapSense, y al servicio que la llamaba, StartCapSenseService.
  • Se cambió a nop la instrucción de llamada a StartCapSenseService para eliminar el arranque del servicio del panel de control.
  • Se modificaron bytes en la imagen en bruto part.3.factory y se volvió a flashear en el offset 0x10000, pero no arrancó por un error de checksum de imagen del ESP32.
  • Se añadió un script para corregir el checksum de la partición de aplicación, basado en la lógica interna de esptool.
  • Tras flashear la imagen con el checksum reparado, el dispositivo funcionó normalmente incluso sin panel de control, y la modificación del firmware fue exitosa.

Estructura del encabezado de paquete y del CRC

  • Al comparar paquetes tras varios reinicios, se vio que los primeros 13 bytes eran similares y que el resto parecía cifrado.
  • El formato del encabezado del paquete era el siguiente.
    • 55: byte mágico para identificar el protocolo
    • 00 31: longitud del paquete
    • 02: identificador de mensaje
    • 01 23 45 67 89 AB CD EF FF: serial del dispositivo de 9 bytes
  • El patrón de IDs de mensaje era el siguiente.
    • 0x02: primer paquete enviado por el dispositivo inteligente
    • 0x82: primera respuesta enviada por el servidor en la nube
    • 0x01: paquetes posteriores enviados por el dispositivo inteligente
    • 0x81: respuestas posteriores enviadas por el servidor
  • El bit más alto distinguía entre solicitudes del cliente y respuestas del servidor, y los bits bajos distinguían entre el intercambio inicial y los paquetes posteriores.
  • Siguiendo la función que referenciaba la cadena Message CRC error, se confirmó la lógica de verificación CRC.
  • Los últimos 2 bytes eran un checksum CRC-16 sobre todo el resto del paquete.
    • El polinomio era 0x1021.
    • El valor inicial era 0xFFFF.
    • Se verificó del mismo modo en varios paquetes capturados.

Flujo de generación de claves ECDH/HKDF

  • En el paquete que parecía corresponder al intercambio de claves inicial, los datos excluyendo los 13 bytes del encabezado y los 2 bytes del CRC eran de 32 bytes, lo que coincidía con el tamaño de una clave pública de 256 bits.
  • La solicitud del cliente llevaba delante 00 01, y como ese valor no cambiaba entre reinicios, se trató como un descriptor de datos.
  • En Ghidra se encontró la función de generación de claves siguiendo las cadenas de error, y se resumió a nivel de pseudocódigo comparándola con el código fuente de mbedtls.
  • La función de generación de claves realizaba las siguientes operaciones.
    • Generaba un par de claves ECDH con mbedtls_ecdh_gen_public.
    • La clave generada parecía sobrescribirse en memoria con otra clave.
    • Cargaba otra clave pública.
    • Calculaba el secreto compartido con mbedtls_ecdh_compute_shared.
    • Generaba un valor aleatorio de 32 bytes con mbedtls_ctr_drbg_random.
    • Derivaba la clave final con mbedtls_hkdf.
  • La configuración de HKDF era la siguiente.
    • Hash: SHA-256
    • salt: secreto compartido ECDH
    • input: valor aleatorio de 32 bytes generado por el dispositivo
    • info: serial del dispositivo de 9 bytes
    • Tamaño de la clave de salida: 0x10, es decir, 16 bytes
  • La función llamadora enviaba 0x22 bytes al adjuntar el valor aleatorio de 32 bytes después de 00 01, lo que coincidía con el formato del paquete inicial de intercambio de claves capturado.

Impresión del secreto compartido y descifrado AES

  • Para calcular la clave final de descifrado, se necesitaba el secreto compartido ECDH.
  • En lugar de usar depuración por JTAG, se parchó el firmware para sobrescribir con una función personalizada la ubicación de la lógica CapSense ya desactivada y hacer que imprimiera por serial el secreto compartido.
  • Se insertó una llamada de función justo después de que se generara el secreto compartido en GenerateNetworkKey, y se imprimieron 32 bytes usando el puntero de clave en el registro.
  • Durante el arranque, después de Write ECC conn packet, el secreto compartido se imprimía en hexadecimal, y el valor no cambiaba incluso tras varios reinicios.
  • La clave de salida de HKDF también se confirmó con otro parche, y se pudo reproducir la misma lógica de generación de claves sobre los paquetes capturados.
  • Dentro de la función de cifrado se encontró una tabla estática que comenzaba con 63 7C 77 7B F2 6B 6F C5, y coincidía con el AES Forward S-Box de mbedtls.
  • El método final de cifrado era AES-128-CBC, y el valor aleatorio de 16 bytes dentro del paquete se usaba como IV.
  • En los paquetes descifrados se identificaron valores legibles como mirror_data_get, FAN_SPEED, BOOST, FILTER1 y FILTER2.

Implementación del proxy MITM

  • Como ya se contaba con la clave privada del dispositivo y la lógica de derivación de claves, y los datos dinámicos necesarios estaban expuestos en la red, fue posible escribir un proxy MITM sin parchear el firmware.
  • El script en Node.js creaba un socket UDP local y otro socket UDP para el servidor en la nube, y reenviaba paquetes en ambos sentidos.
  • Los paquetes recibidos del dispositivo inteligente se registraban en logs y luego se enviaban al servidor en la nube, y los paquetes recibidos del servidor en la nube se registraban en logs y luego se enviaban al dispositivo inteligente.
  • Los paquetes con messageId igual a 2 se trataban como paquetes de intercambio de claves, y con el valor aleatorio dentro de ellos se calculaba la clave AES de los paquetes posteriores.
  • Mientras se controlaba el dispositivo desde la app móvil, se acumularon logs MITM para confirmar las formas de solicitud y respuesta necesarias para implementar un servidor local.

Estructura de mensajes MessagePack

  • Los datos descifrados seguían estando en un formato de serialización binaria
  • El encabezado de datos interno parecía ser un ID y una longitud en little-endian
    • 01 00: ID de paquete
    • 64 00: ID de transacción
    • 29 00: longitud de los datos serializados
  • Parte del formato de serialización se hizo por ingeniería inversa manualmente, pero al verificarlo resultó ser MessagePack
  • Con implementaciones como msgpackr, era fácil convertir los datos binarios a formato JSON
  • Los principales mensajes identificados fueron los siguientes
    • intercambio de claves: el dispositivo envía al servidor bytes aleatorios que se usarán en HKDF
    • mirror_data_get: obtiene el estado inicial del servidor durante el arranque
    • connect: envía el UUID actual del firmware, y el servidor responde con información de firmware, configuración, hora y direcciones del servidor
    • mirror_data: el servidor cambia el estado del dispositivo, o el dispositivo reporta al servidor un estado modificado
    • keep_alive: el dispositivo envía periódicamente estado como RSSI, RTT, pérdida de paquetes, cantidad de conexiones, uptime, etc.

Integración del puente MQTT y Home Assistant

  • Se usó MQTT para conectar Home Assistant con un servidor personalizado
  • En Home Assistant se configuró el addon de Mosquitto, un broker MQTT de código abierto
  • La estructura de conexión es Home AssistantMQTT BrokerCustom ServerSmart Device
  • El servidor personalizado funciona de la siguiente manera
    • Cuando el dispositivo solicita estado con mirror_data_get, responde usando el valor retained del broker MQTT o un valor predeterminado
    • Cuando Home Assistant envía un comando de cambio de estado a un tópico MQTT, el servidor personalizado lo reenvía al dispositivo
    • Si el estado del dispositivo cambia por cualquier motivo, publica y retiene el paquete mirror_data del dispositivo en el broker MQTT
  • La fuente de verdad del estado siempre es el dispositivo
    • Si la actualización de estado falla, no se muestra en el broker MQTT como si se hubiera actualizado
    • Si el estado cambia desde el panel de control físico, también se refleja en el broker MQTT
  • Se usó la integración MQTT Fan de Home Assistant para mapear el purificador de aire como un dispositivo de ventilador
  • En configuration.yaml se configuraron el tópico del estado de energía, el tópico de comandos, el tópico del estado de velocidad del ventilador, el tópico de comandos de velocidad del ventilador y el rango de velocidad 1~4
  • Se configuró el DNS local de Pi-hole para que resolviera el dominio de la nube del fabricante hacia el servidor personalizado, haciendo que el servidor local actuara como servidor del dispositivo

Evaluación de seguridad y resultados

  • El fabricante implementó un protocolo propio en lugar de un protocolo estándar como DTLS
  • No está claro si cada dispositivo tiene una clave privada única, pero en cualquier caso hay desventajas
    • Si todos los dispositivos comparten la misma clave privada del firmware, con hacer ingeniería inversa de un solo dispositivo basta para intentar un ataque MITM contra otros dispositivos
    • Si cada dispositivo tiene una clave privada única, el servidor debe conservar un mapeo entre números de serie y claves del dispositivo, y si esos datos se pierden, el servidor ya no puede responder a la comunicación de los dispositivos
  • Como el firmware incluye una clave privada estática, un atacante puede obtener la clave a partir de un solo volcado de firmware y realizar un ataque MITM
  • La implementación no es completamente mala desde el punto de vista de la seguridad, y el ataque sigue requiriendo acceso físico
  • La implementación propia volvió opacas las comunicaciones de red, pero Security through obscurity apenas sirve para frenar temporalmente ataques generales dirigidos a implementaciones estándar, y para un atacante es un obstáculo superable
  • Se logró el objetivo final de integrar con Home Assistant, y el purificador de aire funcionó sin problemas durante varias semanas
  • También se configuró una automatización para activar el modo boost del purificador de aire por un tiempo determinado cuando un monitor de aire independiente detecta que los niveles de PM2.5 o VOC suben demasiado

Aún no hay comentarios.

Aún no hay comentarios.