# Make it Blink: Over-the-Air Exploitation of the Philips Hue Bridge
The year-end edition of **Pwn2Own** took place in Cork, Ireland. For the first time, this event featured smart home devices, including the **Amazon Smart Plug**, **Home Assistant Green**, and the **Philips Hue Bridge**. The attack scenario defined by the **ZDI** involved an adversary with access to services listening on the local network, or launching an attack via a proximity network (Wi-Fi, Bluetooth, Zigbee). This article details the research conducted on the Philips Hue Bridge to achieve **remote code execution (RCE)** from the Zigbee network.
Looking to improve your skills? Discover our **trainings** sessions! Learn more.
## Philips Hue Bridge
### Overview
The **Philips Hue Bridge** comes in two versions: a standard version (white casing) and a **Pro version** (black casing), the latter of which was recently released in 2025. For the **Pwn2Own** competition, only the standard version was included in the target list.
The **Philips Hue Bridge** allows users to control lighting and create various ambiances via the Hue mobile app. Communication between the bridge and the bulbs is handled over a **Zigbee network**. New devices can be **paired** by either launching a scan from the app or by using the central button on the casing. A discovery process then follows to collect information about the detected devices and integrate them into the network.
### Getting a shell
The first step was to get a shell on the device. Fortunately, several blog posts detail how to achieve this. The process requires shorting a specific pin during the boot sequence; from there, it is possible to reset the keys and enable the SSH service.
### Architecture
The Philips Hue Bridge is based on a **MIPS architecture** running Linux. All core functionalities are consolidated into a single, large binary (> 9 MB) named `ipbridge`, which contains approximately 40,000 functions. Multiple instances of this binary are executed to manage various services, such as Apple HomeKit, Matter, and others.
### Attack surface
The attack surface comprises both services accessible via the local network and proximity interfaces, such as Bluetooth and Zigbee. Several services are listening, including `hk_hap`, which runs on TCP port 8080. This service handles the interaction between the Philips Hue Bridge and **Apple HomeKit**. During Pwn2Own, all competing teams exploited vulnerabilities in this service, most commonly through an **authentication bypass** followed by **memory corruption**.
Other services, such as Matter (a standardized smart home protocol designed for cross-vendor interoperability), mDNS, and UPnP, are also accessible but were not explored in this research. To avoid potential **bug collisions** during the competition, we chose to focus on the **RF surface** instead. The article _Don’t be silly – it’s only a lightbulb_ by Check Point Research highlights several vulnerabilities in Zigbee frame processing and serves as an excellent starting point for getting familiar with this specific attack surface.
## Zigbee
### Zigbee stack
The following section first provides a brief overview of the Zigbee stack. At the top of the stack, we distinguish two protocols: ZDP (Zigbee Device Profile) and ZCL (Zigbee Cluster Library). The former is used for network management and node discovery, while the latter defines standard actions like turning on a light or reading a sensor value.
Two encryption keys are used within the Zigbee network. The first is called the **Link Key**, and its default value is “ZigBeeAlliance09”. It is used to protect the exchange of the second key, the **Network Key**, which is distributed during the pairing phase. This second key is then used to encrypt the data on the Zigbee network. Consequently, an attacker eavesdropping on the network during the pairing phase could potentially capture this key
It should be noted that the Zigbee 3.0 specification enhances security: now, every device supporting the new standard features a unique secret called an install code, from which a key is derived to secure the distribution of the **Network Key**.
### Zigbee frame processing
Zigbee frames are first intercepted by the Atmel controller, which converts them from a binary to a textual format before transmitting them to the `ipbridge` binary via a serial device exposed on `/dev/ttyZigbee`.
The data is then processed in a thread named `smartlink`, which is responsible for identifying and executing the appropriate handler.
Messages transmitted by the Zigbee controller follow this format:
“`
Group,Command,Data_1,Data_2,…,Data_N
“`
The comma character (“,”) is used as a delimiter. An example message is provided below, corresponding to the reception of a **ZDP** `SendMgmtPermitJoiningReq` frame:
“`
Zdp,SendMgmtPermitJoiningReq,B=0xFFFC.0,40,0
“`
The first token identifies the group. The binary distinguishes twelve groups: **Bridge**, **Link**, **TH**, **Connection**, **Network**, **Zdp**, **Zcl**, **Zgp**, **Groups**, **Log**, **Stream**, and **TrustCenter**. A set of routines is associated with each of these groups.
It is worth noting that not all messages originate from the network: some represent commands issued by the controller itself. This is the case for messages belonging to the Bridge, Link, TH, and Log groups.
### Zigbee state machines
The `ipbridge` binary implements several state machines for node discovery, pairing, configuration, and so on.
Each state machine is referenced in the binary by the acronym **FSM**, likely standing for **Finite State Machine**. A FSM is defined by a set of states, transitions, and events that trigger the move from one state to another.
The initial state of the state machine is created within a function typically named `fsm_init_state`, whose prototype is provided below:
“`
fsm_init_state( fsm *fsm; fsm_state *state; fsm_transition *transition; uint8_t nb_transitions; char *entry_function; )
“`
A state is described by the following structure:
“`
struct fsm_state { char *name; void (*entry)(void *); void (*exit)(void *); uint32_t type; };
“`
A transition is defined as following:
“`
struct fsm_transition { fsm_state *state; char *event; void (*check)(void *); void (*action)(void *); fsm_state *next_state; };
“`
State transitions are handled by a function we have dubbed `fsm_do_transition`. It takes the FSM structure, an event, and the corresponding event data as arguments:
“`
int fsm_do_transition(fsm *fsm, char **event, void *data);
“`
The function iterates through the transition table and executes the `check` function associated with both the current state and the incoming event. This `check` routine determines whether the transition is valid; if not, the process continues to the next transition’s `check` function.
Once a valid transition is identified:
– The
`exit` function of the current state is called.
– The
`action` function associated with the transition is executed.
– Finally, the
`enter` function of the new state is invoked.
A simplified view of the code is provided below:
“`
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)(); }
“`
To simplify the analysis of these state machines, we developed an **IDA Python script** to generate a visual representation of the FSMs. As an example, the following figure illustrates the **”Download Blob”** state machine. This FSM is responsible for downloading model information from a Zigbee node identified during the discovery phase. The data is retrieved on a block-by-block basis.
The initial state is highlighted in yellow. States shown in gray are “transitional” ( `state->type == 2`), meaning they automatically trigger a transition to another state. When multiple transitions are possible from a single state, the one validated by its `check` function is selected.
The vulnerability exploited during Pwn2Own resides within this specific state machine. We will explore this in detail in the next section.
## Vulnerability research
The Zigbee frame processing routines identified earlier provide an excellent entry point for vulnerability research. We focused our efforts primarily on **manufacturer-specific frames**; as these are non-standardized, they are significantly more prone to implementation errors.
A vulnerability was quickly identified in the handling of certain **ZCL (Zigbee Cluster Library)** frames specific to Philips:
“`
void zcl_basic_cluster_custom_command(zcl_decoded_frame *decoded_frame) { if ( decoded_frame->manufacturer_code && decoded_frame->cluster_command == 0xC1 && decoded_frame->manufacturer_code == 0x100B ) { zcl_handle_block_received_data(decoded_frame); } }
“`
The function we called ` zcl_handle_block_received_data`, performs minimalistic checks, and initiates a transition in the **”Download Blob”** FSM, with the **DATABLOCK RESPONSE RECEIVED** event :
“`
fsm_do_transition(&fsm_download_blob, &DataBlockResponseReceived, decoded_frame);
“`
By inspecting the state machine, we identified the routine responsible for processing this frame. A simplified view of the function is shown below:
“`
void zcl_handle_download_blob_received_bloc_event(zcl_decoded_frame *decoded_frame) { struct download_blob_ctx *ctx = global_download_blob_ctx; struct unk = ctx->field_28; ctx->copy_sucess = 0; uint8_t *payload = decoded_frame->payload uint32_t offset = extract_offset(payload); // bytes 2,3,4,5 uint32_t total_size = extract_total_size(payload); // bytes 6,7,8,9 uint8_t blob_size = payload[10]; if(!(ctx->offset ^ offset)) { if (payload[1] == unk->byte8 && decoded_frame->cluster_id == unk->cluster_id) { if (total_size >= blob_size + offset && total_size < 0x2800) { if (blob_size) { payload_size = decoded_frame->payload_size; if (blob_size + 11 == payload_size) { if (!offset && !ctx->first_fragment) { ctx->first_fragment = 1; ctx->total_size = total_size; } if (ctx->first_fragment) { if (!ctx->buffer) { ctx->buffer = malloc(total_size); } memcpy(&ctx->buffer[offset], payload[11], blob_size); ctx->offset += bolb_size ctx->copy_sucess = 1; } } } } } } }
“`
An analysis of the function’s code reveals the following frame format:
The function handles fragmented data copies. The internal copy state is managed via a global context structure ( `ctx`), which includes a buffer dynamically allocated upon receiving the initial data blob, along with an offset updated after each subsequent fragment copy.
The routine performs several sanity checks to ensure frame consistency. First, it extracts the **offset** field and compares it against the expected value stored in the global context. It then extracts the **total size** field, verifying that it is both greater than the cumulative value of the offset plus the current fragment size, and that it does not exceed a maximum threshold of 0x2800 bytes. Finally, it validates the **blob size** field against the total frame size read from the controller.
However, the data blob is copied without checking for a potential buffer overflow. Even though the initial allocation size is stored in the context structure, it is never validated against the incoming fragment size before the copy occurs.
An attacker can exploit this vulnerability by first sending a ZCL frame with a small **total size**. The overflow is then triggered by a second ZCL frame where the **blob size** exceeds `ctx->total_size – offset`.
Reaching the vulnerable code directly is not possible, as the execution path requires the **Download Blob** FSM to be in a specific state (e.g., `REQUEST DATA BLOCK`). A transition from the `IDLE` state of the **Download Blob** machine is triggered by another state machine, **Configure Devices**, which manages the commissioning of newly discovered Zigbee nodes. For Philips devices (such as a lightbulb), the bridge requests model information, thereby triggering the initial transition of the **Download Blob** FSM.
This vulnerability has been assigned **CVE-2026-3555**.
## Vulnerability exploitation
The **CVE-2026-3555** vulnerability allows to overflow into an adjacent heap chunk. Our initial strategy was to target a contiguous object containing function pointers. However, this approach requires both identifying a suitable object and having the necessary primitives to **spray** the heap so that the object lands predictably near the vulnerable buffer.
We quickly abandoned this path upon discovering that the bridge uses the **musl libc** allocator. This allowed us to revive the “Vudo” techniques described in the famous Phrack article **”Vudo malloc tricks”** to attack the allocator itself. In fact, the bridge’s embedded libc (version 1.1.24) incorporates a modified version of the **dlmalloc** allocator.
### Notes on the allocator
Allocations are served from **bins**, which consist of doubly-linked lists of chunks. The allocator maintains 64 bins, each dedicated to a specific range of allocation sizes. A chunk is defined by the following structure:
“`
struct chunk { size_t psize; size_t csize; struct chunk *next; struct chunk *prev; };
“`
We will focus specifically on the `free` function, as it provided the mechanism for our **arbitrary write primitive**.
When a chunk is released, the `__bin_chunk` function is invoked — provided the chunk was not originally allocated via an `mmap` call:
“`
void free(void *p) { if (!p) return; struct chunk *self = MEM_TO_CHUNK(p); if (IS_MMAPPED(self)) unmap_chunk(self); else __bin_chunk(self); }
“`
Before being reinserted into the target bin, the chunk may be merged with its neighbors. This procedure is handled by the following loop within the `__bin_chunk` function:
“`
void __bin_chunk(struct chunk *self) { struct chunk *next = NEXT_CHUNK(self); size_t final_size, new_size, size; int reclaim=0; int i; final_size = new_size = CHUNK_SIZE(self); for (;;) { if (self->psize & next->csize & C_INUSE) { self->csize = final_size | C_INUSE; next->psize = final_size | C_INUSE; i = bin_index(final_size); lock_bin(i); lock(mal.free_lock); if (self->psize & next->csize & C_INUSE) break; unlock(mal.free_lock); unlock_bin(i); } if (alloc_rev(self)) { self = PREV_CHUNK(self); size = CHUNK_SIZE(self); final_size += size; if (new_size+size > RECLAIM && (new_size+size^size) > size) reclaim = 1; } if (alloc_fwd(next)) { size = CHUNK_SIZE(next); final_size += size; if (new_size+size > RECLAIM && (new_size+size^size) > size) reclaim = 1; next = NEXT_CHUNK(next); } } /* … */ }
“`
The `alloc_next` and `alloc_prev` functions are responsible for merging with the next and previous chunks, respectively. To perform this merge, the neighboring chunk must first be “unbinned.”
“`
static void unbin(struct chunk *c, int i) { if (c->prev == c->next) a_and_64(&mal.binmap, ~(1ULL<prev->next = c->next; c->next->prev = c->prev; c->csize |= C_INUSE; NEXT_CHUNK(c)->psize |= C_INUSE; }
“`
If a chunk’s metadata has been compromised, the `unbin` function can be leveraged to achieve an **arbitrary write primitive**. By corrupting the `next` and `prev` pointers, an attacker can write 4 bytes (” **WHAT**”) to an arbitrary memory address (” **WHERE**”).
### Arbitrary write primitive
As described previously, by setting `c->prev` to `WHERE – 8` and `c->next` to `WHAT`, an attacker can write 4 arbitrary bytes to an arbitrary address via the instruction `c->prev->next = c->next`. However, the second write performed by the `unbin` function ( `c->next->prev = c->prev`) is more problematic, as it triggers a side-effect write (a “parasitic” write) to the address `WHAT + 12`. This address must therefore be valid and writable to avoid a crash.
While this technique writes 4 bytes at a time, chaining multiple fake free chunks allows for successive 4-byte writes.
Rather than corrupting the `next` and `prev` pointers of the adjacent chunk which frequently introduces allocator inconsistencies, we opted for a different approach. Directly altering these pointers would break the `unbin` logic, potentially leading to double-allocations and causing the `ipbridge` process to crash prematurely.
Our refined strategy involved corrupting the chunk size ( `csize`) with a “negative” value. This effectively **redirects the allocator’s traversal** toward a series of fake chunks staged within our vulnerable buffer. Because these fake chunks are not tracked within the allocator’s actual bins, they can be manipulated without corrupting its internal state or metadata.
Upon freeing the vulnerable chunk, the allocator traverses these staged structures. Each **fake chunk** triggers a 4-byte arbitrary write, chaining them together until an allocated chunk is found.
Note that corrupting the size of the adjacent chunk also introduces allocator inconsistencies. In certain scenarios, this can lead to instability or even an application crash.
### Code execution
The arbitrary write primitive was used to write a minimalist shellcode designed to invoke the `system` function. The command passed to `system`, which initiates a **reverse shell**, was also written into memory using the same primitive. Finally, we hijacked a global function pointer used for reading Zigbee frames to redirect execution to our shellcode. Since this underlying function is called frequently and periodically, it serves as an ideal trigger, ensuring our shellcode executes before any allocator inconsistencies can propagate and crash the system.
The arbitrary writes are triggered as soon as the vulnerable buffer is freed. A closer inspection of the state machine revealed that this free operation can be forced by transmitting three malformed packets immediately after our payload. For instance, by providing an unexpected **offset** value, the FSM transitions into the `RETRY` state. Once the retry count exceeds three, the state machine resets, and the vulnerable buffer is released.
## Exploitation code
To exploit the vulnerability, we required a hardware and software environment capable of Zigbee communication with the bridge. On the hardware side, we chose the **nRF52840 dongle** (see figure below); it is widely supported by various Zigbee stacks and offers a seamless firmware flashing process. The dongle is recognized as a USB mass storage device, meaning the firmware can be updated by simply dragging and dropping the file onto the drive.
Regarding the software, we initially opted for the **WHAD (Wireless HAcking Devices)** project—an open-source framework designed to capture, inspect, and inject traffic into wireless protocols such as Bluetooth and Zigbee. This choice was driven by the availability of an intuitive Python framework, which allowed us to rapidly implement the Zigbee exchanges required to exploit the vulnerability.
Initially, we used the project to capture the traffic observed during the commissioning of a Philips lightbulb, intending to replicate that exact traffic while modifying only the model information (the source of the vulnerability). However, multiple attempts to reproduce the commissioning phase failed due to overly restrictive timers within certain state machines. Specifically, as the Zigbee stack is implemented in Python, it introduces significant latency, causing the commissioning procedure to time out prematurely.
The ZBOSS stack is the official Zigbee stack utilized by several major manufacturers, including Nordic, MediaTek, and Espressif. It provides numerous implementation examples for various device types (such as lightbulbs and switches), along with clear instructions for compiling and flashing the resulting firmware onto the nRF dongle.
The example was adapted for a Philips lightbulb by defining the **endpoints** and **attributes**, and incorporating the payload used during the model information collection phase.
## Demo
## Conclusion
Three weeks elapsed between purchasing the Philips Hue bridge and obtaining the first root shell. The first week was dedicated to configuring the bridge, gaining SSH access, and mapping the attack surface. The second week focused on reverse-engineering the `ipbridge` binary, with a primary emphasis on the Zigbee protocol. Finally, the third week was devoted to exploit development.
The exploit succeeded on only our second attempt; the initial failure was due to a Zigbee channel misconfiguration rather than a reliability issue. This achievement, along with two additional entries, secured our spot on the third step of the podium:
