本文へスキップ
AZ Tools

Python .pyc バイトコード解析

.pyc は CPython がソースの隣の __pycache__ に書くコンパイル結果で、構造は二つの部分だけです。先頭16バイトはヘッダーです。4バイトのマジックナンバーがこのファイルを書いた CPython のリリースを正確に指し(いま入っている Python ではなく、コンパイルした側です)、続いてフラグワードが来ます。その0ビット目が無効化の方式を決めます。0 なら次の8バイトはソースの更新時刻とサイズで、どちらかがディスク上の .py と食い違った瞬間にキャッシュは捨てられます。1 なら PEP 552 の方式で、その8バイトはソースのハッシュになり、1ビット目は import のたびに検証するか、それとも無条件に信用するかを表します。再現可能なビルドが欲しいのは後者です。ヘッダーの後ろには marshal で直列化されたコードオブジェクトが一つあり、モジュール内の関数・ラムダ・内包表記・クラス本体はすべてその定数の中に別のコードオブジェクトとして入っています。ここの木が何段も深くなるのはそのためです。それぞれについて、参照する名前、ローカル・セル・自由変数、引数の数、スタックサイズ、解読したフラグビット、repr() が出すとおりの定数、そして EXTENDED_ARG を後続命令に畳み込み、ジャンプを絶対オフセットに解決した逆アセンブルを表示します。オペコード番号はリリースごとに変わるため、マジックナンバーから表を選び、どの版の表かを画面に出します。3.7 から 3.13 に対応し、それ以外のファイルはヘッダーだけを読み、誤った表で解釈せずに本体を残します。ソースを返してはくれません。逆コンパイラではないので、名前・ドキュメント文字列・定数はそのまま残る一方、見えているのは書いたループではなくバイトコードで、コメントや書式はこのファイルができる前に失われています。行番号テーブルと 3.11 以降の例外テーブルは読み飛ばすだけで解析しません。

使い方

  1. __pycache__ の .pyc をドロップします。名前はふつう module.cpython-311.pyc のような形です。
  2. まず「書き込んだ版」を見ます。このファイルをコンパイルした CPython のリリースで、マジックナンバーが違うインタプリタはこのキャッシュを無視してソースから作り直します。
  3. 無効化方式を確認します。タイムスタンプ方式はソースの更新時刻とサイズを、ハッシュ方式はソースのハッシュを持ち、検証なしのほうは以後ソースと比較されません。
  4. 木からコードオブジェクトを選びます。先頭がモジュールで、関数・クラス本体・ラムダ・(3.12 より前の)内包表記がそれぞれ定義された場所の下に並びます。
  5. 選んだオブジェクトの逆アセンブルを読みます。解決結果の列が名前・ローカル変数・定数を展開し、>> はジャンプの着地点を示します。

よくある質問

