Incident de sécurité ? Suspicion de compromission ? 09 71 18 27 69csirt@synacktiv.com

Make it Blink : Compromission à distance du bridge Philips Hue

Rédigé par Mehdi Talbi, Matthieu Breuil - 06/05/2026 - dans Exploit - Téléchargement

L'édition de fin d'année de Pwn2Own s'est tenue à Cork, en Irlande. Pour la première fois, cette édition a mis en jeu des équipements domotiques, tels que l’Amazon Smart Plug, le Home Assistant Green et le Philips Hue Bridge. Le scénario envisagé par le ZDI consistait en un attaquant disposant d’un accès aux services en écoute depuis le réseau local, ou menant une attaque depuis un réseau de proximité (Wi-Fi, Bluetooth, Zigbee). Cet article détaille les travaux réalisés sur le Philips Hue Bridge pour parvenir à l’exécution de code depuis le réseau Zigbee.

Vous souhaitez améliorer vos compétences ? Découvrez nos sessions de formation ! En savoir plus

Philips Hue Bridge

Présentation

Le Philips Hue Bridge se décline en deux versions : une version standard (boîtier blanc) et une version Pro (boîtier noir), cette dernière ayant été commercialisée récemment, en 2025. Dans le cadre de la compétition Pwn2Own, seule la version standard a été mise en jeu.

Philips Hue Bridge

Le Philips Hue Bridge permet de piloter les luminaires et de créer des ambiances variées via l’application mobile Hue. La communication entre le bridge et les ampoules s’effectue via le réseau Zigbee. Il est possible d’enrôler de nouveaux équipements en lançant un scan depuis l’application ou en utilisant le bouton central du boîtier. Une procédure s’ensuit pour collecter les informations sur les nouveaux équipements détectés et les intégrer au réseau.

Obtenir un shell

La première étape a consisté à obtenir un accès shell sur l'appareil. Heureusement, plusieurs articles de blog détaillent la marche à suivre. La méthode repose sur le court-circuitage d'une broche (pin) spécifique durant la phase de démarrage. À partir de là, il est possible de réinitialiser les clés et d'activer le service SSH.

Architecture

Une fois qu’un accès SSH a été obtenu sur le bridge, il est possible d’examiner son architecture interne. Le bridge Philips Hue repose sur une architecture MIPS tournant sous Linux. L’ensemble des fonctionnalités principales est regroupé dans un unique binaire volumineux (> 9 Mo), ipbridge, qui contient environ 40000 fonctions. Plusieurs instances de ce binaire sont lancés afin de gérér différents services (Apple HomeKit, Matter, etc.).

Surface d'attaque

La surface d’attaque comprend à la fois les services accessibles depuis le réseau local et les interfaces de proximité, telles que Bluetooth et Zigbee.

Plusieurs services sont en écoute, parmi lesquels hk_hap, qui tourne sur le port TCP 8080. Ce service gère l’interaction entre le bridge Philips Hue et Apple HomeKit. Lors de la compétition Pwn2Own, toutes les équipes ont exploité des vulnérabilités dans ce service, le plus souvent par un contournement de l’authentification suivi d’une corruption mémoire.

D’autres services, comme Matter (Protocole domotique standardisé visant l’interopérabilité entre différents fabricants), mDNS ou bien UPnP, sont également accessibles mais n’ont pas été explorés dans le cadre de cette recherche.

Pour éviter d’éventuelles collisions de bugs durant la compétition, nous avons préféré explorer la surface RF. L'article Don’t be silly – it’s only a lightbulb par Check Point Research présente quelques vulnérabilités dans le traitement des trames Zigbee et constitue un bon point d'entrée pour se familiariser avec cette surface d'attaque.

Zigbee

La section suivante présente brièvement la stack Zigbee, avant de détailler le traitement des trames Zigbee par le Philips Hue Bridge.

Stack Zigbee

