1from __future__ import annotations
2
3import io
4import os
5from dataclasses import dataclass
6from pathlib import Path
7from typing import TYPE_CHECKING
8
9from cryptography.hazmat.backends import default_backend
10from cryptography.hazmat.primitives.ciphers import (
11 Cipher,
12 algorithms,
13 modes,
14)
15
16from unblob._rust import math_tools
17from unblob.file_utils import Endian, File, FileSystem, InvalidInputFormat, StructParser
18from unblob.models import (
19 Extractor,
20 ExtractResult,
21 HandlerDoc,
22 HandlerType,
23 HexString,
24 Reference,
25 StructHandler,
26 ValidChunk,
27)
28
29if TYPE_CHECKING:
30 from collections.abc import Iterable
31
32 from cryptography.hazmat.primitives.ciphers import CipherContext
33
34C_DEFINITIONS = r"""
35 typedef struct dlink_alpha_header {
36 uint32 magic1;
37 uint32 signature_len;
38 uint32 padding;
39 char signature[signature_len]; /* "signature=XXXXXX_dlink.YYYY_ZZZZZZ\0" */
40 // where XXXXXX seems to be the Alpha Networks model number, YYYY the year
41 // and ZZZZZZ the D-Link model number
42 uint32 magic2;
43 uint32 signature_len2;
44 uint32 fsize; /* size of the firmware image (without 128 Bytes header) */
45 } dlink_alpha_header_t;
46
47 // see https://github.com/openwrt/firmware-utils/blob/master/src/mkwrggimg.c
48 typedef struct wrgg03_header {
49 char signature[32]; /* signature without "signature=" */
50 uint32 magic1;
51 uint32 magic2;
52 char version[16];
53 char model[16];
54 uint32 flag[2];
55 uint32 reserve[2];
56 char buildno[16];
57 uint32 fsize;
58 uint32 offset;
59 char devname[32];
60 char digest[16];
61 } wrgg03_header_t;
62"""
63XOR_RANGE = 0xFC
64UNENCRYPTED_ENTROPY_THRESHOLD = 3
65
66
67@dataclass
68class EncParams:
69 signature: bytes
70 key: bytes
71 iv: bytes
72
73 @property
74 def mangled_key(self):
75 return self._mangle(self.signature, self.key)
76
77 @property
78 def mangled_iv(self):
79 return self._mangle(self.signature, self.iv)
80
81 @staticmethod
82 def _mangle(image_sign: bytes, data: bytes) -> bytes:
83 sign_len = len(image_sign)
84 return bytes(
85 data_byte ^ ((i + 1) % XOR_RANGE) ^ image_sign[i % sign_len]
86 for i, data_byte in enumerate(data)
87 )
88
89
90@dataclass
91class EncParamsV2(EncParams):
92 key: bytes = b"oVhq0hvXHdfaGFLdubM4/QvuVHdKee7v"
93 iv: bytes = b"0BO5nlYankuVBe4s"
94
95
96ENC_START_TO_PARAMS = {
97 # DAP-1665 B
98 bytes.fromhex("35 66 6f 68"): EncParams(
99 signature=b"wapac25_dlink.2015_dap1665",
100 key=b"EfCHXytwsC6F0zsedwZc+9vDbCjE3ge4",
101 iv=b"ggPy917jwESpnfXm",
102 ),
103 # DAP-1720 A
104 bytes.fromhex("68 01 cc fb"): EncParams(
105 signature=b"wapac28_dlink.2015_dap1720",
106 key=b"qBiz6o/1RVQTtJBd3FS7FDbqogE8yoBm",
107 iv=b"EfDMqWWxHCOhEqgY",
108 ),
109 # DIR-822 C1
110 bytes.fromhex("df 8c 39 0d"): EncParams(
111 signature=b"wrgac43s_dlink.2015_dir822c1",
112 key=b"KNpsEntCcsep1jdFIs3wnXySKRGNCGmf",
113 iv=b"uph587JdKHrtAUlr",
114 ),
115 # DIR-842 C1 / C2
116 bytes.fromhex("f5 2a a0 b4"): EncParams(
117 signature=b"wrgac65_dlink.2015_dir842",
118 key=b"xQYoRZeD726UAbRb846kO7TeNw8eZa6u",
119 iv=b"zufEbNF3kUafxFiE",
120 ),
121 # DIR-842 C3 (same as C1 except for the "EU" at the end of the signature)
122 bytes.fromhex("21 dd da 00"): EncParams(
123 signature=b"wrgac65_dlink.2015_dir842EU",
124 key=b"xQYoRZeD726UAbRb846kO7TeNw8eZa6u",
125 iv=b"zufEbNF3kUafxFiE",
126 ),
127 # DIR-850L A1
128 bytes.fromhex("e3 13 00 5b"): EncParams(
129 signature=b"wrgac05_dlob.hans_dir850l",
130 key=b"BIuS1CVMEQG+0pUeE99jnR+vLlLd9unr",
131 iv=b"f3+odwHhmJL1ceW1",
132 ),
133 # DIR-850L B1
134 bytes.fromhex("0a 14 e4 24"): EncParams(
135 signature=b"wrgac25_dlink.2013gui_dir850l",
136 key=b"qQehHMEmEPQ5izL+cabn8bNHZXHjkp6W",
137 iv=b"Mmb+IKQgnO8OuF4b",
138 ),
139 # DIR-859 A
140 bytes.fromhex("4c 1b 95 af"): EncParams(
141 signature=b"wrgac37_dlink.2013gui_dir859",
142 key=b"KY0H9R2PDL3eu1J4uCVd1CK7BJ7vF1kc",
143 iv=b"qbStAzIRvWeQHz5U",
144 ),
145 # FixMe: add more entries when they are found
146}
147
148
149def decrypt_chunk(encrypted: bytes, enc_params: EncParams) -> bytes:
150 decryptor = _get_decryptor(enc_params)
151 return decryptor.update(encrypted) + decryptor.finalize()
152
153
154def decrypt_file(
155 file: File, enc_params: EncParams, chunk_size: int = 16
156) -> Iterable[bytes]:
157 decryptor = _get_decryptor(enc_params)
158 while chunk := file.read(chunk_size):
159 yield decryptor.update(chunk)
160 yield decryptor.finalize()
161
162
163def _get_decryptor(enc_params: EncParams) -> CipherContext:
164 cipher = Cipher(
165 algorithms.AES(enc_params.mangled_key),
166 modes.CBC(enc_params.mangled_iv),
167 backend=default_backend(),
168 )
169 return cipher.decryptor()
170
171
172class AlphaEncimgExtractorV1(Extractor):
173 def extract(self, inpath: Path, outdir: Path) -> ExtractResult | None:
174 fs = FileSystem(outdir)
175 with File.from_path(inpath) as file:
176 enc_magic = file.read(4)
177 enc_params = ENC_START_TO_PARAMS.get(enc_magic)
178 if not enc_params:
179 raise InvalidInputFormat("Device not supported")
180 file.seek(0, io.SEEK_SET)
181 out_path = Path(f"{enc_params.signature.decode()}.bin")
182 fs.write_chunks(out_path, decrypt_file(file, enc_params))
183 return ExtractResult(reports=fs.problems)
184
185
186class AlphaEncimgExtractorV2(Extractor):
187 def extract(self, inpath: Path, outdir: Path) -> ExtractResult | None:
188 struct_parser = StructParser(C_DEFINITIONS)
189 fs = FileSystem(outdir)
190 with File.from_path(inpath) as file:
191 header = struct_parser.parse("wrgg03_header_t", file, Endian.BIG)
192 enc_params = EncParamsV2(signature=header.signature.rstrip(b"\0"))
193 file.seek(AlphaEncimgV2Handler.HEADER_SIZE, io.SEEK_SET)
194 out_path = Path(f"{enc_params.signature.decode()}.bin")
195 fs.write_chunks(out_path, decrypt_file(file, enc_params))
196 return ExtractResult(reports=fs.problems)
197
198
199class AlphaEncimgHandler(StructHandler):
200 NAME = "alpha_encimg_v1"
201 PATTERNS = [
202 HexString("35 66 6f 68 ef 1a fe 1f 34 ef 4f 11 21 05 4e be"), # DAP-1665 B
203 HexString("68 01 cc fb ad 6b a0 ba 33 04 b0 9c bb 48 b0 27"), # DAP-1720 A
204 HexString("df 8c 39 0d 22 b4 dc 29 fb 4e bf db e8 e1 8b fb"), # DIR-822 C1
205 HexString("f5 2a a0 b4 92 53 bf ef f8 21 a6 2e 28 a7 39 8b"), # DIR-842 C1 / C2
206 HexString("21 dd da 00 99 d8 87 a9 d5 2d 7e ff 3b 58 70 a6"), # DIR-842 C3
207 HexString("e3 13 00 5b 76 df 0b e8 83 24 5a 42 ff 91 2d 3d"), # DIR-850L A1
208 HexString("0a 14 e4 24 ff 0f b4 d7 53 66 a0 b0 72 fe ab df"), # DIR-850L B1
209 HexString("4c 1b 95 af 93 72 5f 81 03 03 96 4d dd 76 01 74"), # DIR-859 A
210 HexString("98 9c 3c 2c 6b 19 1b 25 6e 03 ee 0f 72 5c 6c a0"), # DIR-865L A
211 HexString("3d e6 02 63 3f c1 85 e7 d2 bf 64 af 29 57 3a bd"), # DIR-868L A
212 HexString("8e 69 57 e7 76 09 d7 94 47 75 78 a2 4a 1f c9 b2"), # DIR-880L A
213 HexString("92 61 58 58 14 db bb 3b e5 a5 f3 e7 10 9c a2 0b"), # DIR-885L A
214 HexString("cb c3 a7 4c 50 0f 42 43 a5 d9 7c a5 25 6b cd ba"), # DIR-890L A
215 HexString("91 5a c9 a2 11 ff aa 6d b0 12 e8 8d 2c 3c 23 cb"), # DIR-895L A
216 ]
217 EXTRACTOR = AlphaEncimgExtractorV1()
218 C_DEFINITIONS = C_DEFINITIONS
219 DOC = HandlerDoc(
220 name="D-Link Alpha encimg V1 Firmware",
221 description=(
222 "Encrypted firmware images found in D-Link DIR devices manufactured by Alpha Networks."
223 "Uses AES-256-CBC encryption with device-specific keys."
224 ),
225 handler_type=HandlerType.ARCHIVE,
226 vendor="D-Link",
227 references=[
228 Reference(
229 title="OpenWRT forum",
230 url="https://forum.openwrt.org/t/adding-openwrt-support-for-d-link-dir-x1860-mt7621-mt7915-ax1800/106500",
231 ),
232 Reference(
233 title="delink tool",
234 url="https://github.com/devttys0/delink/blob/main/src/encimg.rs",
235 ),
236 ],
237 limitations=[],
238 )
239 HEADER_STRUCT = "dlink_alpha_header_t"
240 HEADER_SIZE = 0x80
241 _SIGNATURE_PREFIX = b"signature="
242 MAGIC = {0x5EA3A417}
243
244 def _get_header(self, file: File):
245 encrypted_header = file.read(self.HEADER_SIZE)
246 enc_params = ENC_START_TO_PARAMS.get(encrypted_header[:4])
247 if not enc_params:
248 raise InvalidInputFormat("Device not supported")
249
250 decrypted_header = decrypt_chunk(encrypted_header, enc_params)
251 header = self._struct_parser.parse(
252 self.HEADER_STRUCT, decrypted_header, Endian.BIG
253 )
254 self._validate(enc_params, file, header)
255 return header
256
257 def calculate_chunk(self, file: File, start_offset: int) -> ValidChunk | None:
258 header = self._get_header(file)
259 end_offset = start_offset + self.HEADER_SIZE + header.fsize
260 return ValidChunk(start_offset=start_offset, end_offset=end_offset)
261
262 def _validate(self, enc_params: EncParams, file: File, header):
263 if header.magic1 not in self.MAGIC:
264 raise InvalidInputFormat(f"Invalid magic: {header.magic1}")
265
266 expected_signature = self._SIGNATURE_PREFIX + enc_params.signature
267 if header.signature.rstrip(b"\0") != expected_signature:
268 raise InvalidInputFormat(f"Invalid signature {header.signature}")
269
270 remaining = file.size() - file.tell()
271 if header.fsize > remaining:
272 raise InvalidInputFormat("Invalid file size")
273
274 if header.fsize % 16:
275 raise InvalidInputFormat(
276 f"Firmware size not aligned to 16 bytes: {header.fsize}"
277 )
278
279
280class AlphaEncimgV2Handler(AlphaEncimgHandler):
281 NAME = "alpha_encimg_v2"
282 PATTERNS = [
283 HexString("77 61 70 [29] 21 03 08 20 21 03 08 20"), # Alpha Networks DAP-2xxx
284 ]
285 EXTRACTOR = AlphaEncimgExtractorV2()
286 DOC = HandlerDoc(
287 name="D-Link Alpha encimg v2 Firmware",
288 description=(
289 "Encrypted firmware images found in D-Link DIR devices manufactured by Alpha Networks."
290 "Uses AES-256-CBC encryption with device-specific keys."
291 "Unlike the other variant, this one uses a prepended unencrypted WRGG03 header."
292 ),
293 handler_type=HandlerType.ARCHIVE,
294 vendor="D-Link",
295 references=[
296 Reference(
297 title="OpenWRT Wiki",
298 url="https://openwrt.org/toh/d-link/d-link_dap_series_of_business_access_points#old_generation_dap-2xxxdap-3xxx_built_by_alpha_networks",
299 ),
300 Reference(
301 title="delink tool",
302 url="https://github.com/devttys0/delink/blob/main/src/encimg.rs",
303 ),
304 ],
305 limitations=[],
306 )
307 HEADER_SIZE = 0xA0
308 HEADER_STRUCT = "wrgg03_header_t"
309 _SIGNATURE_PREFIX = b""
310 MAGIC = {0x20080321, 0x21030820}
311 KNOWN_UNENCRYPTED = (
312 bytes.fromhex("5d 00 00 80"),
313 bytes.fromhex("d0 0d fe ed"),
314 bytes.fromhex("10 ec 7a 0d"), # DAP X2810/X2850
315 )
316
317 def _validate(self, enc_params: EncParams, file: File, header):
318 first_block = file.read(16)
319 file.seek(-16, os.SEEK_CUR)
320 if self._is_not_encrypted(first_block):
321 raise InvalidInputFormat("Firmware payload does not seem to be encrypted")
322 super()._validate(enc_params, file, header)
323
324 def _is_not_encrypted(self, first_block: bytes) -> bool:
325 return (
326 any(first_block.startswith(m) for m in self.KNOWN_UNENCRYPTED)
327 or math_tools.shannon_entropy(first_block) < UNENCRYPTED_ENTROPY_THRESHOLD
328 )
329
330 def _get_header(self, file: File):
331 current_offset = file.tell()
332 header_le = self.parse_header(file, Endian.LITTLE)
333 file.seek(current_offset, io.SEEK_SET)
334 header_be = self.parse_header(file, Endian.BIG)
335 # there are little and big endian devices that use this format, but there seems to be
336 # only one mandatory integer header field: size (sadly we cannot write signatures for that)
337 # since the size is an uint32 and the FW image should be smaller than 64 MB, it is safe to assume
338 # that the correct endianness is the one with the smaller size field
339 header = header_le if header_le.fsize < header_be.fsize else header_be
340
341 device = header.signature.rstrip(b"\0")
342 enc_params = EncParamsV2(signature=device)
343 self._validate(enc_params, file, header)
344 return header