# WeChat画像はどう暗号化され、どのように段階的に復元できるのか

プログラムでWeChatのメッセージを{読|よ}み{始|はじ}めると、テキストはたいてい{比較的|ひかくてき}{簡単|かんたん}に{処理|しょり}できます。しかし、画像を1{枚|まい}{読|よ}み{込|こ}むとなると、もう1つ{手順|てじゅん}が{増|ふ}えます。WeChatでは{問題|もんだい}なく{表示|ひょうじ}されるのに、ローカルキャッシュを{取|と}り{出|だ}しても、画像{認識|にんしき}プログラムでは{開|ひら}けません。

{今回|こんかい}{検証|けんしょう}したmacOS版WeChat 4.1.9 V2のキャッシュでは、画像のバイトデータにさらに1{層|そう}の{暗号化|あんごうか}{処理|しょり}が{施|ほどこ}されています。プログラムでこの画像を{利用|りよう}するには、まずこの{処理|しょり}を{取|と}り{除|のぞ}く{必要|ひつよう}があります。パソコンにキャッシュすらない{場合|ばあい}は、メッセージに{含|ふく}まれるリソース{情報|じょうほう}をもとにダウンロードする{必要|ひつよう}もあります。

ここでは{自作|じさく}のテスト画像を1{枚|まい}{使|つか}い、この{流|なが}れを{最初|さいしょ}から{順番|じゅんばん}に{確認|かくにん}します。まず、{検証済|けんしょうず}みのキャッシュ{処理|しょり}ルールを{再現|さいげん}し、画像のバイトデータがどのように{分割|ぶんかつ}、{暗号化|あんごうか}、{結合|けつごう}されるかを{見|み}ていきます。{次|つぎ}に、{同|おな}じキャッシュを{使|つか}い、{逆方向|ぎゃくほうこう}の{手順|てじゅん}で{復元|ふくげん}します。この{経路|けいろ}を{確認|かくにん}したあと、メッセージに{含|ふく}まれる{高画質|こうがしつ}リソースをどのように{選択|せんたく}し、ダウンロードするかを{見|み}ていきます。

{順方向|じゅんほうこう}の{部分|ぶぶん}は、{検証済|けんしょうず}みの{形式|けいしき}に{基|もと}づく{合成|ごうせい}{再現|さいげん}であり、WeChatの{完全|かんぜん}な{書|か}き{込|こ}み{処理|しょり}を{記録|きろく}したものではありません。デモ画像、アカウント、UIN、{暗号鍵|あんごうかぎ}、ダイジェストは、すべて{合成|ごうせい}{実験|じっけん}のものです。{実際|じっさい}にダウンロードした2つのサンプルについては、それぞれから{何|なに}を{証明|しょうめい}できるかを{個別|こべつ}に{説明|せつめい}します。

{関連|かんれん}動画の{長|なが}さは{合計|ごうけい}9{分|ふん}04{秒|びょう}です。チャプター：00:00 メッセージ{内|ない}の画像から{開始|かいし}；03:37 バイトデータの{逆変換|ぎゃくへんかん}；06:11 ローカルにファイルがない{場合|ばあい}のリソースダウンロード。

::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="WeChat画像はどう暗号化され、どのように段階的に復元できるのか"}

{記事|きじ}の{末尾|まつび}には、{実行可能|じっこうかのう}な{合成|ごうせい}{実験|じっけん}を{掲載|けいさい}しています。{自作|じさく}画像と{合成|ごうせい}アカウントを{使用|しよう}し、WeChatのデータは{読|よ}み{取|と}りません。

## 画像・エンコード・暗号化とは何か

画面に表示されているのはピクセルです。PNGやJPEGなどのファイルには、特定の形式でエンコードされたバイト列が保存されています。エンコードや圧縮は、それらのバイト列で画像をどのように表現するかを決めます。一方、暗号化は、対応する鍵がない場合に、読み手がそのバイト列を直接復元できないようにします。

そのため、画像はまずエンコードされ、その後に暗号化して保存されることがあります。ファイルの拡張子を `.dat` から `.jpg` に変更しても、内部のバイト列は変わらないため、当然ながら復号の代わりにはなりません。

