本文へスキップ
AZ Tools

Python pickle インスペクター

pickle は本当はデータ形式ではなく、小さなスタックマシン向けのプログラムであり、pickle.load はそれを実行します。値が現れる前に、オペコードは任意のモジュールをインポートし、任意の呼び出し先を呼び、任意のクラスを構築できます。だからこのツールは決してアンピクルしません。pickletools.dis と同じ読み方でバイト列をオペコード単位にたどり、オフセット、復号された引数、スタックへの影響を示したうえで、Python を呼ばずに組み立てられる部分だけを復元します。辞書、リスト、タプル、集合、文字列、バイト列、桁数無制限の整数、浮動小数点数、真偽値、None はそのまま戻り、インポートを要するものは推測せず明示的なプレースホルダーとして表示します。プロトコルバージョンは 2 通りで報告します。両者は食い違うことがあるからです。プロトコル 2 以降はバージョンを宣言する PROTO オペコードで始まりますが、0 と 1 は何も宣言しないため、ファイル内のオペコードが実際に要求する最大のプロトコルも算出し、実際より古いと称するファイルには警告を出します。メモの表は、どのオブジェクトが格納され再び取り出されたかを示します。共有参照と循環参照が見えるのはこの経路だけです。自分自身を含むリストとは、そのリストを構築している最中に格納されたキーを取り出した結果だからです。プロトコル 4 と 5 のストリームはフレーム化されており、フレーム表が各ブロックの開始位置を示します。最初に読む価値があるのはセキュリティ所見の欄です。読み込み時にコードを走らせる構文をすべて名指しします。インポートする名前を指す GLOBAL と STACK_GLOBAL、呼び出しそのものである REDUCE、クラス構築の INST・OBJ・NEWOBJ・NEWOBJ_EX、__setstate__ を呼ぶ BUILD、ローダー独自の persistent_load フックに渡す PERSID、そして名前すらファイルにない copyreg レジストリの EXT1/2/4 まで挙げ、ローダーが引く全てのモジュールと属性を並べて、os.system、subprocess、builtins.eval、posix.system のように公開された攻撃に登場するものに印を付けます。これらのオペコードが 1 つもない pickle はデータであり、あるものはプログラムです。いくら眺めても、信頼できない出所の pickle を読み込む行為が安全になるわけではありません。壊れたファイルは中途半端に読まず、名前を付けて報告します。切り詰められたストリーム、STOP の後に残ったバイト、つまり 1 つ目の後ろに隠れた 2 つ目の pickle、そして格納されたことのないメモキーの取り出しがそれぞれそのように示されます。

使い方

  1. .pkl や .pickle など pickle 化されたファイルをボックスにドロップします。アップロードもアンピクルもせず、逆アセンブルするだけです。
  2. まずセキュリティ所見を読みます。「データのみ」なら、インポートも呼び出しもできるオペコードが 1 つもないという意味です。インポート名が並ぶなら、信頼できないコードとして扱い読み込まないでください。
  3. 宣言されたプロトコルと必要なプロトコルを比べます。プロトコル 2 と宣言しながらプロトコル 4 のオペコードを使うファイルは、pickle.dumps ではなく手で組み立てられたものです。
  4. オペコードの表を眺めます。インデントは MARK の入れ子に従うのでコンテナがブロックとして見え、スタック列は各段階のあとに生きている値の個数を示します。
  5. メモの表を開いて共有オブジェクトを探します。再利用回数が 1 以上のキーは、同じオブジェクトが複数箇所に現れている印であり、循環参照も同じ書き方で表されます。

よくある質問

信頼できない pickle をここで開いても安全ですか。
安全です。ここではアンピクルを一切行わないからです。pickletools.dis とまったく同じようにオペコード単位で読み、GLOBAL や REDUCE に対して行うのはモジュール名と属性名を表示することだけです。インポートも呼び出しも起きず、復元はオペコードだけで作れる値で止まります。ファイルがブラウザの外に出ることもありません。File API で読み、ページ内で解析します。ただしこのツールがそのファイルを安全にするわけではありません。所見にインポート名が並んだなら、同じファイルに pickle.load を実行すればそのインポートは実際に行われます。
シリアライズ形式なのに、なぜコードが実行できるのですか。
任意のオブジェクトを保存するには復元する手段が要り、Python の答えが __reduce__ だからです。オブジェクト自身が「どの呼び出し先にどの引数を渡せば自分を作り直せるか」を申告し、ストリームにはインポートすべき名前と呼び出しが保存され、アンピクラーはその両方を実行します。この一つの仕組みのおかげで datetime も numpy 配列もあらゆる自作クラスも pickle 化でき、攻撃者は同じ仕組みで datetime.datetime の代わりに os.system と書くだけです。形式の中に両者を区別する印はありません。だからファイルが何をするかを知る方法はオペコードを読むことだけなのです。
メモとは何で、循環参照はどう現れますか。
メモはピクラーがすでに書き出したオブジェクトの表です。同じオブジェクトが再び現れると、2 回目からはメモからの取り出しになります。プロトコル 0 では PUT と GET、以降は BINPUT と BINGET、プロトコル 4 からはキー番号を書かずに暗黙に振る MEMOIZE がその役目です。これが同一性を保ちます。1 つのリストを指していた 2 つの名前は、読み込み後も1 つのリストを指します。循環参照はここから一歩進んだものです。コンテナは中身が書かれる前にメモへ格納されるので、中身がそのコンテナを参照し返せます。自分自身を含むリストが表現できるのはそのためで、このツールも無限に展開せず参照として示します。
6 つのプロトコルは実際に何が違うのですか。
プロトコル 0 は ASCII です。整数も浮動小数点数も文字列も改行で終わるテキストとして書かれるため、目でもほぼ読めます。プロトコル 1 はその大半に二進表現を加えます。プロトコル 2 は NEWOBJ による効率的なクラス構築、1 バイトのタプル、本物の真偽値を加えます。プロトコル 3 は Python 2 が表現できなかった bytes を加えます。プロトコル 4 はフレーム化を導入し、ストリームを長さ付きのブロックに切って読み手がまとめて取り込めるようにしたうえ、64 ビットのサイズ、STACK_GLOBAL、集合の直接対応、暗黙のメモキーを加えます。プロトコル 5 は帯域外バッファを加え、大きな配列がストリームの中ではなく脇を流れるようにします。NEXT_BUFFER の後ろにデータがないのはそのためです。
STOP の後にバイトが残っているのは何を意味しますか。
STOP は pickle 1 つを終わらせ、その後ろは次に来るものの領分です。1 つのファイルに複数の pickle を書くのはごく普通のやり方で、開いたファイルに pickle.dump を繰り返し、pickle.load を繰り返して読み戻します。ですから残りのバイトは、そのファイルが 1 個のオブジェクトではなくレコードの列だという意味であることが多いのです。同時に、無害な 1 つ目の後ろに 2 つ目のペイロードを隠す手口でもあります。load を一度しか呼ばないプログラムは最初のオブジェクトしか見ず、残りを覗かないからです。このツールは 1 つ目を逆アセンブルし、残りが何バイトかを報告します。続きを調べるには STOP のオフセットでファイルを切り、後半を別に開いてください。
numpy 配列や pandas の DataFrame の中身も見られますか。
値は見られません。そうしたオブジェクトは numpy や pandas を呼び出して初めて復元されるからです。ストリームには numpy.core.multiarray._reconstruct のような名前や pandas のクラスが書かれ、そこにバイト列が渡され、配列はその呼び出しが終わってから存在します。見えるのはファイルの輪郭です。インポートされる名前、抱えている生のバッファとそのサイズがわかり、たいていはそのファイルが名乗り通りかを判断するのに足ります。プロトコル 5 のファイルなら配列のデータがファイルの中にすらないこともあります。NEXT_BUFFER は、ペイロードが帯域外で渡され、呼び出し側が別途与える必要があるという意味です。

関連ツール