La couche protocolaire Zigbee est résumée par la figure suivante. Au niveau de la couche applicative, on distingue deux protocoles : le ZDP (Zigbee Device Profile) et le ZCL (Zigbee Cluster Library). Le premier est utilisé pour la gestion du réseau et la découverte des nœuds, tandis que le second définit des actions standards telles que l'allumage d'une lumière ou la lecture de la valeur d'un capteur.

Stack Zigbee

Deux clés de chiffrement sont utilisées dans le réseau Zigbee. La première est appelée Link Key, et sa valeur par défaut est "ZigBeeAlliance09". Elle sert à protéger l’échange de la seconde clé, la Network Key, qui est distribuée lors de la phase d’enrôlement. Cette seconde clé est ensuite utilisée pour chiffrer les données circulant sur le réseau Zigbee. Ainsi, un attaquant situé à proximité du réseau pendant la phase d’enrôlement pourrait potentiellement récupérer cette clé.

Il est à noter que la spécification Zigbee 3.0 renforce la sécurité : désormais, chaque appareil supportant le nouveau standard dispose d’un secret unique appelé install code, à partir duquel est dérivée une clé utilisée pour sécuriser la distribution de la Network Key.

Traitement des trames Zigbee

Les trames Zigbee sont d’abord interceptées par le contrôleur Atmel, qui les convertit depuis un format binaire vers un format textuel avant de les transmettre au binaire ipbridge via un périphérique série exposé sur /dev/ttyZigbee.

Les données sont ensuite traitées dans le thread nommé smartlink, responsable d’identifier le handler approprié et de l’exécuter.

Les messages transmis par le contrôleur Zigbee suivent le format suivant :

Group,Command,Data_1,Data_2,...,Data_N

Le caractère "," est utilisée comme séparateur.  Un exemple de message est donné ci-après et correspond à la réception d'une trame ZDP SendMgmtPermitJoiningReq :

Zdp,SendMgmtPermitJoiningReq,B=0xFFFC.0,40,0

Le premier token permet d’identifier le groupe. Dans le binaire, on distingue douze groupes : Bridge, Link, TH, Connection, Network, Zdp, Zcl, Zgp, Groups, Log, Stream et TrustCenter. Pour chacun de ces groupes, un ensemble de routines leur est associé.

Il est à noter que tous les messages ne proviennent pas du réseau : certains représentent des commandes émises par le contrôleur lui-même. C’est le cas des messages des groupes Bridge, Link, TH et Log.

La machine à états Zigbee

Le binaire ipbridge implémente plusieurs machines à états pour la découverte de nouveaux nœuds, leur enrôlement, leur configuration, etc.

Chaque machine à états est référencée dans le binaire par le sigle FSM, probablement pour Finite State Machine. Elle est définie par un ensemble d’états, de transitions et d’événements, permettant de passer d’un état à un autre.

L’état initial de la machine à états est créé dans la fonction généralement appelée fsm_init_state, dont le prototype est donné ci-dessous :

Un état est décrit par la structure suivante :

Une transition quant à elle, est définie par la structure suivante :

La transition d’un état à un autre est effectuée dans la fonction que nous nommerons fsm_do_transition. Cette fonction prend en entrée la structure FSM, un événement, ainsi que les données spécifiques à cet événement :

La fonction itère sur le tableau des transitions et exécute la fonction check associée à l’état courant et à l’événement en cours. Cette fonction permet de déterminer si la transition est possible. Dans le cas contraire, la fonction check de la transition suivante est exécutée.

Une fois qu’une transition valide est identifiée :

  1. La fonction exit de l’état courant est appelée.

  2. La fonction action associée à la transition est exécutée.

  3. Enfin, la fonction enter du nouvel état est exécutée.

Une vue simplifiée du code est donnée ci-après :

type = fsm->state->type;

// exit current state
if ((type - 1) >= 2 && fsm->state->exit) {
    fsm->state->exit)();
}

// set next state
fsm->state = transition->next_state;