また、`wxgf` で始まるペイロードも確認されています。現在対応している静的サンプルでは、HEVC画像のビットストリームが含まれており、さらにPNGへデコードする必要があります。**復号するとファイルのバイト列が得られ、デコードして初めて表示可能な画像になります。**

今回の調査では、異なる2組のパラメータを使用します。

| パラメータ | 取得元 | 用途 |
| --- | --- | --- |
| ローカルキャッシュ鍵 | 検証済みアカウントパスのUIN候補とアカウント名から導出 | ローカルのV2 DATを復元 |
| リソースダウンロード用パラメータ | 対応するメッセージXML内のリソース識別子、AESパラメータ、長さなど | WeChatネイティブのダウンロード経路 |

ローカルキャッシュがある場合は、まずそれを復元します。ファイルがない場合は、メッセージに基づいてダウンロードします。どちらの経路でも、最終的に選択したリソースのファイルバイト列を取得し、検証したうえで必要に応じてデコードします。

## 順方向から見る：1つのPNGがDATになるまで

320 × 240のテスト画像を生成しました。PNGファイルのサイズは**9,086バイト**です。以下の計算はすべて、このファイルに対して実際に実行したものです。逆変換で復元した出力は、入力とバイト単位で完全に一致しています。

### 1. ローカルキャッシュ鍵を導出する

合成実験では、次の値を使用します。

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

検証済みの導出規則に従い、まずUINを10進数の文字列にして先頭に置き、その直後に区切り文字なしでアカウント名を連結します。

```text
"123456" + "demo_account"
    ↓ MD5、小文字の16進文字列として出力
7f104a1508c17d1b446fca4b9b336c72
    ↓ 先頭16文字のASCIIバイト列を取得
7f104a1508c17d1b
```

この文字列を得た段階で、初めてAES処理に進みます。この16文字のASCIIバイト列を、AES-128の鍵として入力します。間違えやすい点ですが、**これはMD5の生の16バイトダイジェストではありません。**

この例では、ダイジェストの生の先頭バイトは`0x7f`です。一方、鍵の先頭文字は文字`7`であり、そのASCIIバイトは`0x37`です。両者から得られる鍵はまったく異なります。16バイトという長さはAESへの入力長を表すだけであり、この導出方式が128ビットのランダムエントロピーを提供するという意味ではありません。

ここで使用するアカウント名は、キャッシュが属するアカウントと一致している必要があります。追加の接尾辞を含むディレクトリ名全体を、そのままアカウント名として使用してはいけません。ローカルの統計ファイル名からUIN候補を取得できますが、その候補も対象ファイルで検証する必要があります。

末尾に使用するXOR値は次のとおりです。

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

### 2. ペイロードを前半部と末尾部に分割する

鍵の準備ができたので、次にファイルのどの範囲へ適用するかを見ていきます。この合成再現では、先頭1,024バイトを前半部、残りを末尾部とします。

```text
PNGペイロード：9,086 B
├── 前半部：1,024 B
└── 末尾部：8,062 B
```

これは、現在検証済みのサンプルで一般的に見られるレイアウトを再現するためのものです。すべてのDATで前半部を固定の1,024バイトとして扱えるという意味ではありません。逆方向に解析する際は、ファイル長とヘッダーフィールドから境界を求める必要があります。

### 3. 前半部をパディングし、ブロックごとにAES暗号化する

