avz

Le he hecho ingeniería inversa a una bombilla

Todo esto empezó porque quería dos bombillas para mi habitación. En el pasado ya tuve las chinas de AliExpress que van con el mando, pero la verdad es que no he tenido experiencias buenas, principalmente porque las opciones de colores al final son bastante limitadas. Por ello he querido probar con unas con las que que pudiera tener más rango de control, y para esto tuve que decantarme por unas bombillas IoT.

Sé que traer IoT a mi casa es como traer el caballo de Troya. Lo sano en este mundo es no fiarse nunca de un dispositivo IoT. No tienes acceso a su sistema ni sabes si al provisionarse en tu LAN va a empezar a hacer requests de telemetría cada 30 segundos con tu SSID, tu ubicación, tu IP, el tiempo de uso, los usuarios que están en tu LAN, los cambios que hay en tu LAN, y/o el uso que le das, cómo, desde qué dispositivo, la info de tu dispositivo... Puedes estar dándole acceso gratuito a escuchar toda la LAN, a un dispositivo que te acabas de comprar, sólo por la conveniencia de cambiar los colores a través de una app. Esto no es nada descabellado teniendo en cuenta que ha salido recientemente un escándalo con televisores LG (https://www.techradar.com/televisions/lg-tvs-collect-far-more-data-on-you-than-youd-expect-says-new-report-including-logging-microphone-audio-while-on-standby). Por la gracia de conectar la TV para ver Jellyfin, tu TV ha convertido tu vida en una estadística por el ad revenue. Y porque sólo ha sido expuesta esta TV, que si nos paramos a pensar todos los dispositivos IoT que hay conectados que están mandando telemetría que no sabemos ahora, da para capítulo de Black Mirror. Es ahí donde sabemos que el consumo está por encima de la consciencia.

Entonces sabiendo esto, pues evidentemente que no me iba a arriesgar. Los únicos dispositivos IoT que voy a traer a casa ni de coña van a salir a Internet. Para ello, tenía dos opciones:

  1. Conectarlas a mi LAN, y hacer un filtro por MAC para que no hagan tráfico saliente hacia la WAN. Bastante simple de hacer desde OPNsense.
  2. Como mi intención era controlarlas desde mi PC o desde el móvil, aprovechar que tengo una interfaz Wi-Fi en mi PC sin darle uso (porque la activa es eth0), y crear un hotspot controlado donde las bombillas se puedan conectar y yo las pueda configurar.

Pues he tirado por la segunda.

Mirando por Amazon, compré las Philips Smart LED/WiZ RGB A60 E27, que parecían bastante decentes siendo primera marca. Y tal cual llegaron, no les iba a dar un buen trato.

Conseguir el provisioning de las bombillas

En cuanto llegaron, las saqué de la caja, comprobé si funcionaban, pero no me instalé la app (de momento). Estuve investigando el funcionamiento del provisioning de las bombillas en Internet, y vi que las bombillas se pueden emparejar con dos métodos:

  • "Smart Pairing": implementando el protocolo ESP-TOUCH de Espressif para emparejar las bombillas. Si no lo había entendido mal, el funcionamiento de esto es que la bombilla hace sniffing de tráfico en el aire, y nosotros tenemos que tirar al aire el POST request correcto con el SSID y la contraseña, para que la bombilla la capte y se sincronice.
  • "Manual pairing": La bombilla abre un servidor HTTP, y un hotspot con SSID "WiZConfig_0000", siendo 0000 los últimos 4 caracteres de su MAC. Nosotros nos conectamos a ese hotspot y enviamos el POST request con el SSID y la contraseña. Una vez procesado, cierra hotspot y se intenta conectar a nuestra LAN. Una vez conectada, espera a que alguien le mande el request para terminar el setup.

Fuimos con el primer método. Mediante dorks revisé proyectos en GitHub y encontré este en particular: https://github.com/deepanshutr/wiz-core. En particular porque hasta la fecha ha sido el único proyecto que he visto que no fuera un wrapper de requests para cambiar de color, sino que soporta el onboarding via ESP-TOUCH. Obviamos que está hecho con Claude, si funciona funciona.

Me hice un archivo de configuración sencillo en hostapd para activar el AP:

# /etc/hostapd/rgb.conf
interface=wlan0
driver=nl80211

ssid=RGB

country_code=ES
hw_mode=g
channel=6

ieee80211n=1
wmm_enabled=1

auth_algs=1
wpa=2
wpa_key_mgmt=WPA-PSK
rsn_pairwise=CCMP

wpa_passphrase=[redacted]

Y también necesitaría DHCP para darles una IP a las bombillas:

# /etc/dnsmasq.conf
interface=wlan0
bind-interfaces

port=0

dhcp-range=192.168.33.10,192.168.33.30,255.255.255.0,12h
dhcp-option=3,192.168.33.1
dhcp-authoritative
dhcp-leasefile=/tmp/rgb.leases

Activo el AP, ejecuto el onboarding de wiz-core, pongo la bombilla en modo "Smart Pairing" (morado parpadeando), y para sorpresa de nadie, no funciona. En principio el setup iba a ser automático.

Como mi filosofía es "si algo está hecho con IA, usa la IA para estudiarlo", le pedí un par de pistas a mi IA local, que examinó el código y me dijo que efectivamente el slop es slop y no merece la pena tener dolores de cabeza. Básicamente el código tenía hardcodeado el rango 192.168.1.0/24, y yo en /etc/hostapd/rgb.conf había puesto 192.168.33.0/24. Seguramente porque el que lo hizo con Claude hizo la implementación con su entorno sólo en 3 prompts.

Entonces puse mi rango en la función que tocaba, y nada. Resulta que al hacer broadcast, salía por 255.255.255.255 a través de mi interfaz eth0. Workarond básico fue cambiar la ruta para que saliera por wlan0 así no tocaba el código: doas ip route replace 255.255.255.255/32 dev wlan0 src 192.168.33.1.

Y al probar ahora, wiz-core serve, me da NotImplementedError. Se lo paso a mi IA local, problema de implementación de uvicorn.

A partir de ahí, decido que paso de gastar tiempo con workarounds y saco Codex (no la IA local) para que fixee esta basura de manera integral. Fast forward, el código que se supone que tendría que funcionar salió del horno. Codex comentó características del refactor que sólo prestaría atención si el provisioning funcionara.

Entonces probé: wiz-core onboard --ssid RGB (RGB siendo el SSID que puse en hostapd).

Output: Provisioning deadline expired. Vamos, que estaba todo arriba bien, pero la bombilla no venía a mi AP. A partir de ahí, tras investigarlo y ver que en el código estaba todo correcto (le dije que no cometiera errores), pasé a ver si era yo que tenía una mala configuración en la interfaz. Para el recuerdo, estoy seguro de que fue el módulo de la interfaz del Wi-Fi. Linux implementa mac80211 como módulo del kernel para el protocolo 802.11 (Wi-Fi). Si la interfaz Wi-Fi está en modo AP y no hay ningún dispositivo Wi-Fi conectado que pueda ser posible receptor de multicast, mac80211 nos va a tirar los frames de datos antes de transmitirlos por radio. Como ironía, tenemos este snippet de código en el kernel, en net/mac80211/tx.c, que lo comenta claramente:

if (ieee80211_vif_get_num_mcast_if(sdata) <= 0) {
    /* No associated STAs - no need to send multicast frames. */
    kfree_skb(skb);
    goto out;
}

La investigación empieza aquí.

Todo lo de antes es yo probando el modo "Smart Pairing" a través de un software que dudo si funcionó en algún momento, y fallando. Como hemos dicho que tenemos dos modos, vamos a probar el modo "Manual pairing". Para esto no había encontrado documentación, así que ahora toca que lo haga yo manualmente.

El modo manual nos expone una SSID al aire, "WiZConfig_xxxx", donde xxxx eran los 4 dígitos de la MAC de mi bombilla. Ahí la lógica sería bastante más simple. Para ello, en ese momento, tendría que hacer reconocimiento. Sé de antemano por haber visto proyectos como pywizlight (https://github.com/sbidy/pywizlight) que van por UDP, con application/json, y podría verlos con un tcpdump.

Entonces, en mi portátil (este tiene FreeBSD), primero revisé si el driver de mi interfaz tenía modo monitor. Esto es muy importante verlo de antemano, porque no todos los drivers pueden tener la capacidad de ser un AP, o no todos pueden tener la capacidad de ser un sniffer. La comprobación es sencilla:

$ ifconfig wlan0 list caps | grep MONITOR
drivercaps=581e201<STA,PMGT,TXPMGT,SHSLOT,SHPREAMBLE,MONITOR,WPA1,WPA2,WME>

Si vemos que sale MONITOR en drivercaps, el driver permite capturar tráfico.

Entonces, lo puse en modo monitor:

$ sysctl -n net.wlan.devices
iwn0
$ doas service netif stop wlan0
$ doas ifconfig wlan0 destroy
$ doas ifconfig wlan0 create wlandev iwn0 wlanmode monitor

Ahora está puesto en modo monitor, pero necesitamos poner la interfaz en UP escuchando un canal en concreto. Para saber eso tengo una app muy conveniente llamada Spectre en el móvil (https://github.com/thomasbuilds/Spectre), que me permite ver las frecuencias y sus datos. Con esta, pude ver que WiZConfig_xxxx estaba transmitiendo en el canal 1. Así que:

$ doas ifconfig wlan0 channel 1 up

Y ahora podíamos hacer una prueba a ver si captura tráfico:

$ doas tcpdump -ni wlan0 -y IEEE802_11_RADIO -e -vv

Efectivamente capturaba tráfico, así que tocaba hacer la captura completa:

$ doas tcpdump -ni wlan0 -y IEEE802_11_RADIO -s0 -U -w wiz.pcap

Aquí me instalé la aplicación WiZ Connected. Es una aplicación comercial, intuitiva, pero se nota que es china. No la usaría en mi día a día. Hice el setup, me conecté al AP de mi PC, e hice el manual pairing. Pero ahí me sorprendió porque no funcionaba. Me daba error desconocido al empezar el setup.

Sospeché que fuera porque uso GrapheneOS y no tuviera los servicios de Google Play y tirara la función abajo a falta de dependencias, entonces en un perfil aislado con Google lo instalé de nuevo, pero ocurrió lo mismo. También me estuve peleando con los permisos y nada. Hice prueba y error con el setup, hasta que al final caí. ¿Es posible que la app sea efectivamente spyware y sí o sí me tenga que conectar a Internet para vincular las bombillas?

Cambié a mi LAN local en el móvil, hice el setup y esta vez sí empezó correctamente. Me pidió conectarme al Wi-Fi de la bombilla, me conecté, y la app hizo el proceso de registro. Todo esto mientras tcpdump capturaba. La terminé de registrar en la app, cambié dos colores a la bombilla, el brillo, y todo iba ok. La bombilla funcionaba como se supone.

Ctrl+C a tcpdump y revisé con tshark:

$ tshark -r wiz.pcap -q -z io,phs

===================================================================
Protocol Hierarchy Statistics
Filter: 

frame                                    frames:10017 bytes:1437911
  radiotap                               frames:10017 bytes:1437911
    wlan_radio                           frames:10017 bytes:1437911
      wlan                               frames:10017 bytes:1437911
        _ws.malformed                    frames:3783 bytes:808758
        llc                              frames:676 bytes:94077
          arp                            frames:9 bytes:800
          ipv6                           frames:36 bytes:4718
            icmpv6                       frames:36 bytes:4718
          ip                             frames:631 bytes:88559
            udp                          frames:230 bytes:33908
              dhcp                       frames:12 bytes:5616
              dns                        frames:218 bytes:28292
            tcp                          frames:193 bytes:30107
              http                       frames:35 bytes:13013
                json                     frames:18 bytes:9322
            icmp                         frames:208 bytes:24544
        data                             frames:136 bytes:18308
===================================================================

Me interesan sobretodo los HTTP requests, así que tiré por ahí:

$ tshark -r wiz.pcap -Y 'http.request' -T fields \
-e tcp.stream -e ip.src -e ip.dst -e http.request.method -e http.request.uri 
1   192.168.56.100  192.168.56.1    GET /device
2   192.168.56.100  192.168.56.1    GET /device
3   192.168.56.100  192.168.56.1    GET /device
4   192.168.56.100  192.168.56.1    GET /device
5   192.168.56.100  192.168.56.1    GET /device
6   192.168.56.100  192.168.56.1    GET /device
9   192.168.56.100  192.168.56.1    GET /device
10  192.168.56.100  192.168.56.1    GET /device
11  192.168.56.100  192.168.56.1    GET /device
12  192.168.56.100  192.168.56.1    GET /device
13  192.168.56.100  192.168.56.1    GET /device
14  192.168.56.100  192.168.56.1    GET /device
16  192.168.56.100  192.168.56.1    GET /device
17  192.168.56.100  192.168.56.1    GET /device
18  192.168.56.100  192.168.56.1    GET /device
19  192.168.56.100  192.168.56.1    GET /device
22  192.168.56.101  192.168.56.1    POST    /pairing

Encontrados. Los únicos endpoints son /device y /pairing.

Descubro /device:

$ tshark -r wiz.pcap -q -z follow,tcp,ascii,1

===================================================================
Follow: tcp,ascii
Filter: tcp.stream eq 1
Node 0: 192.168.56.100:46570
Node 1: 192.168.56.1:80
100
GET /device HTTP/1.1
user-agent: Dart/3.11 (dart:io)
accept-encoding: gzip
host: 192.168.56.1


    429
HTTP/1.1 200
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range
Access-Control-Expose-Headers: Content-Length,Content-Range
Access-Control-Max-Age: 1728000
Content-Type: application/json
Content-Length: 71

{"mac":"[redacted]","name":"ESP25_SHRGB_01","fw":"1.33.0","status":0}
===================================================================

Descubro /pairing:

$ tshark -r wiz.pcap -q -z follow,tcp,ascii,22

===================================================================
Follow: tcp,ascii
Filter: tcp.stream eq 22
Node 0: 192.168.56.101:55954
Node 1: 192.168.56.1:80
170
POST /pairing HTTP/1.1
user-agent: Dart/3.11 (dart:io)
Content-Type: application/json; charset=utf-8
accept-encoding: gzip
Content-Length: 193
host: 192.168.56.1


193
{"enc_ssid":"4Jrjgmxxxxx/xxxxxxxxow==","enc_pwd":"h5Uxxxxxxxx+xxxxxxr66Q==","pwd":"[redacted]","offsets":[15,1],"iv":"xxxxxxxxxx+9QrhqeL5Q==","hid":21839492,"env":"pro","reg":"eu","ts":1791425617}
    357
HTTP/1.1 200
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range
Access-Control-Expose-Headers: Content-Length,Content-Range
Access-Control-Max-Age: 1728000
Content-Type: application/json
Content-Length: 0


===================================================================

Aquí tenemos 3 cosas importantes:

  • enc_ssid: mi SSID, cifrada antes de tŕansito, localmente desde la app
  • enc_pwd: la contraseña, también cifrada desde la app
  • pwd: segunda mitad de la contraseña, pero en texto plano sin cifrar.
  • iv: ?

Fun fact, el GET /device se envía desde 192.168.56.100 y POST /pairing desde 192.168.56.101. Eso es porque me había desconectado y reconectado con el móvil. La bombilla no guarda reservas DHCP por MAC sino que parece asignar concesiones temporales de manera cíclica.

Pero ahora se abre un melón más grande: ¿cómo que enc_ssid y enc_pwd?

Sabemos que tenemos dos endpoints ahora:

  • GET /device: identifica el dispositivo
  • POST /pairing: envía los datos de mi LAN para que se conecte

Pero no sé qué parte de la app me cifra esto. Sólo sé que es la app quien envía la petición porque el user-agent es Dart/3.11, es decir, una app hecha en Flutter.

Entonces había pensado en descargarme la apk para ver si podía extraerla y hacer un grep que matchee cypher/enc_ssid... por probar. Pero había pensado, si tengo adb, puedo extraer yo mismo el apk. Entonces:

$ adb devices
List of devices attached
48xxxxxx1E6 device

$ mkdir wiz
$ adb shell pm path com.wizconnected.wiz2
package:/data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-sF1mo0M1QkFk_lCTaJt60w==/base.apk
package:/data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-sF1mo0M1QkFk_lCTaJt60w==/split_config.arm64_v8a.apk
package:/data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-sF1mo0M1QkFk_lCTaJt60w==/split_config.es.apk
package:/data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-sF1mo0M1QkFk_lCTaJt60w==/split_config.xxhdpi.apk
$ adb pull /data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-sF1mo0M1QkFk_lCTaJt60w==/base.apk wiz/
/data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-sF1mo0M1QkFk_lCTaJt60w==/split_config.arm64_v8a.apk
$ adb pull /data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-sF1mo0M1QkFk_lCTaJt60w==/split_config.arm64_v8a.apk
/data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-s...ile pulled, 0 skipped. 39.4 MB/s (109635631 bytes in 2.656s)
$ adb pull /data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-sF1mo0M1QkFk_lCTaJt60w==/split_config.es.apk wiz/
/data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-s...onfig.es.apk: 1 file pulled, 0 skipped. 12.2 MB/s (61849 bytes in 0.005s)
$ adb pull /data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-sF1mo0M1QkFk_lCTaJt60w==/split_config.xxhdpi.apk wiz/
/data/app/~~OE2BW2SUAUdH1_g8Nry_gA==/com.wizconnected.wiz2-s...xxhdpi.apk: 1 file pulled, 0 skipped. 13.0 MB/s (131041 bytes in 0.010s)

Aviso, a partir de aquí me apoyé de la IA para que me explicara cómo hacerlo porque no tengo experiencia haciendo ingeniería inversa a aplicaciones Flutter ni auditando .bin. Por lo demás, si.

Aquí hay que buscar dónde se encuentra el código de la app. Flutter lo guarda de esta manera:

  • libflutter.so (Flutter engine).
  • libapp.so (el código).
$ cd wiz
$ for apk in ./*.apk; do
    echo "--- $apk ---"
    unzip -l "$apk" | grep -E 'libapp\.so|libflutter\.so'
done
--- ./base.apk ---
--- ./split_config.arm64_v8a.apk ---
 24642480  01-01-1981 01:01   lib/arm64-v8a/libapp.so
 11317744  01-01-1981 01:01   lib/arm64-v8a/libflutter.so
--- ./split_config.es.apk ---
--- ./split_config.xxhdpi.apk ---

La apk que nos interesa es split_config.arm64_v8a.apk. Descomprimimos:

$ mkdir arm64
$ unzip -p ./split_config.arm64_v8a.apk lib/arm64-v8a/libapp.so > arm64/libapp.so

Ahora, lo que dije antes de grep estaba bien encaminado. Ese es el siguiente paso, aunque dependiendo de cómo busque por string, mi terminal se volvía /dev/urandom, así que lo más seguro cuando se trata del codebase es ver si aquí está la presencia de los strings en un si/no. A partir de ahí indagamos más a fondo:

$ for s in enc_ssid enc_pwd offsets pairing /pairing hid env reg; do
    printf '%-12s ' "$s"
    grep -aFq "$s" arm64/libapp.so && echo YES || echo no
done 
enc_ssid     YES
enc_pwd      YES
offsets      YES
pairing      YES
/pairing     YES
hid          YES
env          YES
reg          YES

Con este for loop que habría montado yo de otra forma más simple, ya sabemos que en libapp.so estos strings aparecen mencionados. En este punto, quiero almacenarlos en índices para poder buscar sin volver a tener /dev/urandom en la terminal. strings al menos nos deja el texto legible.

$ strings -a -n 4 arm64/libapp.so > /tmp/wiz.strings
$ rg -n '_onEncryptPassword|SendEncryptedCredential|WifiCredentialCubit|enc_ssid|enc_pwd|offsets|/pairing' /tmp/wiz.strings

Entre los resultados salían estas rutas y nombres:

package:wiz_v2/bloc/pairing/wifi_credential/wifi_credential_cubit.dart
package:wiz_v2/ui/screen/pairing/ap/ap_pairing_screen.dart
WifiCredentialCubit
EncryptPassword { wifiSsid :
_onEncryptPassword@2900121702
SendEncryptedCredential { wifiSsid :

Aquí ya tenía algo bastante más útil.

Lo malo es que Flutter compila el código Dart de estas apps en AOT. Eso quiere decir que no iba a abrir el APK y encontrarme el archivo wifi_credential_cubit.dart tal cual. Los nombres ayudan, pero hay que recuperar las funciones y seguir las llamadas. Para esto se usa Blutter (https://github.com/worawit/blutter). Necesita libapp.so y libflutter.so de la misma build, así que faltaba extraer el segundo:

$ unzip -p ./split_config.arm64_v8a.apk lib/arm64-v8a/libflutter.so > arm64/libflutter.so
$ sha256sum arm64/libapp.so arm64/libflutter.so

La versión que identificó era Dart 3.11.5. La IA cocinó una serie de comandos de bootstrap para seguir investigando.

Ejecuto el bootstrap que me ha cocinado:

$ git clone --depth 1 https://github.com/worawit/blutter.git tooling/blutter
$ python3 -m venv tooling/blutter-env
$ . tooling/blutter-env/bin/activate
$ pip install requests pyelftools
$ pkg-config --exists icu-i18n capstone
$ python3 tooling/blutter/blutter.py arm64 research/blutter-out
$ rg -n '_onEncryptPassword|_onSendEncryptedCredential|apEncryptPwd|confirmLightCreation' research/blutter-out/asm/wiz_v2

Aquí me ha encontrado algo: la app no estaba cifrando todo localmente. _onEncryptPassword parte la contraseña por la mitad y manda el prefijo a /ap-encrypt-password mediante un request autenticado al cloud. Después, _onSendEncryptedCredential toma la otra mitad y construye el POST local a /pairing.

Vamos, que para pasarle la contraseña a una bombilla que está al lado, la app estaba usando un servicio remoto para hacer parte del trabajo, no sé por qué. ¿Hace el trabajo a cambio de métricas, sin decir que son métricas? Pero que la app lo haga así no implica que la bombilla necesite ese servidor. Lo que necesita la bombilla es recibir algo que pueda descifrar ella misma.

También aparecía otra función que sería importante: confirmLightCreation. Esta manda un POST /complete. En la primera captura no lo habíamos visto, porque estábamos escuchando el canal del WiZConfig_xxxx, y ese paso viene después de que la bombilla se conecte a la nueva red.

Mientras se horneaba el análisis de Flutter, también me estuvo horneando el análisis de un firmware de la bombilla. Fun fact, estas bombillas llevan un ESP32-C3, así que aquí el assembly es RISC-V, no el ARM64 de la app.

Lo que me encuentra para mi sorpresa es un certificado público Let's Encrypt Authority X3 en formato PEM dentro del firmware. Esto es muy importante porque resulta que es la clave de cifrado cuya contraseña y SSID se construye usando un trozo del texto de ese certificado.

Y tras terminar de auditar el firmware e intentar comprender cómo lo hace, os explico: para cifrar las credenciales, la app y la bombilla necesitan usar la misma clave. El firmware nos permite construirla a partir de un certificado público que lleva dentro. Aquí el certificado se usa como un texto del que sacar caracteres, curioso.

El campo offsets que vemos en el POST /pairing le está indicando a la bombilla desde dónde buscar los caracteres. Por ejemplo, [15,1] significa ir a la línea 15 y tomar 16 caracteres desde la posición 1, contando desde cero. A ese fragmento le calcula MD5, y convierte el resultado en una cadena hexadecimal de 32 caracteres. Esa cadena, usada como 32 bytes de texto, es la clave para AES-256. Con esa clave cifra el SSID y la primera parte de la contraseña.

Pero antes de cifrar la contraseña, convierte ambos textos a UTF-8 y añade un relleno llamado PKCS#7. PKCS#7 es una regla para rellenar el texto hasta completar un bloque de 16 bytes, porque AES trabaja con bloques de 16 bytes. El cifrado usa el modo CBC (Cipher Block Chaining) y un IV de 16 bytes que también se incluye en el request, para que la bombilla pueda descifrarlo.

¿Y pwd? El firmware descifra enc_pwd y hace un strcat con pwd. Es decir, concatena. Eso explica por qué había visto la segunda mitad en texto plano.

Entonces, volviendo al caso, con la clave recuperada pudimos descifrar la captura en memoria y reconstruir el input. A estas alturas ya tenía lo que quería para el POST: generar el payload sin la app, sin cuenta, y sin pedirle al cloud que me cifre nada.

Haber provisionado la bombilla es raro

Leer el estado me funcionaba. Cambiarlo, no. Un setPilot sin firmar devolvía -32602. Probamos campos que aparecían en la app, como orig=andr2 y sigTs, pero no era simplemente que faltase un parámetro de color.

Conecté el móvil por ADB otra vez y capturé el tráfico de WiZ con PCAPdroid usando su VPN local. Aquí estuve repitiendo acciones durante dos minutos. Vi cuatro setPilot firmados y sus cuatro respuestas correctas.

Intento replicar uno de esos requests, pero me devuelve -32605 Security Error. Desde la app también estuve mirando la opción de permitir todos los controles. Tiene tres políticas: permitir todo, sólo cifrado, y bloquear. Seleccionar el ajuste en el móvil no me cambiaba nada. Raro.

¿Y si la app lo estaba mandando todo a la nube, y la nube me lo devolvía?

Aquí quise ver si el tráfico entre el cloud y la bombilla era UDP legible, y puse una captura desde mi router OPNsense apuntando a la bombilla.

root@opnsense:~ # tcpdump -ni any -s0 -U -w /tmp/wiz-cloud.pcap 'host 10.0.0.186'
tcpdump: any: No such device exists

En FreeBSD no tenemos el any de Linux. Había que mirar las interfaces y seleccionar la LAN real:

root@opnsense:~ # tcpdump -D
1.igb0 (LAN (lan)) [Up, Running, Connected]
2.re0 [Up, Running, Connected]
3.vlan01 [Up, Running, Connected]
4.pppoe0 (WAN (wan)) [Up, Running]
5.wg0 (WG_HOME (opt1)) [Up, Running]
...
root@opnsense:~ # tcpdump -ni igb0 -s0 -U -w /tmp/wiz-cloud.pcap 'host 10.0.0.186'

Mando la captura a mi PC como ~/wizc.pcap.

$ tshark -r ~/wizc.pcap -Y tls.handshake.type                                                                                  
   54  68.433706   10.0.0.186 → 34.226.136.168 TLSv1.2 276 Client Hello (SNI=eu.mqtt.wiz.world)
   57  68.531942 34.226.136.168 → 10.0.0.186   TLSv1.2 1494 Server Hello
   58  68.531964 34.226.136.168 → 10.0.0.186   TLSv1.2 1494 Certificate
   59  68.531982 34.226.136.168 → 10.0.0.186   TLSv1.2 190 Server Key Exchange, Server Hello Done
   62  68.838991   10.0.0.186 → 34.226.136.168 TLSv1.2 129 Client Key Exchange

Aquí veo que la bombilla le manda mensajes a 34.226.136.168, que resuelve a eu.mqtt.wiz.world, Es una máquina AWS EC2 según resuelve el hostname.

En este punto aquí con la IA habiendo hecho el hallazgo importante de la criptografía, me decido volver al objetivo inicial. A ver, no necesitaba obtener la clave de una casa ya registrada para tener mis bombillas aisladas si al final necesitaba provisionarlas yo desde cero. Y esta idea es bastante lógica ahora que lo pienso, dado que en ese momento sentía que estaba en un punto muerto.

Reproducir el provisioning en local

La primera idea seguía siendo usar sólo wlan0: apagar mi AP, pasar a STA, conectarme a WiZConfig_xxxx, enviar las credenciales y levantar el AP de RGB inmediatamente.

El POST /pairing respondía 200, pero después no aparecía una estación nueva ni una concesión DHCP. Esperaba y nada. La hipótesis que tengo es que el AP de RGB no estaba listo cuando la bombilla empezaba a buscarlo. Aquí entonces pensé en no spawnear el AP después de mandar el POST /pairing, porque no le daba tiempo al AP. Para el momento en el que el AP ya estaba activo, la bombilla ya había dejado de intentarlo, así que hacemos otra cosa: mi PC mantendría el hostapd abierto, y el portátil se conectaría a WiZConfig_xxxx para mandar las credenciales. Es decir que se hace a la antigua.

En la consola del portátil:

$ cd ~/wizbind
$ python3.12 scripts/pairing.py --interface wlan0

Este es un script de prototipo que en resumen, ponía la interfaz en modo STA, se conectaba a WiZConfig_xxxx y ejecutaba el payload.

Y por fin respondió el POST de /pairing correctamente. WiZConfig_xxxx desapareció. En el PC principal, esta vez sí vimos asociación y DHCP, la bombilla recibió 192.168.33.14. Pero UDP aún no contestaba.

Me acordé del /complete que habíamos encontrado en la app. Consulto /device en la nueva IP, desde mi PC:

$ curl http://192.168.33.14/device

Respondía con info del dispositivo. La bombilla ya estaba conectada al Wi-Fi, pero aún en el setup. Entonces tuve que hacerlo:

$ curl -H 'Content-Type: application/json; charset=utf-8' --data '{}' http://192.168.33.14/complete

200 OK sin body, bombilla se pone en blanco. Lo hemos hecho.

Para comprobar la bombilla, y cambiar a verde:

$ printf '%s' '{"method":"getPilot","params":{}}' | nc -u -w 2 -s 192.168.33.1 192.168.33.14 38899
$ printf '%s' '{"method":"getSystemConfig","params":{}}' | nc -u -w 2 -s 192.168.33.1 192.168.33.14 38899
$ printf '%s' '{"method":"setPilot","params":{"state":true,"r":0,"g":255,"b":0,"c":0,"w":0,"dimming":60}}' | nc -u -w 2 -s 192.168.33.1 192.168.33.14 3889

La respuesta de setPilot confirmaba success:true, y físicamente estaba verde. Probé rojo y azul y luego el blanco.

Ahora sí. he registrado una bombilla sin Internet. En ese momento empecé a dar volteretas. LETS GOOOOOO

420

La primera vez el homeId que devolvía era 0. Todavía no le di importancia, ni me había fijado. Quise probar la persistencia así que la apagué, esperé diez segundos, y la encendí. Se encendió en blanco. Salvo que las bombillas tengan una condición guardada de preservar el estado antes del apagado, bootearán siempre a blanco 2700K creo recordar.

Se encendió en blanco, pero no me respondía. No había un nuevo DHCP. La STA antigua que seguía apareciendo en hostapd estaba inactiva... Pruebo a escanear la LAN por si había vuelto a la red antigua o algo:

$ doas nmap -sn 10.0.0.0/24

No la veo ni en mi LAN. Tampoco me responde en RGB. ¿Había perdido la configuración? Pero no es memoria volátil, esa configuración se guarda. Aquí teorizo sobre si hay algún parámetro que hace que no persista la configuración. Y aquí tengo una certeza muy fuerte de que creo que es porque no tengo ID de casa. Es el único tag que puedo poner yo mismo sin automatizar. El request original que se hizo en la app me dio una ID de casa legítima, tiene lógica que la bombilla se trague cualquier ID como "label". Además, sSi ha hecho /complete conmigo debería funcionar bien, no es posible que fuera un bug.

Así que probé hid=420.

Repetí el mismo método de pairing, el hostapd arriba, y el portátil probando el POST /pairing, pero con hid=420.

Al apagar y reiniciar:

printf '%s' '{"method":"getSystemConfig","params":{}}' | nc -u -w 2 -s 192.168.33.1 192.168.33.14 38899 
{"method":"getSystemConfig","env":"pro","result":{"mac":"xxxxxxxxxxxx","homeId":420,"roomId":0,"rgn":"eu"...}}

Devolvía homeId:420. Literalmente era eso. 420.

La puse verde para celebrarlo, y otra vez apagar diez segundos. Al encender estaba blanca, pero esta vez sí se me conectaba de nuevo y me respondía al getPilot. homeId seguía siendo 420. La volví a poner en verde.

Le cambié los colores de nuevo, la puse en el techo, y seguía respondiendo a mis requests.

Y así fue como conseguí hackear una bombilla. Este proyecto me duró dos días.

Epílogo

Luego vino el proyecto de desarrollo, juntamos todo el proceso que hemos investigado, de manera sencilla e intuitiva. El repo se puede comprobar aquí: https://github.com/50ck/wizbind.

La IA realmente ha ayudado mucho, sobretodo al hacer el análisis de las funciones de la app .apk y del firmware. Si no, sé que me habría estancado en sacar los datos de la app, o habría tardado más de una semana en la ingeniería inversa del .bin y de libapp.so. Tenemos una muy buena herramienta a nuestro alcance, y en este caso ha llenado la laguna de conocimiento que tenía para este proyecto. Yo no sé (aún) analizar Flutter, mucho menos el RE de un .bin. Todo lo demás son tareas que ya he hecho en otros proyectos, pero esto es otro tema.

Ahora el valor de las bombillas Philips WiZ subirán 10x por lo hackeables que son. Un saludo.