A firmware modification exploit for the TP-Link Archer AX10 v3, likely applicable to other AX models.
The name is a play on the Xbox 360 BadUpdate exploit, but this time, actually accurate: in the update file, two flipped bytes in a plaintext header, a recomputed hash, and a
modified string marking the firmware as Proud, the value that tells the update manager to skip signature verification entirely.
The v3 is not the v1/v1.2 sold prior. Those are Broadcom BCM490x and BCM675x and are effectively dead to mainline. The v3 is far worse; Realtek:
| Property | Value |
|---|---|
| SoC | Realtek RTL8198D (machine is rtk,8198d, system type: Formosa) |
| CPU | MIPS interAptiv V2.0, 900 MHz, 256 MB RAM |
| Endianness | Big-endian (ELF 32-bit MSB, mips32r2) |
| Storage | Realtek Luna SPI NAND, UBI on mtd2/mtd3 (51 MB each) |
| 2.4 GHz radio | RTL8832BR |
| 5 GHz radio | RTL8192CD |
| Kernel | 4.4.140, Realtek MSDK-4.8.5p1 |
| Toolchain | msdk-4.8.5-mips-EB-4.4-u0.9.33-m32ut (big-endian uClibc) |
This matters primarily because if you want to modify the rootfs you need to ensure you are targeting MIPS32 rel2 with MSB specifically, and not the ARMv7 LSB SoC the earlier revisions have.
I wanted root on my AX10 v3, and I had nothing to work with. I was on latest and previous exploits had already been long patched. Mainline for this SoC is essentially nonexistent, so there was no way in except building my own firmware and flashing it. SSH was the obvious first target, since dropbear was sitting in the stock image, just never started.
So I started by decrypting the firmware using this tool. This gave me a dump where I could actually extract a UBI partition layout and a SquashFS rootfs. The decryption tool did not allow you to re-encrypt an image, but it seemed simple enough to do so, and as such the obvious thing was to modify the rootfs and repack it, then re-encrypt it myself (which was annoying to do.)
...And my images wouldn't flash. The WebUI would take the file, display 0% the entire time, and then reject it with "Upload error", which told me something was checking the image on the way in.
The first thing I did was pull the web UI bundle apart looking for the upload endpoint. I found /debug?form=upload sitting in the JS. A debug upload path on a consumer router is worth a second look, so I searched the entire rootfs for the binary doing the actual work, and found /usr/lib/libnvrammanager.so, with strings that gave me confidence this managed updates.
Running strings on that binary gave me an fw-type parser, something that cared about
"cloud", and an MD5 verify with both possible outcomes. Enough to make me suspicious, not
enough to build on; strings don't show you control flow, and I wasn't going to spend a
week chasing a guess. So I disassembled the routine and traced the branch.
It reads a length, parses fw-type, resolves it to a name, then branches on that name.
The cloud branch does RSA. Everything else falls through to the salted MD5 path. Behavior that shouldn't even exist on a consumer firmware release, why is there even a fallback?
That was the bug, and you only really see it at the instruction level. One field in the
header picks which of two verification routines runs, and one of them is not keyed. It is
also a negative test — Cloud versus everything else — so there is no allowlist to satisfy
and no second magic value to hunt for.
The useful part was that this library also performs the check on demand.
/usr/bin/nvrammanager -c <image> verifies an image without flashing it, so once I had the
branch traced I could run it under QEMU against the stock rootfs and watch both paths
without touching the router at all. Against an unmodified 1.3.2 stock image it prints:
fw_type_name : Cloud
RSA2048 PSS
md5 or cloud-func verify ok!
Change fw-type in the update image to anything else and the RSA2048 PSS line disappears entirely, replaced by
the MD5 path, and a modified image that fails with RSA PSS Verify Error 3! under the stock
header passes with even a one byte edit. That turned guesswork into something I could iterate
on offline in seconds, which is how the rest of this was built. I chose to set my fw-type value to Proud, purely so I could rip-off BadUpdate's name, and because it was known-good (you'll see later).
The field turned up immediately in the 1.3.2 image, in cleartext at offset 0x14, value
starting at 0x1C. Byte 28.
Two bytes still felt too easy, so I sanity-checked it against a published AX55 repack method. Same offset, 28, on a completely different model. That was enough confidence to actually try it.
My goal was a modified image the bootloader and WebUI would accept. The common assumption in publicly available TP-Link firmware-hacking material is that images are RSA-signed and the key must be recovered. While yes, this is technically true, there is a major flaw in the update check.
The firmware header advertises which verification path applies via an RSA version
indicator (rsa_ver). Where the header selects RSA, the bootloader performs an RSA
signature check. Where it does not, the device falls back to a weaker integrity scheme and
the MD5 path is reachable.
What I verified:
- Grepping TP-Link's own firmware-building tools released for the AX10v3 (
mktplinkfw.c,mktplinkfw2.c,mkporayfw.c, shipped inGPL_ax10v3.zipunderIplatform/) returns no matches forrsa,openssl, orprivate key. - Those tools implement just the fallback path, using a salted MD5:
md5salt_normal = dcd73aa5c39598fbdcf9e7f40eae4737
md5salt_boot = 8cef335bd5c5cefa79c28dab2e90f42
The library on the device hardcodes its own AX10 salt rather than reading one from the file, which is what actually gets used when the check runs:
salt = 7a2b15ed9b98596de504ab44ac2a9f4e
digest = md5(salt + file[0x14:])
The RSA path still exists on the device; I did not satisfy it. What makes this work is that the fallback is unkeyed — MD5 has no secret, and every salt involved is either a compile-time constant in the GPL source or a constant compiled into the library. That makes it integrity ("did the file corrupt?") and gating ("did you flip two bytes?"), not authentication.
The plaintext header begins at offset 0x00. At 0x14 is the key fw-type:, so its
value begins at byte 28 (0x1C):
00000010: 2401 7502 6677 2d74 7970 653a 436c 6f75 fw-type:Clou
00000020: 640a ... d.
This field is what steers the library's decision, and the check is a negative test against a single value rather than an allowlist:
fw-type:Cloud— the image arrived through cloud distribution, and the library takes the full RSA-2048/PSS verification path- Any other value — the library concludes the image is a local build and drops to the weaker MD5-only integrity check
The library's own success string spells out the branch as md5 or cloud-func verify ok!,
which is the whole mechanism in six words: one unkeyed hash, or the cloud routine.
That asymmetry is the entire weakness. There is no list of accepted firmware types, and
Proud is not special-cased, privileged, or otherwise recognised by the check. Any string that is not Cloud
lands in the same fallback branch; Proud is used here only because it is a known-good non-Cloud value of identical length, so overwriting two bytes leaves the rest of the
header layout untouched.
Writing Pr at offset 28 turns fw-type:Cloud into fw-type:Proud. Two bytes. The
offset is identical across 1.0.8 and 1.3.2 and matches the published AX55 repack method:
printf '\x50\x72' | dd of=image bs=1 seek=28 conv=notruncPassing the signature check is necessary but not sufficient. Immediately afterwards the
library looks for a support-list record in the header and fails hard if it is missing:
fw_type_name : Proud
md5 or cloud-func verify ok!
[NM_Error] Read support-list from ... < check passed, flash rejected here
That record is what the nm_checkSupportList path parses, and it carries a
SupportList: body of per-region and per-model entries followed by the version string:
support-list
SupportList:
{product_name:Archer AX10,product_ver:3.0.0,special_id:55530000}
{product_name:Archer AX10,product_ver:3.0.0,special_id:43410000}
...
Psoft_ver:1.3.2 Build 20251125 rel.69127
This is where the anti-downgrade behaviour comes from, and it is worth knowing about
before you start building. Flashing stock 1.0.8 onto an updated unit fails with
Upload error at 0%, rejected before any write, because the declared version is below the unit's fuse. A custom image that declares a version at or above the running one sails through. So the version string has to be set deliberately. Declaring something newer is what avoids the downgrade rejection, which is why the final image reports 4.2.0. They have e-fuses to prevent downgrading, but if you can literally just change a few strings to bypass it, what's the point?
Two layouts are in play, because the final image uses the 1.0.8 plaintext container while
nvrammanager -c validates 1.3.2-style headers.
1.3.2 header - what the library validates:
| Offset | Size | Field |
|---|---|---|
0x00 |
4 | File length, big-endian |
0x04 |
16 | MD5 digest over salt + file[0x14:] |
0x14 |
var | fw-type: key. Value starts at 0x1C |
0x1288 |
var | Psoft_ver: |
1.0.8 plaintext container - what my image is built with:
| Offset | Size | Field |
|---|---|---|
0x14 |
var | fw-type: key - value starts at 0x1C |
0x34 |
16 | MD5 salt |
0x44 |
16 | MD5 digest over file[0x14:] |
0x1288 |
var | Psoft_ver: - version string shown in the UI |
This was the only way I could get my modified image to boot properly, for some reason.
The fw-type bypass says nothing about payload encryption, which is a separate layer.
- 1.0.8 - plaintext. UBI image begins at
0x12d9 - 1.3.2 - AES-encrypted payload
Key and IV are static per model and recoverable from the GPL toolchain:
key = 55709696c5e7e97692e9e15278bbe18a
iv = 1291ce6372b020a297afa598e26d1c85
tp-link-decrypt takes only the firmware path and auto-detects key/IV:
$ tp-link-decrypt stock132.bin
IV=1291ce6372b020a297afa598e26d1c85
Firmware verification successful
Decrypted firmware written to stock132.bin.dec
The decrypted payload is a UBI image with non-standard offsets:
| Parameter | Value |
|---|---|
| PEB size | 131072 |
| LEB size | 126976 |
| EC header | offset 0 |
| VID header | offset 0x800 |
| Volume data | offset 0x1000 |
ubireader mis-parses this image and emits garbage volumes — no UBIFS superblock magic
present, volumes truncated by roughly 114 KB, and a
length is not block aligned, could mean missing data warning. The cause is exactly the
offset above: VID headers at 0x800, not the conventional 0x40. Padding the tail to a
full PEB boundary doesn't help.
A small custom parser corrected it:
PEB, LEB = 131072, 126976
VID_OFF = 0x800
DATA_OFF = 0x1000
# skip vol_type 0x01 (layout) and 0x7F (internal)
# group by vol_id, order by lnum, concatenate 126976-byte payloadsThis produced:
| Volume | LEBs | Size | Magic | Contents |
|---|---|---|---|---|
vol0 |
31 | 3,936,256 | 0x27051956 |
uImage kernel |
vol1 |
153 | 19,427,328 | hsqs |
SquashFS rootfs |
Note the rootfs is SquashFS, not UBIFS. Running ubireader_extract_files on it fails
with "could not determine start offset" - correct behaviour given a wrong assumption. Use
unsquashfs (2,867 files, 68 MB uncompressed).
Because the 1.3.2 payload is encrypted it cannot be repacked correctly. The working approach uses a plaintext container from 1.0.8 with the 1.3.2 kernel and rootfs:
1.0.8 plaintext header (4825 bytes, up to 0x12d9)
+
1.3.2 UBI (kernel vol_id=0 static, rootfs vol_id=1 dynamic)
The 1.0.8 and 1.3.2 kernels are both Linux version 4.4.140 from the same SDK, differing
only in build date and hostname (Akronite vs ArcherAX10), so they interchange cleanly.
- Patch the extracted rootfs with any wanted changes
mksquashfs rootfs newroot.sqfs -comp xz -b 256K(matches stock: xz, 262144 block)ubinize -o newimg.ubi -m 2048 -p 128KiB -s 2048, kernel static + rootfs dynamic - only one volume may carryautoresize, or ubinize abortscat hdr18.bin newimg.ubi > img_raw.bin- Patch
fw-type:Cloud→fw-type:Proudat offset 28 - Set
Psoft_verat0x1288- must not be below the unit's fused version - Recompute MD5 over
file[0x14:]; write salt to0x34, digest to0x44
Re-extracting the rootfs from the finished image, not the build tree, confirmed
2,867 stock files with the vendor system intact (usr/sbin/hostapd, sbin/wifi,
lib/wifi/hostapd.sh, usr/lib/lua/luci, lib/libcrypt.so.0.9.33,
etc/init.d/wireless, etc).
The rootfs is read-only SquashFS and /etc is ramfs, so nothing persists there - changes must be baked into the image rather than applied live.
Simply adding this to /etc/rc.local allows telnet access:
telnetd -l /bin/sh &telnetd is a busybox symlink already present at usr/sbin/telnetd. With
telnetd -l /bin/sh, any telnet session lands directly in a root shell with no password
prompt:
$ telnet 192.168.0.1 23
Trying 192.168.0.1...
Connected to 192.168.0.1.
Escape character is '^]'.
BusyBox v1.19.4 (2025-11-25 18:56:33 CST) built-in shell (ash)
Enter 'help' for a list of built-in commands.
/etc/modules.d # id
uid=0(root) gid=0(root)
/etc/modules.d #
AX10_1.3.2_hybrid_4.2.0.bin
size : 24,384,217 bytes
SHA256 : 61367357835da80ca71ebb2e0aca1178d3964759f3c0d058def194e751689b2c
fw-type : Proud
version : Psoft_ver:4.2.0 Build 20251125 rel.69127
UBI# at : 0x12d9
You can flash this on any retail Archer AX10 v3. Boots into stock, telnet accessible on TCP port 23.
Not yet. Not sure if i'll ever even consider this.
- xmb9 - Discovering the signature bypass (I think), reversing the entire format, figuring out how to modify the UBI, this writeup and exploit
- watchfulip - Providing the tool to decrypt the firmware image
- lmadarassy - Earlier work researching the AX55 firmware
Parts of this README were written by AI. I am not afraid to admit that.