// call action function
if (transition->action)
    transition->action(args);
}

// call entry function of next state
if ((type - 1) >= 2) {
    fsm->state->entry_function)();
}

Afin de simplifier la lecture des machines à états, un script IDA Python a été implémenté pour générer une visualisation de la machine à états. À titre d’exemple, la figure suivante illustre la machine à états "Download Blob", responsable du téléchargement des informations sur le modèle du nœud Zigbee identifié lors de la phase de découverte de nouveaux équipements sur le réseau. Les informations sont téléchargées bloc par bloc.

State Machine

L’état initial est représenté en jaune. Les états grisés sont dits transitionnels (c’est-à-dire state->type == 2) : ils déclenchent automatiquement une transition vers un autre état. Lorsqu’il existe plusieurs transitions possibles depuis un même état, celle qui est validée par la fonction check est choisie.

La vulnérabilité exploitée dans le cadre de Pwn2Own se situe au sein de cette machine à états. Nous y reviendrons dans la section suivante.

Recherche de vulnérabilités

Les routines de traitement des trames Zigbee identifiées précédemment constituent un bon point d’entrée pour la recherche de vulnérabilités. Nous nous sommes principalement intéressés aux trames spécifiques au constructeur, car elles ne sont pas normalisées et sont donc plus susceptibles de contenir des erreurs d’implémentation.

Une vulnérabilité a été identifiée dans le traitement de certaines trames ZCL spécifiques au constructeur Philips :

La fonction, que nous avons nommée zcl_handle_block_received_data, effectue quelques vérifications minimalistes, puis initie une transition d’état dans la machine à états "Download Blob" présentée précédemment, en utilisant l’événement DATABLOCK RESPONSE RECEIVED :

En inspectant la machine à états, nous identifions rapidement la routine responsable du traitement de cette trame. Une vue simplifiée de la fonction est présentée ci-dessous :

En analysant le code de la fonction, on peut déduire le format suivant de la trame :

ZCL custom frame

La fonction gère une copie fragmentée. L'état interne de la copie est gérée par une structure de contexte globale (variable ctx) qui contient entre autres un buffer alloué dynamiquement lors de la première réception du blob de données et un offset mis à jour après chaque copie de blob reçu.

La fonction procède à plusieurs vérifications pour s'assurer de la cohérence de la trame. En effet, la fonction commence par extraire le champs offset et le confronte à la valeur attendue sauvegardée dans le contexte global. Elle extrait également le champs total size et vérifie que celui-ci est bien supérieur à la valeur cumulée de l'offset et de la taille du fragment reçu mais n'excède pas une valeur maximale de 0x2800 octets. La cohérence du champs blob size est également vérifiée s'il est en adéquation avec la taille totale de la trame lue depuis le contrôleur.

Cependant, la copie du blob de données est effectuée sans vérifier qu’elle ne dépasse pas le buffer. Pourtant, la taille totale de l’allocation initiale est bien renseignée dans la structure de contexte et aurait pu être vérifiée avant la copie.

Un attaquant peut exploiter cette vulnérabilité en envoyant une première trame ZCL avec un total size réduit. Le débordement se produira lors de la réception d’une seconde trame ZCL dont le blob size est supérieur à ctx->total_size - offset.

Il n’est pas possible d’atteindre directement le code vulnérable car le chemin critique nécessite que la machine à états soit dans le bon état (par exemple, REQUEST DATA BLOCK) de la machine à états Download Blob. La transition depuis l’état IDLE de la machine à états Download Blob est initiée par une autre machine à états, Configure Devices, qui gère la procédure d’enrôlement des nouveaux nœuds Zigbee découverts sur le réseau. Dans le cas des équipements Philips (par exemple, une ampoule), le bridge demande à l’équipement de renvoyer des informations sur le modèle, ce qui déclenche la première transition de la machine à états Download Blob.

Le numéro CVE-2026-3555 a été assigné à cette vulnérabilité.

