本文へスキップ
郭立 (leeguoo)

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

画像エンコード、アカウント鍵の導出、AESとXORによる分割処理から、DATの逆変換と高画質リソースのダウンロードまで、同じ再現可能なファイルを使ってmacOS版WeChat 4.1.9の画像処理経路を解説します。

このページの目次
1.画像・エンコード・暗号化とは何か2.順方向から見る:1つのPNGがDATになるまで3.1. ローカルキャッシュ鍵を導出する4.2. ペイロードを前半部と末尾部に分割する5.3. 前半部をパディングし、ブロックごとにAES暗号化する6.4. 末尾部を1バイトずつXORする7.5. ファイルヘッダーを追加してV2 DATを構成する8.逆方向から見る:DATをどう復元するか9.1. レイアウトから境界を計算する10.2. 対象の鍵を導出して確認する11.3. AES復号後、パディングを検査して除去する12.4. 同じXOR値で末尾領域を復元する13.5. 完全なバイト列で復元の正しさを証明する14.高解像度のオリジナル画像をダウンロードする方法15.1. 同じメッセージから同じリソースの情報を選ぶ16.2. WeChatの構築・選択ロジックに従ってリクエストを作成する17.3. ネイティブ経路でリソースをダウンロードして処理する18.4. ペイロードを検証してから、表示方法を決定する19.2つの入口が、同一のペイロードへ合流する20.自分で再現する

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

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

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

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

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

記事きじの末尾まつびには、実行可能じっこうかのうな合成ごうせい実験じっけんを掲載けいさいしています。自作じさく画像と合成ごうせいアカウントを使用しようし、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を参照してください。

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

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

パディング規則の詳細は、RFC 5652の第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–507 08 56 32 08 07サンプル形式のシグネチャ。56 32はASCIIのV2
6–900 04 00 00頻繁に観測される値0x400。AES暗号文の長さとしては扱わない
10–137e 1f 00 00リトルエンディアンの末尾長。値は8,062
1401サンプルヘッダー内のバイト。画像ペイロードには含まれない

先頭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では、MD5のセキュリティ上の制限について説明されています。

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

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

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

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

リソースの選択リソース識別子フィールドバイト長フィールドその他の関連情報
独立した高解像度画像 original_hdcdnbigimgurlhdlength同じリソースのaeskey、md5
通常画質の完全画像 ordinary_fullcdnmidimgurllength同じリソースの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))

同じカテゴリの記事 · 解析とセキュリティ

2026年7月6日
16分で読めます
微信ローカルデータの二つの道——wechat-use と wechat-decrypt の原理レベルの比較(実測による訂正付き)
二つの微信復号ツールはよく比較されますが、比較の方向がずれています。本当の違いは機能の多さではなく、SQLCipher の key をどう取得するかにあります。この記事では両者を原理レベルまで分解します——SQLCipher の per-DB 鍵モデル、メモリから key を取り出す二つの仕組み(LLDB+entitlement vs sudo+mach_vm)——さらに実際に自分のマシンで走らせ、初版を書いたときの二つの誤判定もついでに訂正しました。
→
2026年7月3日
20分で読めます
Claudeのアカウント停止で見るべき3点:地域、IPタイプ、リクエスト指紋、そして自己チェック方法
最近のClaudeアカウント停止の波を分解すると、独立して検出できる3つの軸が重なったものです:地域(公式が唯一明言しているレッドライン)、IPタイプ(データセンター/VPN/プロキシ/住宅プロキシ)、そして中継利用時に日付の中へ隠されるステガノグラフィー透かし。この記事では各軸の検出メカニズム、自己チェック方法、回避方法を整理し、無料の検出ページとステータスバー用の小さなツールも紹介します。
→
2026年5月26日
6分で読めます
AI監査に向き合う:リバースエンジニアリングプロジェクトをどのように「合法化」できるか?
Claude Code と Gemini CLI を組み合わせ、逆コンパイルプロジェクトを扱う実践的なアプローチを検討する。所有権検証という課題に焦点を当て、自動サニタイズ処理による解決を取り上げる。
→
次の記事 →
chrome-useを5分で理解:どんなエージェントにも自分のChromeを使わせる

コメント

コメントは即時公開されますが、ポリシー違反時は非表示になる場合があります。

最大 1000 文字。