解锁并提取Linux客户端微信数据库 (vibe coded)
1r"""WeChat .dat image file decryption module.
2
3Supports three encryption formats:
4 - V1: AES-128-ECB with fixed key cfcd208495d565ef + XOR(0x88)
5 - V2: AES-128-ECB with per-install key from process memory + XOR(0x88)
6 - Legacy XOR: single-byte XOR, key auto-detected from image magic bytes
7
8V1/V2 file structure:
9 [6B signature] [4B aes_size LE] [4B xor_size LE] [1B padding]
10 [aligned_aes_data] [raw_data] [xor_data]
11
12Reference: github.com/ylytdeng/wechat-decrypt
13"""
14
15import os
16import struct
17import sys
18
19V1_MAGIC = b'\x07\x08V1\x08\x07'
20V2_MAGIC = b'\x07\x08V2\x08\x07'
21V1_KEY = b'cfcd208495d565ef' # md5("0")[:16]
22
23# Known image magic bytes (longer signatures first to avoid false positives)
24IMAGE_MAGIC = {
25 'png': bytes([0x89, 0x50, 0x4E, 0x47]),
26 'gif': bytes([0x47, 0x49, 0x46, 0x38]),
27 'tif': bytes([0x49, 0x49, 0x2A, 0x00]),
28 'webp': bytes([0x52, 0x49, 0x46, 0x46]),
29 'jpg': bytes([0xFF, 0xD8, 0xFF]),
30 'bmp': bytes([0x42, 0x4D]),
31}
32
33
34def is_v1v2(data: bytes) -> bool:
35 """Check if data starts with V1 or V2 magic."""
36 return len(data) >= 6 and data[:6] in (V1_MAGIC, V2_MAGIC)
37
38
39def detect_image_format(header: bytes) -> str:
40 """Detect image format from decrypted header bytes."""
41 if len(header) < 2:
42 return 'bin'
43 if header[:3] == bytes([0xFF, 0xD8, 0xFF]):
44 return 'jpg'
45 if header[:4] == bytes([0x89, 0x50, 0x4E, 0x47]):
46 return 'png'
47 if header[:3] == b'GIF':
48 return 'gif'
49 if header[:2] == b'BM':
50 return 'bmp'
51 if header[:4] == b'RIFF' and len(header) >= 12 and header[8:12] == b'WEBP':
52 return 'webp'
53 if header[:4] == bytes([0x49, 0x49, 0x2A, 0x00]):
54 return 'tif'
55 if header[:4] == b'wxgf':
56 return 'hevc'
57 return 'bin'
58
59
60def _detect_xor_key(data: bytes) -> int | None:
61 """Auto-detect single-byte XOR key from legacy format."""
62 if len(data) < 4:
63 return None
64 # Try longer magic signatures first
65 for fmt, magic in IMAGE_MAGIC.items():
66 if fmt == 'bmp':
67 continue # handle separately
68 key = data[0] ^ magic[0]
69 match = all(
70 (data[i] ^ key) == magic[i]
71 for i in range(1, min(len(magic), len(data)))
72 )
73 if match:
74 return key
75 # BMP: 2-byte magic, needs extra validation
76 bmp_key = data[0] ^ 0x42
77 if len(data) >= 2 and (data[1] ^ bmp_key) == 0x4D:
78 if len(data) >= 14:
79 dec = bytes(b ^ bmp_key for b in data[:14])
80 bmp_size = struct.unpack_from('<I', dec, 2)[0]
81 bmp_offset = struct.unpack_from('<I', dec, 10)[0]
82 if 14 <= bmp_offset <= 1078 and bmp_size < len(data) * 2:
83 return bmp_key
84 return None
85
86
87def decrypt_image(data: bytes, aes_key: bytes = None) -> tuple[bytes, str] | None:
88 """Decrypt a WeChat .dat image.
89
90 Args:
91 data: Raw .dat file contents.
92 aes_key: 16-byte AES key for V2 format. Ignored for V1/legacy.
93
94 Returns:
95 (decrypted_bytes, format_string) or None on failure.
96 """
97 if len(data) < 6:
98 return None
99
100 sig = data[:6]
101
102 # V1 / V2 format
103 if sig in (V1_MAGIC, V2_MAGIC):
104 return _decrypt_v1v2(data, sig, aes_key)
105
106 # Legacy XOR format
107 xor_key = _detect_xor_key(data)
108 if xor_key is None:
109 return None
110 decrypted = bytes(b ^ xor_key for b in data)
111 fmt = detect_image_format(decrypted[:16])
112 return (decrypted, fmt)
113
114
115def _decrypt_v1v2(data: bytes, sig: bytes, aes_key: bytes = None) -> tuple[bytes, str] | None:
116 """Decrypt V1/V2 format: AES-ECB + raw + XOR(0x88)."""
117 from Cryptodome.Cipher import AES
118
119 if sig == V1_MAGIC:
120 key = V1_KEY
121 else:
122 if aes_key is None:
123 return None
124 key = aes_key if isinstance(aes_key, bytes) else aes_key.encode('ascii')
125 key = key[:16]
126 if len(key) < 16:
127 return None
128
129 if len(data) < 15:
130 return None
131
132 aes_size, xor_size = struct.unpack_from('<II', data, 6)
133
134 # aes_size is already 16-byte aligned (the encrypted data size)
135 offset = 15 # 6 sig + 4 aes_size + 4 xor_size + 1 pad
136 if offset + aes_size > len(data):
137 return None
138
139 # AES-ECB decrypt (no PKCS7 padding — aes_size is the exact encrypted size)
140 aes_data = data[offset:offset + aes_size]
141 try:
142 cipher = AES.new(key, AES.MODE_ECB)
143 dec_aes = cipher.decrypt(aes_data)
144 except (ValueError, KeyError):
145 return None
146 offset += aes_size
147
148 # Raw data (unencrypted)
149 raw_end = len(data) - xor_size
150 raw_data = data[offset:raw_end] if offset < raw_end else b''
151
152 # XOR(0x88) section
153 xor_data = data[raw_end:]
154 dec_xor = bytes(b ^ 0x88 for b in xor_data)
155
156 decrypted = dec_aes + raw_data + dec_xor
157 fmt = detect_image_format(decrypted[:16])
158 return (decrypted, fmt)
159
160
161def decrypt_image_header(data: bytes, aes_key: bytes) -> bytes | None:
162 """Decrypt only the first AES block (16 bytes) for validation.
163
164 Used by memscan to test candidate keys quickly without decrypting
165 the entire file.
166 """
167 from Cryptodome.Cipher import AES
168
169 if len(data) < 15 + 16:
170 return None
171
172 sig = data[:6]
173 if sig not in (V1_MAGIC, V2_MAGIC):
174 return None
175
176 key = aes_key if isinstance(aes_key, bytes) else aes_key.encode('ascii')
177 key = key[:16]
178 if len(key) < 16:
179 return None
180
181 offset = 15
182 aes_block = data[offset:offset + 16]
183 try:
184 cipher = AES.new(key, AES.MODE_ECB)
185 dec = cipher.decrypt(aes_block)
186 return dec
187 except Exception:
188 return None
189
190
191# Valid image magic bytes for header validation
192_VALID_HEADERS = [
193 bytes([0xFF, 0xD8, 0xFF]),
194 bytes([0x89, 0x50, 0x4E, 0x47]),
195 b'GIF',
196 b'BM',
197 b'RIFF',
198 bytes([0x49, 0x49, 0x2A, 0x00]),
199 b'wxgf',
200]
201
202
203def is_valid_image_header(header: bytes) -> bool:
204 """Check if decrypted header starts with a known image magic."""
205 return any(header[:len(m)] == m for m in _VALID_HEADERS)
206
207
208# --- CLI ---
209
210if __name__ == "__main__":
211 if len(sys.argv) < 2:
212 print("Usage: python -m wxdump_linux.linux.decode_image <dat_file> [aes_key_hex] [output_file]")
213 sys.exit(1)
214
215 dat_file = sys.argv[1]
216 key_hex = sys.argv[2] if len(sys.argv) > 2 else None
217 out_file = sys.argv[3] if len(sys.argv) > 3 else None
218
219 if not os.path.exists(dat_file):
220 print(f"File not found: {dat_file}")
221 sys.exit(1)
222
223 with open(dat_file, 'rb') as f:
224 data = f.read()
225
226 aes_key = bytes.fromhex(key_hex) if key_hex else None
227 result = decrypt_image(data, aes_key)
228
229 if result is None:
230 print("Decryption failed. For V2 files, provide AES key as second argument.")
231 sys.exit(1)
232
233 img_bytes, fmt = result
234 if out_file is None:
235 base = os.path.splitext(dat_file)[0]
236 for suffix in ('_t', '_h'):
237 if base.endswith(suffix):
238 base = base[:-len(suffix)]
239 break
240 out_file = f"{base}.{fmt}"
241
242 with open(out_file, 'wb') as f:
243 f.write(img_bytes)
244
245 print(f"OK: {out_file} ({fmt}, {len(img_bytes):,} bytes)")