Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/unblob/handlers/archive/dlink/alpha_encimg.py: 48%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

133 statements  

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