From c2e9cda10ec4b23488ff37c60b3aa6c2218771ad Mon Sep 17 00:00:00 2001 From: Matt Brown Date: Sat, 17 Oct 2026 07:51:42 -0400 Subject: [PATCH] feat: identify + descramble D-Link OTA container (Realtek Ameba XOR payload) The D-Link OTA firmware container is a plain [u32 build][u32 count][count x 24-byte entries] wrapper; each entry is {"OTAn", 0x18, cksum, size, payload_off, flags} and the payloads tile forward from just after the entry table to EOF. Each payload is a Realtek Ameba (RTL8710/8711) firmware image obfuscated with a single-byte XOR. Add a dlink_ota descramble scheme + signature. The deobf validator parses the entry table, derives the one-byte key from the Ameba image magic "81958711" in the first payload, and verifies it before decrypting. Keyless and self-validating: a sibling device with a different key still decrypts, and a payload that is not an Ameba image is reported encrypted-but-unkeyable rather than emitted as garbage. Under -e the first slot is de-XORed and handed to the extraction recursion, which then finds the embedded certs/keys/lzma. Observed on D-Link DSP-W118 (two firmware builds). Tests: a synthetic container is identified (verified) and descrambled byte-exact; a non-0xab derived key still decrypts; a bogus entry table and a non-Ameba payload are rejected. Adds a sample-coverage fixture; full suite green. --- signatures/dlink_ota.toml | 21 ++++++ src/deobfuscate/scheme.cpp | 85 ++++++++++++++++++++++- tests/gen_samples.py | 14 ++++ tests/run.sh | 2 + tests/test_dlink_ota.py | 136 +++++++++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 signatures/dlink_ota.toml create mode 100644 tests/test_dlink_ota.py diff --git a/signatures/dlink_ota.toml b/signatures/dlink_ota.toml new file mode 100644 index 0000000..95d88c9 --- /dev/null +++ b/signatures/dlink_ota.toml @@ -0,0 +1,21 @@ +name = "dlink_ota" +category = "encrypted" +confidence = "structural" + +# D-Link OTA container: [u32 build][u32 count][count x 24-byte entries]. The first +# entry's "OTA1" tag sits at offset 8; the deobf validator parses the entry table, +# derives the single-byte XOR key from the Realtek Ameba image magic "81958711" in +# the first payload, and confirms it. Keyless + self-validating, so a bare "OTA1" +# with no valid entry table is rejected. +magic_offset = 8 +validator = "deobf" + +[[magic]] +ascii = "OTA1" +endian = "little" + +[doc] +description = "D-Link OTA container (Realtek Ameba single-byte-XOR payload; decryptable)." +references = [ + { title = "Realtek Ameba (RTL8710/8711) OTA image", url = "https://www.realtek.com/" }, +] diff --git a/src/deobfuscate/scheme.cpp b/src/deobfuscate/scheme.cpp index 425726f..316ff78 100644 --- a/src/deobfuscate/scheme.cpp +++ b/src/deobfuscate/scheme.cpp @@ -416,6 +416,64 @@ std::optional engenius(const Reader& r, size_t off, bool probe return res; } +// ---- D-Link OTA container: a plain [build][count][entry x count] wrapper around +// one or more Realtek Ameba firmware images, each single-byte-XOR obfuscated. Each +// 24-byte entry is {"OTAn", u32 0x18, u32 cksum, u32 size, u32 payload_off, u32 +// flags}; payloads tile forward and the last ends at EOF. The XOR key is derived +// from the known Ameba image magic "81958711" and verified, so a sibling device +// that uses a different key still decrypts (or is reported undecryptable rather +// than emitting garbage). Observed on D-Link DSP-W118 (RTL8711B). ---- +std::optional dlink_ota(const Reader& r, size_t off, bool probe_only) { + static constexpr uint8_t kAmeba[8] = {'8', '1', '9', '5', '8', '7', '1', '1'}; + auto count = r.at(off + 4, Endian::Little); + if (!count || *count < 1 || *count > 9) return std::nullopt; + if (!starts_with(r, off + 8, reinterpret_cast("OTA1"), 4)) return std::nullopt; + const size_t hdr_end = 8 + static_cast(*count) * 24; + size_t expect = hdr_end; + uint32_t ota1_off = 0, ota1_size = 0; + for (uint32_t i = 0; i < *count; ++i) { + const size_t e = off + 8 + static_cast(i) * 24; + if (!starts_with(r, e, reinterpret_cast("OTA"), 3)) return std::nullopt; + auto stride = r.at(e + 4, Endian::Little); + auto size = r.at(e + 12, Endian::Little); + auto poff = r.at(e + 16, Endian::Little); + if (!stride || *stride != 0x18 || !size || !poff || *poff != expect) return std::nullopt; + if (i == 0) { + ota1_off = *poff; + ota1_size = *size; + } + expect = static_cast(*poff) + *size; + } + const size_t total_span = expect; + if (!ota1_size || off + total_span > r.size()) return std::nullopt; + + // Derive the one-byte XOR key from the first Ameba magic byte ('8'), verify the + // full 8-byte magic. Keyless + self-validating. + const size_t p0 = off + ota1_off; + auto hdr = r.bytes(p0, 8); + if (!hdr) return std::nullopt; + const uint8_t key = static_cast((*hdr)[0] ^ kAmeba[0]); + for (size_t i = 0; i < 8; ++i) + if (static_cast((*hdr)[i] ^ key) != kAmeba[i]) return std::nullopt; + + DescrambleResult res; + res.scheme = "dlink_ota"; + res.cipher = "xor-1"; + res.key_desc = "derived 0x" + to_hex(std::span(&key, 1)); + res.validated = "realtek_ameba"; + res.src_offset = p0; + res.src_len = ota1_size; + res.total_span = total_span; + if (!probe_only) { + auto ct = r.bytes(p0, ota1_size); + if (!ct) return std::nullopt; + std::vector out(ota1_size); + for (size_t i = 0; i < ota1_size; ++i) out[i] = static_cast((*ct)[i] ^ key); + res.data = std::move(out); + } + return res; +} + // Cheap header-structure check (no crypto): does a scheme's header actually parse // at `offset`? A bare magic match with no valid surrounding structure is a false // positive (esp. DLK's 3-byte magic, which hits ~once per 16 MB of random data) — @@ -483,6 +541,30 @@ std::optional scheme_structure(const Reader& r, size_t off) { if (len && *len > 136 && ml && *ml <= 256) return StructMatch{"engenius", *len}; return std::nullopt; } + // D-Link OTA: count + "OTA1" at +8 + 24-byte entries that tile forward to EOF. + // (The dlink_ota scheme fn above handles the keyed case; this reports a variant + // whose payload does not XOR to the known Ameba magic as present-but-unkeyable.) + if (starts_with(r, off + 8, reinterpret_cast("OTA1"), 4)) { + auto count = u32le(off + 4); + if (count && *count >= 1 && *count <= 9) { + size_t expect = 8 + static_cast(*count) * 24; + bool ok = true; + for (uint32_t i = 0; i < *count; ++i) { + const size_t e = off + 8 + static_cast(i) * 24; + auto stride = u32le(e + 4); + auto size = u32le(e + 12); + auto poff = u32le(e + 16); + if (!starts_with(r, e, reinterpret_cast("OTA"), 3) || !stride || + *stride != 0x18 || !size || !poff || *poff != expect) { + ok = false; + break; + } + expect = static_cast(*poff) + *size; + } + if (ok && off + expect <= r.size()) return StructMatch{"dlink_ota", expect}; + } + return std::nullopt; + } return std::nullopt; } @@ -490,7 +572,7 @@ struct Entry { const char* name; SchemeFn fn; }; -constexpr std::array kSchemes = {{ +constexpr std::array kSchemes = {{ {"dlink_shrs", shrs}, {"dlink_encrpted_img", encrpted_img}, {"openssl_salted", openssl_salted}, @@ -498,6 +580,7 @@ constexpr std::array kSchemes = {{ {"dlink_dlk", dlk}, {"dlink_tlv", tlv}, {"engenius", engenius}, + {"dlink_ota", dlink_ota}, }}; } // namespace diff --git a/tests/gen_samples.py b/tests/gen_samples.py index 975d198..fd41672 100755 --- a/tests/gen_samples.py +++ b/tests/gen_samples.py @@ -807,6 +807,19 @@ def engenius(): return bytes(out) +def dlink_ota(): + # D-Link OTA container: [u32 build][u32 count][count x 24-byte entries], each + # payload a Realtek Ameba image ("81958711" magic) single-byte-XOR obfuscated. + # moria derives the one-byte key from the magic and verifies it, so this + # identifies dlink_ota at verified tier. Payloads tile forward to EOF. + pt = b"81958711" + bytes(24) + b"AMEBA" + bytes(475) # 512-byte Ameba payload + obf = bytes(b ^ 0xAB for b in pt) + count = 1 + hdr_end = 8 + count * 24 + entry = b"OTA1" + struct.pack(" 1 else os.path.join(HERE, "..", "build", "moria") +AMEBA = b"81958711" + +fails = [] + + +def check(cond, msg): + if not cond: + fails.append(msg) + + +def container(payloads, build=0x30190412): + """payloads: list of raw (already-XORed) payload bytes. Returns the container.""" + count = len(payloads) + hdr_end = 8 + count * 24 + entries = b"" + off = hdr_end + body = b"" + for i, pl in enumerate(payloads): + tag = ("OTA%d" % (i + 1)).encode() + entries += tag + struct.pack(" identified + byte-exact de-XOR. + pt = AMEBA + b"\x00" * 24 + b"AMEBA-TEST-IMAGE" + b"\x00" * 464 + obf = bytes(b ^ 0xAB for b in pt) + data = container([obf, obf]) + gs = ota_findings(data) + check(len(gs) == 1 and gs[0]["offset"] == 0, f"valid container: expected one finding at 0, got {gs}") + if gs: + check(gs[0]["confidence_tier"] == "verified", + f"valid container tier={gs[0]['confidence_tier']}, want verified") + check(gs[0]["size"] == len(data), f"size {gs[0]['size']} != container {len(data)}") + dec = extract(data) + check(dec is not None and dec[:8] == AMEBA, "extract: descrambled.bin missing or wrong magic") + check(dec == pt, "extract: de-XOR not byte-exact") + + # 2. Key is DERIVED, not hardcoded: a non-0xab key still decrypts. + for key in (0x3C, 0x5A, 0x01): + d2 = container([ameba_payload(key)]) + gs2 = ota_findings(d2) + check(len(gs2) == 1 and gs2[0]["confidence_tier"] == "verified", + f"derived key 0x{key:02x}: not identified/verified ({gs2})") + dec2 = extract(d2) + check(dec2 is not None and dec2[:8] == AMEBA, f"derived key 0x{key:02x}: extract failed") + + # 3a. FP guard: "OTA1" at offset 8 but no valid entry table (garbage) -> not dlink_ota. + bogus = struct.pack(" may be reported (encrypted) but must NOT be decryptable (no descrambled.bin). + notameba = container([b"NOTAMEBA" + b"\x11" * 504]) + g3 = ota_findings(notameba) + if g3: + check(g3[0]["confidence_tier"] != "verified", + "FP guard: non-Ameba payload must not be reported decryptable") + check(extract(notameba) is None, "FP guard: non-Ameba payload must not yield descrambled.bin") + + print("-" * 60) + if fails: + for m in fails: + print("FAIL:", m) + return 1 + print("PASS: dlink_ota identify + derived-key XOR descramble + FP guards") + return 0 + + +if __name__ == "__main__": + sys.exit(main())