Extending tpbreak: because kernel netted no good bugs tonight
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.
| Vendor | Cipher | Key came from | Mine? |
|---|---|---|---|
| TP-Link | RSA-2048 + AES, DES | PSS salt via public key / GPL libservice.so | no (Watchful_IP) |
| D-Link | AES-128-CBC (SHRS) | constant in /bin/imgdecrypt | no (0xricksanchez, nstarke) |
| QNAP | PC1 stream (icpnas) | ASCII QNAPNASVERSION4 / 5 | no (Bonstra, ulidtko) |
| Synology | XChaCha20-Poly1305 | 10-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