ソースを直したのに古いコードが動き続けます。なぜですか?
タイムスタンプ方式の .pyc はソースの更新時刻を秒単位、サイズをバイト単位で記録し、両方が一致する限りキャッシュを再利用します。これを破る状況が二つあります。記録された時刻と同じ秒の中で編集し、長さも変わらなければ(生成ファイルの一文字だけ変えた場合など)違いが見えず、古いキャッシュが使われます。またタイムスタンプを保つツールでファイルを復元したり、時刻を巻き戻すブランチをチェックアウトすると、もう対応していないソースより新しい .pyc が残ることがあります。__pycache__ を削除すれば両方とも直り、-B や PYTHONDONTWRITEBYTECODE を使えばそもそも書かれません。
ハッシュ方式の「検証あり」と「検証なし」は何が違いますか?
PEP 552 はタイムスタンプの代わりにソースの8バイトハッシュを入れ、ビルドが更新時刻に依存しないようにしました。同じソースならどのマシンでもバイト単位で同じ .pyc になり、再現可能なビルドが必要とする性質です。フラグの1ビット目が挙動を決めます。検証ありは import のたびにソースをハッシュして違えば作り直し、検証なしはソースをまったく見ません。後者は一度に構築されるコンテナイメージのように Python の外側で鮮度が保証される場合のためのもので、それ以外では罠になります。手で作り直すまで、ソースを直しても何も起きないからです。
.pyc から元のソースを取り戻せますか?
このツールでは戻せませんし、どのツールでも完全には戻せません。コンパイラは実行に必要なものをすべて残します。あらゆる名前と定数、ドキュメント文字列、引数名、各関数の開始行まで残るので、.pyc は思っているよりずっと多くを漏らし、難読化にはなりません。逆に本文そのものは残りません。コメント・空行・書式や式の正確な形は消えます。uncompyle6 や decompyle3 のような逆コンパイラは命令列からそれらしいソースを復元しますが、言語に数年遅れ、3.9 あたりでおおむね止まっています。新しいコンパイラが制御フローを組み替え、一対一で戻せなくなったためです。
同じソースなのに Python の版が違うとバイトコードが全く違うのはなぜですか?
命令セットは CPython が自由に書き換える実装詳細だからです。3.11 はインラインキャッシュを導入して直前の命令に属する実バイトを命令列に入れ、RESUME や MAKE_CELL といったオペコードにフレーム処理を移しました。3.12 はリスト内包と集合内包をインライン化して独立したコードオブジェクトでなくし、3.13 はほぼ全体を番号付け直しました。マジックナンバーがあるのはまさにこのためで、.pyc はそれを書いたリリースでのみ有効、ほかのインタプリタは存在しないものとして扱います。
オフセットが2より大きく飛ぶのはなぜで、EXTENDED_ARG とは何ですか?
命令はオペコード1バイトと引数1バイトの常に2バイトなので、255 を超える引数は手前の EXTENDED_ARG 命令が8ビットずつ足して作ります。ここでは dis と同じく後続命令へ畳み込むため、一行に数千の引数が見えることがあります。3.11 以降は直前の命令に属し実行時の特殊化状態を保持するインラインキャッシュ枠も命令列に含まれます。これは列挙せず読み飛ばすので、オフセットは2より大きく進みます。ディスク上の .pyc ではその枠は常にゼロです。特殊化はメモリ上でのみ起こり、速くなったオペコードがファイルに書かれることはありません。
セル変数と自由変数とは何で、なぜ同じ名前が二つの一覧に出るのですか?
クロージャでは変数がそれを作ったフレームより長く生きる必要があるため、コンパイラはセルに入れます。定義する関数はその名前をセル変数に、それを読む入れ子の関数は同じ名前を自由変数に並べます。3.11 からは両者が種類バイト付きの一つの配列に入るので、入れ子の関数に捕まえられた引数はローカルとしてもセル変数としても現れます。実際に両方であり、その関数は引数をセルへ移す MAKE_CELL 命令から始まります。

関連ツール

Python pickle インスペクター

.pkl ファイルをブラウザで逆アセンブル。全オペコード、メモ、復元された値、そして読み込むとコードが走るかどうかまで。

開発 0 0

Java クラスファイル解析ツール

コンパイル済みの .class ファイルをブラウザーで解析します。クラスファイルバージョンと Java リリース、アクセスフラグ、定数プール、フィールド、メソッド、参照クラスを表示します。

ファイル 0 0

NumPy .npy / .npz インスペクター

.npy・.npz のヘッダーをブラウザで読み取ります。dtype、shape、バイト順、フィールド配置、先頭の値まで。NumPy もアップロードも不要です。

開発 0 0

ELF バイナリ検査ツール

Linux の実行ファイル・.so・.o をブラウザーで開きます。アーキテクチャ、必要な共有ライブラリ、ビルド ID、PIE・NX・RELRO の有無まで。

ファイル 0 0

ファイルタイプ検出(マジックバイト)

ファイルをドロップしてマジックバイトから真のフォーマットを識別 — 拡張子や宣言された MIME タイプが嘘をついているファイルを捕捉。

ファイル 0 0

SQLite データベース検査ツール

.sqlite や .db をブラウザーで開いて構造を読み取ります。ページサイズ、エンコーディング、ジャーナルモード、各テーブルの実際の行数まで。

ファイル 0 0