Exploitation de la vulnérabilité

La vulnérabilité CVE-2026-3555 permet de déborder sur un chunk adjacent. Notre première stratégie a été dans un premier temps de cibler un objet contigu en mémoire qui disposerait de pointeurs de fonctions. Cependant, cette technique requiert de trouver un tel objet et de disposer des primitives permettant de le spray afin qu'il soit atteignable depuis le buffer vulnérable. Cette piste a été vite abandonné lorsqu'on s'est rendu compte que l'allocateur est celui de la libc musl et que l'on peut refaire vivre les "tricks" voodoo de l'article phrack "Vudo malloc tricks" pour attaquer l'allocateur. En réalité, la libc embarquée dans le bridge est en version 1.1.24 qui intègre une version revisitée de l'allocateur dlmalloc.

Notes sur l'allocateur

Les allocations sont servies depuis des bins. Un bin est une liste doublement chaînée de chunks. Il existe 64 bins, chacun gérant les allocations d’une plage de tailles spécifique.

Un chunk est représenté par la structure suivante :

Nous allons nous concentrer ici uniquement sur la fonction free, car c’est par ce biais que nous avons mis en place notre primitive d’écriture arbitraire.

Lorsqu’un chunk est libéré, la fonction __bin_chunk est appelée, à condition que le chunk en question n’ait pas été alloué via un appel à mmap :

Avant de réinsérer le chunk dans le bin cible, celui-ci peut être fusionné avec ses voisins. Cette procédure est effectuée par la boucle suivante dans la fonction __bin_chunk :

Les fonctions alloc_next et alloc_prev sont responsables de la fusion avec le chunk suivant et le chunk précédent, respectivement. Pour procéder à la fusion, le chunk voisin est d’abord "unbin"

Si les métadonnées d’un chunk ont été altérées, la fonction unbin peut conduire à une primitive d’écriture arbitraire. En effet, la corruption des pointeurs next et prev permet à un attaquant d’écrire 4 octets (« WHAT ») à une adresse de son choix (« WHERE »).

Primitive d'écriture arbitraire

Comme décrit précédemment, en ajustant les valeurs de c->prev à WHERE - 8 et c->next à WHAT, il est possible d’écrire 4 octets arbitraires à une adresse arbitraire via l’instruction c->prev->next = c->next. Cependant, la seconde écriture de la fonction unbin (c->next->prev = c->prev) est plus problématique, car elle induit une écriture parasite à l’adresse WHAT + 12. Cette adresse doit donc être valide et accessible en écriture.

Cette technique permet d’écrire 4 octets. En créant plusieurs faux chunks libres, il est possible d’enchaîner des écritures multiples de 4 octets.

Dans le cadre de l’exploitation de la vulnérabilité décrite précédemment, il a été décidé de ne pas corrompre les pointeurs next et prev du chunk adjacent, car cela peut rapidement introduire des incohérences dans l’allocateur. Plus précisément, l’unbin de ce chunk n’aura aucun effet si ses pointeurs next et prev sont altérés. Par conséquent, le même chunk pourrait être alloué plusieurs fois, provoquant un crash prématuré du processus ipbridge.

Notre stratégie d’exploitation a consisté à corrompre plutôt la taille du chunk (csize) et à lui assigner une valeur « négative ». Ainsi, la liste des faux chunks peut être placée dans le buffer vulnérable. Ces faux chunks ne sont pas référencés par l’allocateur et n’ont donc aucune incidence sur son état interne.

Lors de la libération du chunk vulnérable, chaque faux chunk illustré dans la figure suivante permet d’écrire 4 octets arbitraires, et ce jusqu’à ce qu’un chunk réellement alloué soit trouvé :

heap overflow

Il est à noter que la corruption de la taille du chunk adjacent introduit également des incohérences dans l'allocateur qui peuvent causer dans certains cas une instabilité ou même un crash de l'application.

Exécution de code

