Pass Me The Salt: Decrypting TP-Link Omada Fusion Pro Firmware

14 Oct 2026

Omada Banner

I’ve been messing around with TP-Link firmware images for a while now and I figured why not write a blog about decrypting the latest firmware image (4.0.0 Build 20260914) for their Omada Fusion Pro 2.5G and shed some light on firmware “encryption” wekanesses that are very common.

A first look

The image is a 1.27 GB .bin bundled with a GPL licence PDF. Throwing the .bin at file we get: data, and binwalk finds nothing at all:

binwalk 3.1.0: 85 signatures, 187 magic patterns

Analyzed 1 file for 85 file signatures (187 magic patterns) in 1.4 seconds
(no results)

This is already a very strong sign that the image is encrypted.

Before reaching for tools, let’s measure some things. A full pass over the payload gives a byte histogram and a 16-byte block census:

Entropy and ECB census over 0x1000 → EOF

16B blocks: 79588120   duplicates: 0   ratio 0.000000
byte entropy: 8.000000 bits/byte over 1273409920 bytes
distinct bytes: 256
byte freq min/max: 4967912 / 4980589 (expected 4974257.5)

Perfect entropy, and zero duplicate blocks in 79.6 million. A 1.27 GB root filesystem image is full of zero runs, so under ECB those would collide constantly. Zero collisions means a chaining or counter mode: CBC or CTR perhaps. So that rules out a weak mode like ECB and means we need to start looking for the key.

The header is cleartext, so let’s read it

The first 0xC00 (3 KB) is not encrypted, that is where the encrypted region begins. It has a structure:

Offset 0x00

00000000: 0000 0001 aa55 d98f 0000 1000 4be6 c180  .....U......K...
00000010: 4588 7958 781b 99f5 5f04 f1f8 4d16 9a83  E.yXx..._...M...
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
          … zeros to 0x12f …
00000130: 553a 3bed 4212 686f 1d62 1cf8 2788 bb2c  U:;.B.ho.b..'..,
          … 256 bytes, entropy 7.14 …
00000230: 0000 0000 0000 0000 0000 0000 0000 0000  ................
          … zeros to 0xbff …
00000c00: 3c0b fb16 a26b dddb ca4f 96b8 8e13 83a2  <....k...O......
          … high entropy, continuous to EOF …

Mapping non-zero runs over the first 4 KB gives us three main islands: 16 bytes at 0x10, 256 bytes at 0x130, and then unbroken high-entropy data from 0xc00 onward. The word at 0x0c is 0x4BE6C180 = 1,273,414,016, which is the exact file size. That one equality confirms the header is a real plaintext structure rather than just noise.

The firmware image belongs to TP-Link Omada Fusion Pro 2.5G, which is a gateway, Omada controller and video management system in one box, that explains the 1.27 GB image. So I started looking for sibiling firmware images that might provide me with more information about the current firmware image that I’m trying to decrypt, and that’s exactly what I found!

TP-Link publishes GPL source for the non-Pro sibling, and that archive is all we need. It is 409 MiB and contains an OpenWrt-based SDK named omada_gateway.

Inside it, tplink/configs/fusion_2.5g_un_v1/makeflash.py builds the upgrade file and simply declares the format:

omada_gateway/tplink/configs/fusion_2.5g_un_v1/makeflash.py

header_magic = 0xAA55D98F

def generate_up_file(output_dir, filename, config_json):
    up_file_header_size = 4096
    up_file_header = {                  # total header size is 4K
        "version":0x1,                  # 4 bytes
        "magic":header_magic,           # 4 bytes
        "header_len":4096,              # 4 bytes
        "total_len":0x00,               # 4 bytes
        "md5":"",                       # 4x4=16 bytes
        "aio_version":"",               # 16 bytes
        "reserved1":"",                 # 256 bytes
        "rsa":"",                       # 256 bytes, start addr is 0x130=304
        "reserved2":""                  # 3536 bytes
    }

There it is in a comment: a 256-byte RSA field at 0x130. Every field matches the the structure in our .bin byte for byte. The same script shows the entry table that follows at 0x1000: 64-byte big-endian records of name[32], base, size, offset, length, type.

Header layout of our target .bin

