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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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<<i));
c->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").
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.
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.
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.
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:
