# How WeChat Images Are Encrypted—and How We Restore Them Step by Step

When you start reading WeChat messages programmatically, text is usually straightforward to process. Images add another step: although WeChat can display an image, image recognition programs cannot open the corresponding local cache file.

In the WeChat 4.1.9 V2 cache for macOS that we tested, an additional layer of encryption is applied around the image bytes. Before a program can use the image, this processing must be reversed. If the computer does not have a cached copy, the image must instead be downloaded using the resource description contained in the message.

Using a custom test image, we will walk through the entire process from beginning to end. First, we reproduce the verified cache-processing rules to see how the image bytes are segmented, encrypted, and assembled. We then use the same cache file and reverse the process to restore the image. Once this path is complete, we will examine how to select and download the high-resolution resources described in the message.

The forward-processing section is a synthetic reproduction based on the verified format, not a recording of WeChat's complete write process. The demonstration image, account, UIN, keys, and hashes are all part of a synthetic experiment. For the two real downloaded samples, we will explain separately what each one proves.

The accompanying video is 9 minutes and 4 seconds long. Chapters: 00:00 Starting with an image in a message; 03:37 Restoring the bytes in reverse; 06:11 Downloading the resource when no local file exists.

::video{src=https://img.leeguoo.com/media/83a28d3b-b510-4cdf-978c-c42587521695/wechat-image-principles-lesson.mp4 poster=https://img.leeguoo.com/media/41aef7ec-bd12-4464-a1a0-aceb755e0e27/wechat-image-guided-cover.png title="How WeChat Images Are Encrypted—and How We Restore Them Step by Step"}

A runnable synthetic experiment is included at the end of the article. It uses a custom image and a synthetic account and does not access WeChat.

## What Images, Encoding, and Encryption Are

What you see on the screen is pixels. Files such as PNG and JPEG store bytes encoded in a specific format. Encoding or compression determines how those bytes represent the image; encryption prevents readers without the corresponding key from directly reconstructing those bytes.

An image can therefore be encoded first and then stored in encrypted form. Renaming the file extension from `.dat` to `.jpg` does not change the bytes inside, so it cannot replace decryption.

We also encountered payloads beginning with `wxgf`. In the currently supported static samples, these contain HEVC image bitstreams that must be decoded into PNG. **Decryption yields the file bytes; only decoding produces a displayable image.**

This research involves two distinct sets of parameters:

| Parameter | Source | Purpose |
| --- | --- | --- |
| Local cache key | Derived from UIN candidates in verified account paths and the account name | Restore local V2 DAT files |
| Download resource parameters | Resource identifiers, AES parameters, lengths, and other data from the corresponding message XML | WeChat's native download pipeline |

When a local cache is available, we restore it first. When no file is available, we download it based on the message. Both paths ultimately produce the bytes of the selected resource, which are verified and then decoded as needed.

## Forward View: How a PNG Becomes a DAT

We generated a 320 × 240 test image. The PNG file is **9,086 bytes**. Every calculation below was performed on this actual file; the reverse-restored output is byte-for-byte identical to the input.

### 1. Derive the Local Cache Key

The synthetic experiment uses:

```text
UIN = 123456
account_name = demo_account
```

Under the verified derivation rule, the UIN is first converted to decimal text and placed at the beginning. The account name is appended directly afterward, without a delimiter:

```text
"123456" + "demo_account"
    ↓ MD5, output as lowercase hexadecimal text
7f104a1508c17d1b446fca4b9b336c72
    ↓ Take the ASCII bytes of the first 16 characters
7f104a1508c17d1b
```

Only after obtaining this string does AES come into play. Its 16 ASCII bytes are used as the AES-128 key input. One easy mistake is to use **the raw 16-byte MD5 digest instead.**

In this example, the digest's first raw byte is `0x7f`, while the key's first character is `7`, whose ASCII byte is `0x37`. These two methods produce completely different keys. The 16-byte length is the AES input length; it does not mean this derivation method provides 128 bits of random entropy.

The account name here must correspond to the account that owns the cache. The entire directory name, including any appended suffix, cannot simply be used as the account name. Reading the name of the local statistics file can provide a candidate UIN, but the candidate must still be validated against the target file.

The XOR value used for the tail is:

```text
xor_key = UIN & 0xff
        = 123456 & 255
        = 64
        = 0x40
```

### 2. Split the Payload into a Prefix and Tail

With the key ready, the next question is which parts of the file it processes. This synthetic reproduction uses the first 1,024 bytes as the prefix and the remainder as the tail:

```text
PNG payload: 9,086 B
├── Prefix: 1,024 B
└── Tail:   8,062 B
```

This reproduces a common layout found in the currently verified samples. It does not mean that every DAT prefix can be hard-coded as 1,024 bytes. During reverse parsing, the boundary must be derived from the file length and header fields.

### 3. Pad the Prefix, Then Encrypt It Block by Block with AES

AES has a block size of 16 bytes. The ECB mode used here processes each block independently with the same key. See [NIST SP 800-38A](https://csrc.nist.gov/pubs/sp/800/38/a/final) for the mode definition.

The prefix is first padded according to PKCS#7. Even when the input length is already an exact multiple of 16, a complete block of padding is added. In this example:

```text
1,024 B image prefix
+ 16 padding bytes of 0x10
= 1,040 B to encrypt
= 65 blocks of 16 B
```

See [RFC 5652, Section 6.3](https://www.rfc-editor.org/rfc/rfc5652.html#section-6.3) for the padding rules. Padding is not always 16 bytes long; 16 bytes are added here because this prefix is already block-aligned.

Using the key derived above, the first block of the demonstration file undergoes the following actual transformation:

```text
Plaintext:  89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52
Ciphertext: 4f 0b 81 51 39 76 da 89 f2 b4 0b d7 93 b1 dc de
```

The input begins with the PNG file signature. After encryption, the block no longer directly exposes that signature. The remaining blocks are processed independently in the same way. ECB does not require an additional IV.

### 4. XOR the Tail Byte by Byte

Every byte in the tail is XORed with `0x40`. For example, the first byte of this file's tail is `0xf0`:

```text
0xf0  = 11110000
0x40  = 01000000
XOR   = 10110000 = 0xb0
```

XOR compares the two bits at each position: matching bits produce 0, while different bits produce 1. This single-byte XOR is easy to reverse and should not be considered protection equivalent in strength to AES.

### 5. Add the File Header to Form a V2 DAT

Concatenate the 15-byte file header, the AES ciphertext region, and the XOR tail in that order:

```text
15 B header + 1,040 B AES ciphertext + 8,062 B XOR tail
= 9,117 B DAT
```

The header of this synthetic file is:

```text
07 08 56 32 08 07 00 04 00 00 7e 1f 00 00 01
```

| Offset, inclusive | Contents in this example | Explanation |
| --- | --- | --- |
| 0–5 | `07 08 56 32 08 07` | Sample format signature; `56 32` is ASCII for V2 |
| 6–9 | `00 04 00 00` | Common observed value `0x400`; do not treat it as the AES ciphertext length |
| 10–13 | `7e 1f 00 00` | Little-endian tail length, with a value of 8,062 |
| 14 | `01` | Byte present in the sample header; not part of the image payload |

The first six signature bytes identify the format of the current samples. The actual decoder also performs length, segmentation, payload, and other checks; a matching signature alone is not enough to consider arbitrary input valid.

## Reverse View: How We Restore a DAT File

The reverse process is not “run AES over the entire file once.” The header, AES region, and XOR region are handled differently, and the order matters.

### 1. Calculate the Boundaries from the Layout

First identify the V2 format, then read the four bytes at offsets 10–13 as a little-endian `xor_size`. Then calculate:

```text
aes_size = file_size - 15 - xor_size
         = 9,117 - 15 - 8,062
         = 1,040
```

The actual ranges in this example are:

```text
[0, 15)       File header
[15, 1055)    AES ciphertext, 1,040 B total
[1055, 9117)  XOR tail, 8,062 B total
```

The right endpoint is excluded. The implementation also rejects an out-of-bounds tail length, an empty AES region, or an AES region whose length is not a multiple of 16 bytes. This is more reliable than treating the header’s `0x400` directly as the ciphertext length, because the padding is also part of the ciphertext.

### 2. Derive and Verify the Target Key

Derive candidate keys using the same UIN and account-name rules. First decrypt the initial block and check whether the result resembles the beginning of a PNG, JPEG, or another supported payload to narrow down the candidates.

However, “looks like an image header” is not the final answer. Our target key-selection implementation continues restoring the complete file and accepts a candidate key only when the full MD5 of the output matches the digest of the resource corresponding to the target message.

Therefore, a successful result from an unrelated thumbnail does not prove that its selected key necessarily applies to the current target.

### 3. Check and Remove Padding After AES Decryption

Block-by-block decryption produces 1,040 bytes. The trailing sixteen `0x10` bytes are still present; they are not part of the image content.

Read the value `n` from the final byte, require it to be between 1 and 16, and verify that the final `n` consecutive bytes all equal `n` before removing the padding. In this example, this restores the 1,024-byte prefix.

If all 1,040 bytes are appended directly to the tail, the padding is inserted into the middle of the image. The beginning may still contain a valid image signature, and a decoder may tolerate some corruption, but the complete file is already incorrect.

### 4. Restore the Tail with the Same XOR Value

XORing with the same value twice cancels itself out:

```text
A XOR K XOR K = A

0xb0 XOR 0x40 = 0xf0
```

Append the restored tail to the unpadded prefix. Leave the file header out; do not include it in the output. This reconstructs the final 9,086-byte PNG.

### 5. Prove Correct Reproduction Using the Complete Byte Sequence

The synthetic experiment’s input and output can be compared directly byte for byte:

```text
Input MD5:  6d44c8898709f06cada73859dbc53dc6
Output MD5: 6d44c8898709f06cada73859dbc53dc6
Byte comparison: Identical
```

To demonstrate why padding cannot be ignored, we also retained an incorrectly concatenated result. Its MD5 is `9b932acd943687fd30514b62f2d64ac7`, which differs from the input.

When processing real WeChat cache files, we compare the restored payload against the XML digest for the same message and the same resource version. A matching digest confirms content consistency under the existing description; it does not provide a digital signature or authenticate the source. [RFC 6151](https://www.rfc-editor.org/rfc/rfc6151.html) explains the security limitations of MD5.

## How to Download the High-Resolution Original

The reverse process described earlier applies when “the DAT file is already available locally.” If the corresponding image has not yet been saved locally, the resource described by the message must first be obtained. This uses the resource parameters from the message, not the DAT cache key derived earlier.

### 1. Select the Same Resource from the Same Message

An image message may describe a thumbnail, a full ordinary image, and a separate high-resolution resource at the same time. Our verified native entry points distinguish them as follows:

| Resource selection | Resource identifier field | Byte-length field | Other bindings |
| --- | --- | --- | --- |
| Separate high-resolution `original_hd` | `cdnbigimgurl` | `hdlength` | The `aeskey` and `md5` for the same resource |
| Full ordinary image `ordinary_full` | `cdnmidimgurl` | `length` | The `aeskey` and `md5` for the same resource |

These field names do not guarantee that their values can be used directly as browser GET URLs. They are part of the client’s resource description and must be processed using the corresponding native download semantics.

When no separate high-resolution resource is described, the full ordinary image may still be the complete resource actually sent by the user; it is not the same as the chat-list thumbnail. However, the tool cannot promise that a higher-resolution original camera file exists when none is described.

### 2. Create the Request Using WeChat’s Construction and Selection Logic

We traced WeChat’s own field reads, object construction, and selectors to confirm which values enter the actual download object. We then use the corresponding native factory to create the object and populate it with the identifier, AES parameters, and length from the same resource.

The application-level image version, underlying task selection, and format options are handled separately. Changing only a numeric type value does not mean that the resource identifier and key automatically switch to the high-resolution set. In this verified path, the full MD5 from the XML is retained for output validation.

This is where REA and Hopper were used in this research: to help confirm the actual calls and field origins, followed by verification against the original bytes, runtime records, and final files. A field whose name looks like a “digest” is not necessarily the MD5 from the target XML.

### 3. Download and Process the Resource Through the Native Pipeline

The request is passed to WeChat’s own download pipeline, which handles the network resource and its corresponding resource decryption before writing the raw image payload to the specified location. We did not independently reimplement the entire network encryption protocol, nor did we generalize the local AES-ECB cache layout to network packets.

After the native call submits the task, the client must continue running so that asynchronous reception and file writes can proceed. **A successful submission and the complete file being written to disk occur at different times.** The tool must wait for the original task’s output and then verify its length and full digest before returning the result.

### 4. Validate the Payload Before Deciding How to Display It

If the native output is already PNG or JPEG, it is read in the corresponding format. If the result is WXGF, our current static path inspects and locates the HEVC Annex-B bitstream, then uses a decoder to generate a PNG.

The two files are saved separately:

```text
Raw payload ── compare with full XML MD5 ── save
    │
    └── required format decoding ── PNG ── view
```

The converted PNG differs byte-for-byte from the original WXGF, so the PNG cannot also be required to match the original payload’s MD5.

We have two independent sets of empirical evidence. One high-resolution parameter set produced a **204,763 B, 1300 × 1030 PNG** whose full XML MD5 matched; however, it was already present in the cache, so this only validates the parameters. In another test, a new ordinary sample confirmed to be absent from the inspected cache automatically yielded a **95,942 B WXGF** whose full digest matched, and it was then exported as an **832 × 1280 PNG**. These two experiments support different conclusions and cannot be combined into proof of a single cold high-resolution download.

## Two Entry Points Converging on the Same Payload

```text
Existing V2 DAT
  → Identify layout → Derive and validate key → AES decrypt/unpad + XOR restoration
  → Original image payload

Image not available locally
  → Exact message resource → Corresponding download parameters → WeChat native asynchronous download
  → Original image payload

Original image payload
  → Verify and save the complete file
  → Decode the image format if necessary
  → View directly or use with downstream tools
```

Only by mapping each layer’s input, processing, and output can you determine whether you restored a cached file, which resource version you obtained, and how the exported PNG relates to the original file. The current implementation and validation scope remain limited to the stated versions and supported ordinary-resource paths; other business types and builds require separate verification.

## Run It Yourself

Save the Python code below as `reproduce.py`, then download the synthetic test image into the same directory. Python 3 and OpenSSL are required. The entire experiment only reads and writes test files in the current directory and does not access WeChat.

```bash
curl -fL -H 'Accept: image/png' \
  'https://img.leeguoo.com/media/a7c3f889-c71b-4b8b-8215-8db163cb8164/wechat-v2-synthetic-source.png' \
  -o source.png
python3 reproduce.py
```

Downloading the exact original PNG bytes ensures that the length, digest, and calculations match those in this article. The script first verifies the test image, then generates `synthetic-v2.dat` and `recovered.png`, and finally asserts that the recovered result is byte-for-byte identical to the input.

```python
"""Synthetic demonstration of the observed local DAT layout. No WeChat access."""
from pathlib import Path
import hashlib,json,struct,subprocess
p=Path(__file__).parent
original=(p/'source.png').read_bytes()
assert hashlib.md5(original).hexdigest() == '6d44c8898709f06cada73859dbc53dc6', 'test image bytes do not match'
uin=123456;account='demo_account';joined=f'{uin}{account}'
h=hashlib.md5(joined.encode()).hexdigest();key=h[:16].encode();xor=uin&255
prefix,tail=original[:1024],original[1024:]
assert len(tail)>0
pad=16-len(prefix)%16
padded=prefix+bytes([pad])*pad
def aes(data,decrypt=False):
 argv=['openssl','enc','-aes-128-ecb','-nosalt','-nopad','-K',key.hex()]
 if decrypt:argv.append('-d')
 return subprocess.run(argv,input=data,capture_output=True,check=True).stdout
cipher=aes(padded);xtail=bytes(x^xor for x in tail)
header=b'\x07\x08V2\x08\x07'+struct.pack('<II',1024,len(tail))+b'\x01'
dat=header+cipher+xtail;(p/'synthetic-v2.dat').write_bytes(dat)
# Decode from observed fields rather than assuming the cipher length.
xor_size=int.from_bytes(dat[10:14],'little');aes_size=len(dat)-15-xor_size
assert aes_size>0 and aes_size%16==0
plain=aes(dat[15:15+aes_size],True);n=plain[-1]
assert 1<=n<=16 and plain[-n:]==bytes([n])*n
recovered=plain[:-n]+bytes(x^xor for x in dat[15+aes_size:]);(p/'recovered.png').write_bytes(recovered)
assert recovered==original
md5=lambda b:hashlib.md5(b).hexdigest()
info={'schema':'synthetic-wechat-v2-reconstruction/v1','synthetic':True,'account':account,'uin':uin,'joined':joined,'md5Text':h,'aesKeyAscii':key.decode(),'aesKeyHex':key.hex(),'xorKey':xor,'plainBytes':len(original),'prefixBytes':len(prefix),'padCount':pad,'cipherBytes':len(cipher),'tailBytes':len(tail),'headerBytes':15,'datBytes':len(dat),'headerHex':header.hex(' '),'plainBlockHex':prefix[:16].hex(' '),'cipherBlockHex':cipher[:16].hex(' '),'xorExample':{'plain':tail[0],'key':xor,'cipher':xtail[0],'restored':xtail[0]^xor},'originalMd5':md5(original),'recoveredMd5':md5(recovered),'byteForByteEqual':True,'badJoinMd5':md5(plain+tail),'pngDimensions':[320,240],'sourceBasis':'Self-generated ffmpeg testsrc2 PNG; observed layout reproduced with OpenSSL, not a trace of WeChat writer.'}
(p/'demo.json').write_text(json.dumps(info,ensure_ascii=False,indent=2)+'\n')
print(json.dumps(info,ensure_ascii=False))
```