Offset Size Field Value in target
0x000 4 version 1
0x004 4 magic 0xAA55D98F
0x008 4 header_len 4096
0x00C 4 total_len 1273414016 = file size
0x010 16 md5 45887958…4d169a83
0x020 16 aio_version zeros
0x030 256 reserved1 zeros
0x130 256 rsa signature (byte-reversed)
0x230 3536 reserved2 zeros to 0xbff, then payload
0x1000 4096 entry table encrypted
0x2000 - payloads encrypted

The cipher is clearly documented in TP-Link Tapo C310 v2 GPL’s libsecurity, published with their camera source. The function rsa2_pss_sha256_verifysig() verifies an EMSA-PSS/SHA-256 signature, recovers the salt, and then does this:

rsaVerify.c - rsa2_pss_sha256_verifysig()

printf("key/iv:\nKEY=");
for(i = 0; i <16; i++)  printf("%02x",salt[i]);
printf("\nIV=");
for(i = 16; i <32; i++) printf("%02x",salt[i]);

if (memcmp(hash, hash2, hashLen)) {
    printf("RSA PSS Verify Error 3!\n");
    ret = 0;
} else {
    /* Decryption */
    if (!bCheckOnly && slen > 32 && memcmp(data + 0x240, magic, 20)) {
        struct AES_key aes;
        AES_set_decrypt_key(salt, 128, &aes);
        AES_cbc_encrypt(data + data_dec_offset, data + data_dec_offset,
                        (datalen - data_dec_offset) & ~0xF, &aes, salt + 16, 0);
        result = 1;
    }
}

The AES-128 key is salt[0:16] and the CBC IV is salt + 16. The vendor’s own debug printf even labels them KEY= and IV=.

Why this is fatal

In RSA-PSS the salt is NOT a secret. Verification recovers it: you compute s^e mod n with the public exponent, unmask the DB block with MGF1, and the salt falls out. That is how PSS verification works by construction.

So deriving the AES key from the salt means every party who can verify the firmware can also decrypt it, and the public key (which is used for verification) ships inside the product. There is no secret anywhere in the chain.

Going back to the non-Pro Fusion GPL dump, I was able to disassemble/decompile rsaVerify(.c) (under package/utils/uboot-envtools/src/) is a few hundred lines of PKCS#1 v1.5 only with no PSS, no AES. The PSS-plus-AES path exists in the Fusion family as compiled code.

Disassembly of rsa2_pss_sha256_verifysig @0x1b427, dkmgt_firmware_make

0001b616  call  0x15966   ; RSA_SHA256_MGF1
0001b7e0  call  0x158da   ; RSA_SHA256_Simple
0001b80b  call  0x158da   ; RSA_SHA256_Simple
0001b84c  cmp   dword ptr [rbp - 0x37c], 0x20   ; require salt len > 32
0001b855  lea   rdx, [rbp - 0x350]              ; &aes
0001b85c  mov   rax, qword ptr [rbp - 0x370]    ; rax = salt
0001b863  mov   esi, 0x80                       ; 128 bits
0001b868  mov   rdi, rax                        ; key = salt
0001b86b  call  0x5ef8    ; AES_set_decrypt_key
0001b877  lea   rcx, [rax + 0x10]               ; iv = salt + 16  <-- the bug
0001b881  and   eax, 0xfffffff0                 ; len &= ~0xF
0001b89b  mov   r9d, 0                          ; forward = 0 (decrypt)
0001b8ac  call  0x7784    ; AES_cbc_encrypt

WE see a 128-bit key taken from the salt pointer, IV at salt+0x10, length truncated to a block multiple, direction flag 0.

Finding the public key

The key is a Microsoft PUBLICKEYBLOB stored as base64. My first sweep of all 45,532 files inside the sibling GPL image found nothing, because I searched for the prefix BgIAAAwk that previously published TP-Link keys start with. This key begins by BgIAAACk and the difference is aiKeyAlg, 0x0000a400 (CALG_RSA_KEYX) rather than 0x0000240c. Widening the search to BgIAAA found exactly one distinct key across the whole archive.

It appears both in readable source and compiled into binaries:

Because dkmgt_firmware_make is not stripped, the symbol can be read out and checked exactly:

readelf + decode of the rsaPubKey symbol

203: 0000000000247240   369 OBJECT  LOCAL  DEFAULT   23 rsaPubKey

