_._     _,-'""`-._
(,-.`._,'(       |\`-/|
    `-.-' \ )-`( , o o)
          `-    \`_`"'-

Extending tpbreak: because kernel netted no good bugs tonight

2026-05-28 · tpbreakfirmwarereverse-engineeringsynologyqnapd-linkxchacha20

the overnight fuzzers were chewing on the same kernel they've been chewing on all week. nothing but KASAN noise. no repro, no primitive, nothing worth a number. i stopped staring at crash logs and asked a dumber question instead. tpbreak cracks TP-Link's whole firmware fleet because the verify key is the decrypt key. which other vendors ship their keys the same way?

three of them, after one night: D-Link, QNAP and Synology. tpbreak does all four now.

the device can't keep a secret

every one of these update files is "encrypted." but the box that installs it has no user secret to unlock it with, so the key has to live somewhere the vendor ships. an older plaintext image, a GPL drop, a helper binary in the rootfs, a hardcoded string, a key table compiled into the updater. the whole job is finding where they left it.

D-Link. 20 minutes

D-Link's modern images start with SHRS and are AES-128-CBC. the key isn't derived from anything clever, it's a constant inside /bin/imgdecrypt, the decrypt helper D-Link ships in the unencrypted firmware of the very same product line. they literally publish ..._Uncrypt.bin next to the encrypted ones. the IV is per-image in the header and two SHA-512 digests bracket the payload, so a wrong key fails loudly instead of handing you garbage.

# SHRS container: verify before, decrypt, verify after
0x000   "SHRS"
0x008   u32 BE  ciphertext length
0x00C   16      AES-CBC IV          (per image)
0x09C   64      sha512(ciphertext)
0x6DC   --      AES-128-CBC   key C05FBF19…F08D6AD8  (lifted from imgdecrypt)

honest accounting: this one was already public (0xricksanchez, nstarke). i didn't reverse anything. i confirmed it, wired it in, and it decrypted a DIR-882 on the first try into a MIPS uImage. next.

QNAP. an afternoon

QNAP only bothers encrypting the first 1 MiB of the .img, with a custom LCG-driven XOR stream they brand "PC1." the tell is a 74-byte footer at EOF: icpnas, then the encrypted length and model. the key is a literal ASCII string, QNAPNASVERSION4 for QTS 4.x and QNAPNASVERSION5 for 5.x / QuTS hero. decrypt the prefix and the plaintext underneath is a gzip→tar of the entire NAS rootfs.

# pick the key by checking the plaintext makes sense
footer @ EOF-74:  "icpnas"  enc_len(u32 LE)  model  version
decrypt(prefix, "QNAPNASVERSION5")  ->  1f 8b 08 00 …   gzip magic, correct key
                                          wrong key -> entropy stays 8.0, no magic

also public (Bonstra, ulidtko). i re-derived nothing here either. i found the footer on a live image and let the gzip magic tell me which of the two strings to use. the catalog crawler pulled 1,021 QNAP images after that.

Synology. the one i had to earn

DSM .pat is the only real cryptography of the four: per-file libsodium secretstream (XChaCha20-Poly1305), an Ed25519-signed msgpack header, a per-entry key from crypto_kdf_derive_from_key. synacktiv documented the format at Pwn2Own Ireland 2024 and sud0woodo's patology ships the method, but neither ships the master keys. so i grabbed patology's key and pointed it at a current DS220j image. it got further than it had any right to, then face-planted:

[+] header signature verified           # signing key 64faba48… checks out
[-] error decrypting entry: secretstream auth failed

signature passes, decrypt fails. that only happens when the signing key is right and the master key is wrong, they aren't a fixed pair. patology hardcodes one master (078a7529…) and DSM 7.1 clearly wanted a different one. the keys aren't on GitHub, in any fork, or in the blog. time to open the binary.

DSM 6 .pat files are still plain tar, so they carry the upgrade tooling in the clear. untar one and synofwupgrade falls out, a statically-linked, stripped x86-64 ELF. i already know one key in the table, the signing key that just verified, so i use it as an anchor and grep for it:

# DSM 6 .pat -> tar x -> synofwupgrade (x86-64, static, stripped)
# grep the known signing key as an anchor:
64faba48feec6c8a…b293fe0   found @ file offset 0x2c40b8

# it sits in a table of 40-byte slots (32B key + 8B pad). dump it:
slot0 @0x2c40b8  64faba48…b293fe0   signing  (keytype 0 AND 1)
slot1 @0x2c40e0  078a7529…6a7caf61   master   keytype 0   <- patology's key
slot2 @0x2c4108  9c388f52…9f8f26fb   master   keytype 1   <- the one that works
slot3 @0x2c4130  95bbd04b…d98305a7   signing  keytype 2
slot4 @0x2c4158  fecaa2dd…3bfaa19e   …

there it is in three rows. keytype 0 and keytype 1 share the signing key 64faba48 but use different masters. that's why patology verified and then failed: it grabbed the keytype-0 master while the file was keytype 1. get_keys() at 0x4f86c0 is just a ten-case jump table (cmp edi,9 ; jmp [tbl + kt*4]) handing out a (signing, master) pair per keytype. i walked all ten and pulled the full set.

the right fix is to stop trusting the signature to pick the key and trial-decrypt instead: derive the per-entry key for every master whose signing key verifies, and keep the one whose secretstream actually opens.

candidates = [kp.master for kp in KEYS if verify(kp.signing, header)]
for m in candidates:
    k = kdf(subkey_id, ctx, m)
    if secretstream_opens(first_entry, k):   # that's the master
        break

DS220j is keytype 1. with 9c388f52… the secretstream opens and the .pat unpacks into a clean DSM 7.1 tar. hda1-ds220j_7.1-42661.tar, DSM-Security.json, the lot. the 10-key table is embedded in tpbreak now, and the trial-decrypt loop means i never have to care which keytype a given image is.

one pipeline, four vendors

each vendor is a row in a registry: a CDN to fetch from, a zip to maybe unwrap, a byte signature to dispatch on. tpbreak fetches, unwraps, sniffs, decrypts, deep-scans the payload, then streams it off disk through nginx. crawlers walked each vendor's whole download server and merged the results into the catalog.

VendorCipherKey came fromMine?
TP-LinkRSA-2048 + AES, DESPSS salt via public key / GPL libservice.sono (Watchful_IP)
D-LinkAES-128-CBC (SHRS)constant in /bin/imgdecryptno (0xricksanchez, nstarke)
QNAPPC1 stream (icpnas)ASCII QNAPNASVERSION4 / 5no (Bonstra, ulidtko)
SynologyXChaCha20-Poly130510-key table in synofwupgrade get_keys()yes
row{vendor,url} -> fetch -> unwrap zip -> sniff (SHRS? icpnas? 0xADBEEF?)
                -> decrypt (AES / PC1 / XChaCha20 / RSA·DES) -> scan -> stream

TP-Link 772 · QNAP 512 · Synology 183 · D-Link 114   models · 54,786 images

credit

D-Link's SHRS key is 0xricksanchez and nstarke's; QNAP's PC1 is Bonstra and ulidtko's; the TP-Link original is Watchful_IP's. i wired those in, no reverse required, and i won't pretend otherwise. the Synology master-key table i pulled out of synofwupgrade myself, on top of synacktiv's format work and sud0woodo's patology; the keytype 0/1 split and the trial-decrypt fix are the bit i actually had to think about. every key here came out of a binary the vendor publishes. tpbreak cannot sign firmware and never will. for security research. the kernel will still be dry tomorrow.

. _SiCk · afflicted.sh