La primitive d’écriture arbitraire a été utilisée pour écrire un shellcode minimaliste permettant d’exécuter la fonction system. La commande passée à system permet de démarrer un reverse shell et est également écrite en utilisant la même primitive. Enfin, un pointeur de fonction global a été modifié pour appeler notre shellcode. Ce pointeur est utilisé pour la lecture des trames Zigbee et la fonction sous-jacente est exécutée de manière fréquente et périodique, ce qui en fait un bon candidat pour déclencher rapidement l'exécution de notre shellcode avant que les incohérences au niveau de l’allocateur ne se propagent.

Les écritures arbitraires s’effectuent dès que le buffer vulnérable est libéré. En examinant la machine à états de plus près, il a été constaté qu’il est possible de déclencher la libération du buffer vulnérable en transmettant trois paquets malformés immédiatement après l’envoi de notre payload. Par exemple, si le champ offset est défini à une valeur inattendue par la fonction vulnérable, la machine à états passe dans l’état RETRY. Si le nombre de tentatives dépasse trois, la machine à états est réinitialisée et le buffer vulnérable est libéré.

Code d'exploitation

Pour exploiter la vulnérabilité, il est nécessaire de disposer d’un environnement matériel et logiciel permettant de communiquer en Zigbee avec le bridge. Du côté matériel, le choix s’est porté sur le dongle nRF52840 (voir figure ci-après), car il est supporté par plusieurs stacks Zigbee et il est très facile de flasher un nouveau firmware. En effet, le dongle est reconnu comme un périphérique de stockage USB, il suffit simplement de déposer le firmware sur la clé.

nRF

Pour la partie logicielle, le choix s’est d’abord porté sur le projet WHAD (Wireless HAcking Devices), un framework open source permettant de capturer, inspecter et injecter du trafic dans des protocoles sans fil tels que Bluetooth ou Zigbee. Ce choix a été motivé par le fait que le projet propose un framework Python facile à utiliser, permettant d’implémenter rapidement les échanges Zigbee nécessaires à l’exploitation de la vulnérabilité.

Le projet a été utilisé initialement pour capturer le trafic observé lors de l’enrôlement d’une ampoule Philips, dans le but de reproduire exactement ce trafic en modifiant uniquement les informations liées au modèle (source de la vulnérabilité). Cependant, les multiples tentatives pour reproduire la phase d’enrôlement ont toutes échoué en raison de timers trop restrictifs dans certaines machines à états. En effet, la stack Zigbee étant implémentée en Python, elle introduit beaucoup de latence, et la procédure d’enrôlement expire prématurément.

La stack ZBOSS est la stack Zigbee officielle utilisée par plusieurs constructeurs, tels que Nordic, Mediatek ou Espressif. Elle propose de nombreux exemples d’implémentation pour différents équipements (ampoules, interrupteurs), accompagnés d’instructions claires pour compiler et flasher le firmware résultant sur le dongle nRF.

L’exemple a été adapté pour une ampoule Philips, en définissant les endpoints, les attributs, et en incluant la charge utile utilisée lors de la collecte des informations sur le modèle.

Démonstration

Video file

Conclusion

Trois semaines se sont écoulées entre l’achat du bridge Philips Hue à la FNAC et l’obtention du premier shell root. La première semaine a été consacrée à la configuration du bridge, à l’obtention d’un accès SSH et à l’analyse de la surface d’attaque. La deuxième semaine a été dédiée à la rétro-ingénierie du binaire ipbridge, en se concentrant principalement sur le protocole Zigbee. Enfin, la troisième semaine a été consacrée au développement du code d’exploitation.

Le code d’exploitation a fonctionné dès la seconde tentative, le premier échec étant dû à une mauvaise configuration du canal Zigbee plutôt qu’à un problème de fiabilité. Ceci nous a permis, avec 2 autres entrées suppémentaires,  de nous hisser à la troisème marche du podium :

Podium