file offset       : 0x47240
len incl NUL      : 369        NUL-terminated : True
b64 string length : 368
blob len / bType / bVersion / aiKeyAlg : 276 / 0x06 / 0x02 / 0x0000a400
magic b'RSA1'  bitlen 2048  pubexp 65537

The same RSA-2048 key is used for two things: firmware verification, and the factory debug-CLI challenge which banner reads Sign Platform: TP-LINK_SMB_GATEWAY_RSA2048_PSS.

The public key (as published in their GPL release)

BgIAAACkAABSU0ExAAgAAAEAAQDNiuMM+XiH3Vd9HNY2dGW8GFAvI6pQ6CaABZZ2
MB5tcItRRqlo0MnUJN6eRdAM9F66QhoVDJFytzcbj4n5QPFkygFcQ0jrRRI0NUoP
ZIwzk7y5ayJmjPie9dBXDcHkaQVnnjL1c7YxAVW2EACRj14Kv7PbMbv3o5x/U43F
seU0srFuED2uAZ4UWgKMNTnAoz0W1xqr5OXBbzuU4KNFXy4ySK/oAQIPw14tiUq2
8k+srUchUgPfvY33XD+sdVO43GFKxkKmH9IFwlBTMzMEab0IXZfbj33SQ5dsAQpP
qktZIp8fkVkX+cYKCoBQK4aQsNoeJIc/wpYpnyVg1q/dPPKx

Note the blob stores its modulus little-endian. TP-Link’s parser reverses it byte by byte into a scratch buffer before handing it to a big-endian bignum constructor, so n = int.from_bytes(modulus_bytes, "little").

Asking the packaging tool where to look

Now that I had the key, I needed to locate the signature and where the encrypted data sit in my target .bin, I read the vendor’s own verifier. dkmgt_firmware_make is an unstripped x86-64 ELF.

dkmgt_check_rsa copies 256 bytes from buf + 0x130 onto the stack, then calls the verifier:

dkmgt_check_rsa @0x227f2 — the call setup

00022867  add   rax, 0x130                  ; sig source
          … 256 bytes copied to [rbp-0x120] …
000229ed  lea   r12, [rax + 0xc00]           ; pInData   = buf + 0xC00
000229de  sub   eax, 0xc00                     ; inDataLen = len - 0xC00
000229f4  lea   rdi, [rip + 0x224845]          ; -> 0x247240 rsaPubKey
00022a0a  mov   r9d, 0x100                     ; inSignatureLen = 256
00022a20  call  0x1bbc3  ; rsaVerifyPSSSignByBase64EncodePublicKeyBlob

Two facts were immediately clear: the signature really is the 256 bytes at 0x130, and the signed and encrypted region starts at 0xC00.

The signature is stored backwards

So now the key and offsets were known, one question remained: byte order. TP-Link’s bignum layer reverses buffers in several places, so I brute-forced every offset in the header at single-byte granularity, trying the signature both as-is and reversed (because it was just so easy to do):

Offset × endianness sweep, both images

Fusion_2.5Gv1_un_1.0.30 …
  0x0018 fwd: 0xbc trailer -> bad PS byte 0x0e at 0
  0x0130 rev: 0xbc trailer -> PSS VALID
     KEY=b1145466ca135fb597a81c0fc7674b09 IV=2ca0e734a7e401263542062de2c6bebc slen=222
  0x0b9c fwd: 0xbc trailer -> bad PS byte 0x3c at 0
  valid PSS blocks found: 1

Fusion_Pro_common_4.0.0 …
  0x011f fwd: 0xbc trailer -> bad PS byte 0x22 at 0
  0x0130 rev: 0xbc trailer -> PSS VALID
     KEY=ff539b0fc4ca8ec53fbeef2e0ec5ab51 IV=a0e333a6040fe9a3d97ecfad43ffdbb9 slen=222
  valid PSS blocks found: 1

One valid block per image, at the documented offset, with the signature read little-endian. The other 0xbc hits are the expected chance collisions and of course all fail the padding check immediately.

I was able to confirm the order later via libsecurity which does the same: it reverses the signature before verifying it. rsaVerifySign() in rsaVerify.c copies it into signBuf back to front.

Note slen=222. The separator sits at DB[0], so the padding string is empty and TP-Link uses a maximal 222-byte salt of which only the first 32 bytes are ever used:

EM = s^e mod n  (256 bytes, big-endian)
+--------------------------------------+----------------+----+
|            maskedDB · 223            |     H · 32     | bc |
+--------------------------------------+----------------+----+
                                               |
                 MGF1-SHA256(H) XOR maskedDB   |
                   +---------------------------+
                   v
DB (223 bytes)
+----+-------------------------------------------------------+
| 01 |                      salt · 222                       |
+----+-------------------------------------------------------+
  |   PS is empty — separator sits at DB[0]
  v
salt
+--------------+--------------+------------------------------+
| AES KEY · 16 |   IV · 16    |         unused · 190         |
+--------------+--------------+------------------------------+

Now we decrypt and verify it actually works

Header taken as is up to 0xC00, then AES-128-CBC with no padding:

Decrypt

K=ff539b0fc4ca8ec53fbeef2e0ec5ab51
IV=a0e333a6040fe9a3d97ecfad43ffdbb9

head -c 3072 "$FW" > out.bin
tail -c +3073 "$FW" | openssl enc -d -aes-128-cbc -nopad -K $K -iv $IV >> out.bin

And the immediate successful decryption proof is the entry table at 0x1000, which was high-entropy noise and is now plainly readable: partition names in ASCII, with the 64-byte stride makeflash.py described:

Decrypted entry table at 0x1000

00000400: 6d62 7200 0000 0000 0000 0000 0000 0000  mbr.............
00000420: 0000 0000 0000 0000 0000 0000 0001 0000  ................
00000430: 0000 2000 0000 4400 0000 0001 0000 0000  .. ...D.........
00000440: 7061 7274 6974 696f 6e00 0000 0000 0000  partition.......
00000480: 7375 7070 6f72 742d 6c69 7374 0000 0000  support-list....
000004c0: 736f 6674 7761 7265 2d69 6e66 6f00 0000  software-info...

Also now readable, the JSON metadata which confirms the image identity:

software-info

"firmware-info": { "firmware-version": "4.0.0 Build 20260914 Rel.73217" }
"Network-info":  { "firmware-version": "6.3.20.1 Build 20260914 Rel.73217" }
"Guard-info":    { "firmware-version": "2.3.0 Build 20260914 Rel.73217" }
"support-list":  [ "Fusion Pro 2.5G" 1.0, "Fusion Pro 2.5G PoE" 1.0 ]

Ten partitions, 1,273,405,810 bytes total

Partition Offset Length Type
mbr 0x2000 17,408 DOS/MBR + GPT
partition 0x6400 9,405 JSON
support-list 0x88c0 268 JSON
software-info 0x89e0 1,085 JSON
kernel-dtb 0x8e30 14,864,204 Device Tree Blob v17
rootfs 0xe35d80 274,333,696 SquashFS 4.0 / xz · 11,155 inodes
EAPController-package 0x113d5d80 463,633,408 ext4
Central-package 0x2cdfd980 246,336,512 ext4
NVR-package 0x3b8ea580 23,293,952 ext4
VMS-package 0x3cf21580 250,915,840 ext4

Unpacking the file system of the firmware image (rootfs.bin) gives 810 MB across 22,811 files: OpenWrt Barrier Breaker 14.07 on mediatek/generic, ARM64, with nginx running as root, a Lua captive portal, a Java Omada controller and the VMS stack.

Field Value
Image Fusion Pro 2.5G 4.0.0 Build 20260914
AES-128-CBC key ff539b0fc4ca8ec53fbeef2e0ec5ab51
IV a0e333a6040fe9a3d97ecfad43ffdbb9
Region 0xC00 → EOF
Salt length 222 bytes (PS empty)

Keys are per-image, derived from each file’s own signature. The sibling Fusion 2.5G 1.0.30 image yields b1145466ca135fb597a81c0fc7674b09 / 2ca0e734a7e401263542062de2c6bebc by the identical process.

The tool

Here’s a small script that decrypts and unpacks our target firmware image.

tplink_decrypt.py — info | decrypt | unpack

#!/usr/bin/env python3
"""Decrypt + unpack TP-Link Omada firmware images (magic 0xAA55D98F)."""
import hashlib, os, struct, sys, base64

MAGIC      = 0xAA55D98F
HEADER_LEN = 0x1000
SIG_OFF    = 0x130
SIG_LEN    = 0x100
DATA_OFF   = 0xC00          # start of signed + encrypted region

# TP-Link's own public key, from binaries they publish in their GPL release.
PUBKEY_B64 = (
    "BgIAAACkAABSU0ExAAgAAAEAAQDNiuMM+XiH3Vd9HNY2dGW8GFAvI6pQ6CaABZZ2"
    "MB5tcItRRqlo0MnUJN6eRdAM9F66QhoVDJFytzcbj4n5QPFkygFcQ0jrRRI0NUoP"
    "ZIwzk7y5ayJmjPie9dBXDcHkaQVnnjL1c7YxAVW2EACRj14Kv7PbMbv3o5x/U43F"
    "seU0srFuED2uAZ4UWgKMNTnAoz0W1xqr5OXBbzuU4KNFXy4ySK/oAQIPw14tiUq2"
    "8k+srUchUgPfvY33XD+sdVO43GFKxkKmH9IFwlBTMzMEab0IXZfbj33SQ5dsAQpP"
    "qktZIp8fkVkX+cYKCoBQK4aQsNoeJIc/wpYpnyVg1q/dPPKx"
)

def parse_pubkey(b64):
    d = base64.b64decode(b64)
    btype, bver, reserved, alg = struct.unpack_from("<BBHI", d, 0)
    magic, bitlen, pubexp     = struct.unpack_from("<4sII", d, 8)
    if magic != b"RSA1":
        raise ValueError("not an RSA1 PUBLICKEYBLOB: %r" % magic)
    nbytes = bitlen // 8
    # modulus is stored little-endian in the blob
    n = int.from_bytes(d[20:20 + nbytes], "little")
    return n, pubexp, bitlen

def mgf1_sha256(seed, length):
    out, c = b"", 0
    while len(out) < length:
        out += hashlib.sha256(seed + struct.pack(">I", c)).digest()
        c += 1
    return out[:length]

def recover_key_iv(sig_be, n, e, hash_len=32):
    """Recover the PSS salt (= AES key || IV) from a signature."""
    nbytes = (n.bit_length() + 7) // 8
    em = pow(int.from_bytes(sig_be, "big"), e, n).to_bytes(nbytes, "big")
    if em[-1] != 0xBC:
        raise ValueError("not a PSS block (trailer 0x%02x)" % em[-1])
    h    = em[nbytes - 1 - hash_len:nbytes - 1]
    mask = mgf1_sha256(h, nbytes - 1 - hash_len)
    db   = bytearray(x ^ y for x, y in zip(em[:nbytes - 1 - hash_len], mask))
    db[0] &= 0x7F
    sep = db.find(b"\x01")
    if sep < 0 or any(db[:sep]):
        raise ValueError("malformed PSS DB")
    salt = bytes(db[sep + 1:])
    if len(salt) < 32:
        raise ValueError("salt too short (%d bytes)" % len(salt))
    return salt[:16], salt[16:32], salt

def read_header(path):
    with open(path, "rb") as f:
        hdr = f.read(HEADER_LEN)
    ver, magic, hlen, total = struct.unpack(">IIII", hdr[:16])
    if magic != MAGIC:
        raise SystemExit("bad magic 0x%08X" % magic)
    return {"version": ver, "magic": magic, "header_len": hlen,
            "total_len": total, "md5": hdr[0x10:0x20],
            # stored byte-reversed; reverse for the big-endian integer
            "sig": hdr[SIG_OFF:SIG_OFF + SIG_LEN][::-1]}

def get_key(path):
    h = read_header(path)
    n, e, bits = parse_pubkey(PUBKEY_B64)
    key, iv, salt = recover_key_iv(h["sig"], n, e)
    return h, key, iv, salt

Conclusion

There is no leaked key, no weak cipher, no implementation slip in AES. The primitives are fine: RSA-2048, SHA-256, PSS, AES-128-CBC. The failure lies in the design: deriving a confidentiality key from a value that the verification algorithm is going to reveal.

PSS randomises a signature with a salt and then hands that salt back to every verifier; that is the construction working as intended. Using it as an AES key silently converts a signing scheme into a broadcast of the encryption key. Firmware encryption on a device that must decrypt its own updates in the field is always closer to obfuscation than secrecy, but this particular shape means the public key alone is sufficient to decrypt the image with no bootloader dump, no flash desolder, no hardware at all.