AESのブロックサイズは16バイトです。ここで使用するECBモードでは、すべてのブロックを同じ鍵で個別に処理します。モードの定義については、[NIST SP 800-38A](https://csrc.nist.gov/pubs/sp/800/38/a/final)を参照してください。

まず、前半部をPKCS#7規則に従ってパディングします。入力長がちょうど16の倍数であっても、完全な1ブロック分を追加します。この例では次のようになります。

```text
1,024 Bの画像前半部
+ 16個の0x10パディングバイト
= 1,040 Bの暗号化対象データ
= 16 Bのブロック65個
```

パディング規則の詳細は、[RFC 5652の第6.3節](https://www.rfc-editor.org/rfc/rfc5652.html#section-6.3)を参照してください。パディングが常に16バイトになるわけではありません。ここで16バイト追加されるのは、この前半部がすでにブロック境界に揃っているためです。

先ほど導出した鍵を使用すると、サンプルファイルの最初のブロックは実際に次のように変化します。

```text
平文：89 50 4e 47 0d 0a 1a 0a 00 00 00 0d 49 48 44 52
暗号文：4f 0b 81 51 39 76 da 89 f2 b4 0b d7 93 b1 dc de
```

入力はPNGファイルシグネチャから始まっていますが、暗号化後のブロックからこのシグネチャを直接読み取ることはできません。ほかのブロックも同じ方法で個別に処理します。ECBでは、追加のIVを用意する必要はありません。

### 4. 末尾部を1バイトずつXORする

末尾部の各バイトを`0x40`とXORします。たとえば、このファイルの末尾部の先頭バイトは実際には`0xf0`です。

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

XORでは、同じ位置のビット同士を比較し、同じなら0、異なれば1とします。この単一バイトXORは簡単に元へ戻せるため、AESと同等の強度を持つ保護とは見なせません。

### 5. ファイルヘッダーを追加してV2 DATを構成する

15バイトのファイルヘッダー、AES暗号文領域、XOR処理済みの末尾部を順番に連結します。

```text
15 Bのヘッダー + 1,040 BのAES暗号文 + 8,062 BのXOR末尾部
= 9,117 BのDAT
```

この合成ファイルのヘッダーは次のとおりです。

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

| オフセット（両端を含む） | この例の内容 | 説明 |
| --- | --- | --- |
| 0–5 | `07 08 56 32 08 07` | サンプル形式のシグネチャ。`56 32`はASCIIのV2 |
| 6–9 | `00 04 00 00` | 頻繁に観測される値`0x400`。AES暗号文の長さとしては扱わない |
| 10–13 | `7e 1f 00 00` | リトルエンディアンの末尾長。値は8,062 |
| 14 | `01` | サンプルヘッダー内のバイト。画像ペイロードには含まれない |

先頭6バイトのシグネチャは、現在のサンプル形式を示すために使用します。実際のデコーダーでは、長さ、セグメント分割、ペイロードなども検査します。シグネチャが一致するだけで、任意の入力を有効と判断してはいけません。

## 逆方向から見る：DATをどう復元するか

逆方向の処理は、「ファイル全体に一度AESを適用する」ものではありません。ファイルヘッダー、AES領域、XOR領域では処理方法が異なり、順序も間違えてはいけません。

### 1. レイアウトから境界を計算する

まずV2形式を識別し、オフセット10～13の4バイトからリトルエンディアンで`xor_size`を読み取ります。次に、以下のように計算します。

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

この例での実際の範囲は次のとおりです。

```text
[0, 15)       ファイルヘッダー
[15, 1055)    AES暗号文、合計1,040 B
[1055, 9117)  XOR末尾領域、合計8,062 B
```

各区間に右端は含まれません。実装ではさらに、末尾長が範囲外の場合、AES領域が空の場合、またはAES領域のサイズが16バイトの倍数でない場合も拒否します。パディングも暗号文に含まれるため、この方法はヘッダー内の`0x400`をそのまま暗号文長と見なすより確実です。

### 2. 対象の鍵を導出して確認する

同じUINとアカウント名の規則に従って、候補鍵を導出します。まず先頭ブロックを復号し、その結果がPNG、JPEG、またはその他の対応ペイロードの先頭らしいかを確認して、候補を絞り込みます。

ただし、「画像ヘッダーらしい」だけでは最終的な答えにはなりません。対象鍵を選択する実装では、さらにファイル全体を復元し、出力全体のMD5が対象メッセージに対応するリソースのダイジェストと一致した場合にのみ、その候補鍵を採用します。

したがって、無関係なサムネイルで復号に成功したからといって、そこで選ばれた鍵が現在の対象にも必ず適用できるとは限りません。

### 3. AES復号後、パディングを検査して除去する

ブロック単位で復号すると、1,040バイトが得られます。末尾には16個の`0x10`が残っていますが、これらは画像データではありません。

最後の1バイトの値`n`を読み取り、1～16の範囲内であること、さらに末尾から連続する`n`バイトがすべて`n`と等しいことを確認してから、そのパディングを削除します。この例では、前半部分が1,024バイトに戻ります。

1,040バイトすべてをそのまま末尾領域と連結すると、パディングが画像の途中に挟まります。先頭には正しい画像シグネチャが残る可能性があり、デコーダーが一部の破損を許容する場合もありますが、ファイル全体としては正しくありません。

### 4. 同じXOR値で末尾領域を復元する

同じ値とのXORを2回行うと、元の値に戻ります。

```text
A XOR K XOR K = A

0xb0 XOR 0x40 = 0xf0
```

復元した末尾領域を、パディング除去済みの前半部分に連結します。ファイルヘッダーは含めず、外側に残します。最終的に、9,086バイトのPNGが復元されます。

### 5. 完全なバイト列で復元の正しさを証明する

合成実験では、入力と出力をバイト単位で直接比較できます。

```text
入力MD5：6d44c8898709f06cada73859dbc53dc6
出力MD5：6d44c8898709f06cada73859dbc53dc6
バイト比較：完全一致
```

パディングを無視できない理由を示すため、誤って連結した結果も残しています。そのMD5は`9b932acd943687fd30514b62f2d64ac7`で、入力とは一致しません。

実際のWeChatキャッシュを処理する際は、復元したペイロードを、同じメッセージかつ同じリソースバージョンのXMLダイジェストと照合します。ダイジェストの一致は、既存の記述に対する内容の整合性を確認するためのものであり、デジタル署名や出所の認証を意味するものではありません。[RFC 6151](https://www.rfc-editor.org/rfc/rfc6151.html)では、MD5のセキュリティ上の制限について説明されています。

## 高解像度のオリジナル画像をダウンロードする方法

前述の逆処理で解決できるのは、「DATがすでにローカルに存在する」場合です。該当する画像がまだローカルに保存されていない場合は、まずメッセージに記述されたリソースを取得する必要があります。このとき使用するのはメッセージ内のリソースパラメータであり、前述の方法で導出したDATキャッシュキーではありません。

### 1. 同じメッセージから同じリソースの情報を選ぶ

1件の画像メッセージに、サムネイル、通常画質の完全画像、独立した高解像度リソースが同時に記述されている場合があります。検証済みのネイティブエントリポイントでは、次のように区別されます。

| リソースの選択 | リソース識別子フィールド | バイト長フィールド | その他の関連情報 |
| --- | --- | --- | --- |
| 独立した高解像度画像 `original_hd` | `cdnbigimgurl` | `hdlength` | 同じリソースの`aeskey`、`md5` |
| 通常画質の完全画像 `ordinary_full` | `cdnmidimgurl` | `length` | 同じリソースの`aeskey`、`md5` |

これらのフィールド名は、その値をブラウザのGETリクエスト用URLとして直接使用できることを保証するものではありません。これはクライアントのリソース記述の一部であり、対応するネイティブのダウンロード処理を経由する必要があります。

独立した高解像度リソースの記述がない場合、通常画質の完全画像が、ユーザーによって実際に送信された完全なリソースであることもあります。これはチャット一覧のサムネイルとは異なります。ただし、ツール側で、さらに高解像度のカメラ原本が存在すると根拠なく保証することはできません。

### 2. WeChatの構築・選択ロジックに従ってリクエストを作成する

WeChat自体によるフィールドの読み取り、オブジェクトの構築、セレクターの処理を追跡し、実際のダウンロードオブジェクトにどの値が渡されるかを確認しました。そのうえで、対応するネイティブファクトリを使用してオブジェクトを作成し、同じリソースに属する識別子、AESパラメータ、長さを設定します。

業務上の画像バージョン、下位レベルのタスク選択、フォーマットオプションは、それぞれ個別に処理されます。種類を表す数値を1つ変更しただけで、リソース識別子や鍵も自動的に高解像度用の値へ切り替わると考えることはできません。この検証済みの経路では、XML内の完全なMD5は出力検証用として保持されます。

今回の調査におけるREAとHopperの役割は、実際の呼び出しとフィールドの出所を確認し、さらに生バイト、実行記録、最終ファイルと照合することでした。あるフィールド名が「ダイジェスト」のように見えても、それが対象XMLのMD5であるとは限りません。

### 3. ネイティブ経路でリソースをダウンロードして処理する

リクエストはWeChat自身のダウンロード経路に渡され、ネットワークリソースと対応するリソース復号を処理したうえで、指定された場所に元の画像ペイロードが書き出されます。ネットワーク暗号化プロトコル全体を独自に再実装したわけではなく、ローカルのAES-ECBキャッシュ形式をネットワークパケットにそのまま適用したわけでもありません。

ネイティブ呼び出しでタスクを送信した後も、非同期受信とファイル書き込みを進めるには、クライアントを引き続き動作させる必要があります。**送信処理が成功を返す時点と、完全なファイルがディスクに保存される時点は異なります。** ツールは元のタスクによる出力を待ち、長さと完全なダイジェストを確認してから、結果を引き渡す必要があります。

### 4. ペイロードを検証してから、表示方法を決定する

ネイティブ出力がすでにPNGまたはJPEGであれば、対応する形式として読み込みます。WXGFの場合、現在対応している静的処理では、HEVC Annex-Bビットストリームを検査して位置を特定し、デコーダーでPNGを生成します。

2つのファイルは個別に保存します。

```text
元のペイロード ── XMLの完全なMD5と比較 ── 保存
      │
      └── 必要なフォーマット変換 ── PNG ── 表示
```

変換後のPNGと元のWXGFではバイト列が異なるため、PNGにも元のペイロードと同じMD5が一致することを要求してはいけません。

独立した実証結果が2種類あります。高解像度用パラメータとの照合では、**204,763 B、1300 × 1030のPNG**が生成され、XMLの完全なMD5と一致しました。ただし、このデータはすでにキャッシュに存在していたため、パラメータの検証にとどまります。別の実験では、確認済みのキャッシュに存在しない新しい通常画質サンプルから、**95,942 BのWXGF**が自動的に取得され、完全なダイジェストと一致した後、**832 × 1280のPNG**として出力されました。この2回の実験が裏付ける結論は異なるため、1回の高解像度コールドダウンロードの証明として統合することはできません。

## 2つの入口が、同一のペイロードへ合流する

```text
既存のV2 DAT
  → レイアウトを識別 → 鍵を導出・検証 → AES復号／パディング除去 + XOR復元
  → 元の画像ペイロード

ローカルに画像がない
  → 正確なメッセージリソース → 対応するダウンロードパラメータ → WeChatネイティブの非同期ダウンロード
  → 元の画像ペイロード

元の画像ペイロード
  → ファイル全体の検証と保存
  → 必要な画像形式のデコード
  → 人による閲覧または後続ツールでの利用
```

各層の入力・処理・出力を対応付けて初めて、どのキャッシュを復元したのか、どのリソースバージョンを取得したのか、そして出力したPNGが元ファイルとどのような関係にあるのかを把握できます。現時点での実装と検証範囲は、前述のバージョンおよび対応済みの通常リソース経路に限定されており、その他のビジネスタイプやビルドについては別途確認が必要です。

## 自分で再現する

以下のPythonコードを`reproduce.py`として保存し、合成テスト画像を同じディレクトリにダウンロードします。Python 3とOpenSSLが必要です。この実験では、現在のディレクトリ内にあるテストファイルの読み書きだけを行い、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
```

PNGの元バイト列を固定してダウンロードするのは、ファイル長、ダイジェスト、および本文中の計算結果を一致させるためです。スクリプトは最初にテスト画像を検証し、次に`synthetic-v2.dat`と`recovered.png`を生成し、最後に復元結果が入力とバイト単位で完全に一致することをアサートします。

```python
"""観測されたローカルDATレイアウトの合成デモ。WeChatへのアクセスは行わない。"""
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', 'テスト画像のバイト列が一致しません'
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)
# 暗号文の長さを仮定せず、観測されたフィールドからデコードする。
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':'自己生成したffmpeg testsrc2 PNG。観測されたレイアウトをOpenSSLで再現したものであり、WeChatの書き込み処理をトレースしたものではない。'}
(p/'demo.json').write_text(json.dumps(info,ensure_ascii=False,indent=2)+'\n')
print(json.dumps(info,ensure_ascii=False))
```
