Skip to content

About

A firmware modification exploit for the TP-Link Archer AX10 v3, likely applicable to other AX models.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 

Repository files navigation

ProudUpdate

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.


1. Hardware identification

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.


2. Discovery

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.


3. Signature verification — the MD5 fallback

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 in GPL_ax10v3.zip under Iplatform/) returns no matches for rsa, openssl, or private 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 fw-type bypass

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=notrunc

The second gate: support-list

Passing 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?

Header field map

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.


4. Payload decryption

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

5. UBI geometry

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 payloads

This 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).


6. Building the modified image

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.

Assembly sequence

  1. Patch the extracted rootfs with any wanted changes
  2. mksquashfs rootfs newroot.sqfs -comp xz -b 256K (matches stock: xz, 262144 block)
  3. ubinize -o newimg.ubi -m 2048 -p 128KiB -s 2048, kernel static + rootfs dynamic - only one volume may carry autoresize, or ubinize aborts
  4. cat hdr18.bin newimg.ubi > img_raw.bin
  5. Patch fw-type:Cloud → fw-type:Proud at offset 28
  6. Set Psoft_ver at 0x1288 - must not be below the unit's fused version
  7. Recompute MD5 over file[0x14:]; write salt to 0x34, digest to 0x44

Verification

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).


7. Telnet access

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 # 

Result

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.

Is there source-code for a builder?

Not yet. Not sure if i'll ever even consider this.

Credits

  • 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.

About

A firmware modification exploit for the TP-Link Archer AX10 v3, likely applicable to other AX models.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors