Pythonでは、pickle(英:漬け物)という仕組みを利用することで、オブジェクトをシリアライズ化できます。これにより、構造をもつデータの転送や、永続化——データを生成したプログラムが終了してもそのデータを存続させること——が可能となります。
pickleを利用するよう自分で設計することもあるでしょう。あるいは、ライブラリのマニュアルにpickleを利用するよう記載されていることもあるでしょう。例えば、scikit-learnでは、pickleか、joblib拡張版のpickleを利用してモデルを永続化するよう記載があります。機械学習つながりで言えば、書籍の付録として学習済みの重みがpickleで提供されることもあるでしょう。
pickleは、その利便性と、公式ドキュメントやPythonチュートリアルにおける、物騒な警告で有名です。
- 警告:pickle モジュールはエラーや不正に生成されたデータに対して安全ではありません。信頼できない、あるいは認証されていないソースから受け取ったデータを非 pickle 化してはいけません。
- pickle は…デフォルトでは安全でなく、信頼できない送信元から送られてきた、スキルのある攻撃者によって生成された pickle データをデシリアライズすると、攻撃者により任意のコードが実行されてしまいます。
デシリアライズに伴うリスクは、例えばJavaにもあるわけですが、Pythonの場合、具体的にどこがどう危険なのでしょう。英語文献では、pickleの仕組み、リスクについて説明するものが存在します。
- 仕組み
- リスク
他方、日本語で説明している文章をあまり見かけないので、解説を試みます。仕組みとリスクを理解したうえで、美味しい漬け物を頂きましょう。
pickleの使い方
こんな感じで使います。ちなみにGuido van Rossumは、Pythonパパ、すなわち、Pythonの作者です。
import pickle
class Person:
def __init__(self, name):
self.name = name
p1 = Person('Guido van Rossum')
with(open('bdfl.pickle', 'wb')) as f:
pickle.dump(p1, f, protocol=0)
with(open('bdfl.pickle', 'rb')) as f:
p2 = pickle.load(f)
print(p2.name)
出力結果:Guido van Rossum
dumpすることで、パパがカレントディレクトリのファイルに書き込まれます。loadすることで、パパがカレントディレクトリのファイルから読み込まれます。
Python3ではファイルopen時のb指定が必須です。2と3で文字列まわりの扱いが変わったためでしょう。またdumpでprotocol省略時のデフォルトは、Python2では0、Python3ではデフォルトプロトコルとなります。load時のプロトコルは自動判定されます。
pickleの仕組み
以下、pickletools.pyよりの翻訳です。
私たちがpickleと呼んでいるものは、仮想pickleマシン(正確にはunpickleマシン)のためのプログラムです。それは、オペコードの列で、仮想pickleマシンによって解釈・実行されます。仮想pickleマシンはとてもシンプルです。ループや判定、条件分岐、数値計算、関数呼び出しはありません。オペコードは一回ずつ、最初から最後へと、STOP命令に到達するまで実行されます。仮想pickleマシンには2つのデータ領域があります。スタックとメモです。
そう、記事のタイトルのとおり、pickleは仮想マシン(のためのプログラム)なのです。
以下、pickleが解釈・実行されるプロセスを見ていきましょう。簡単のため、プロトコルは0、さらに、pickletoolsを使って最適化されたpickleを取り上げます。例題としては、Pythonパパに再度ご登場願いましょう。
補足:自分で調べたい人のために
- オリジナルのpickleソースを取得します。オペコードに対応するメソッドをデコレートし、スタック(stack、metastack)やメモの状態をprintするようにします。そのような関数を作るということです。さらに、Cで最適化されたpickleのインポートをtryしているので、インポートしないようにします。
- 以下のようなコードを実行します。
import my_pickle as pickle
import pickletools
class Person:
def __init__(self, name):
self.name = name
p1 = Person('Guido van Rossum')
pickled = pickle.dumps(p1, protocol=0)
pickled = pickletools.optimize(pickled)
print(str(pickled)[2:-2].replace('\\n', '\n'))
pickletools.dis(pickled)
p2 = pickle.loads(pickled)
生成されたpickle
ccopy_reg
_reconstructor
(c__main__
Person
c__builtin__
object
NtR(dVname
VGuido van Rossum
sb.
プロトコル0、1では、ASCIIの印字可能文字でpickleが出力されます。1行目1文字目のcはオペコード(命令)で、1行目の残り(copy_reg)と2行目(_reconstructor)は、オペコードcに対するオペランドとなります。
うん、この漬け物は食欲をそそりませんね。逆アセンブルしちゃいましょう。
pickletoolsによる逆アセンブル結果
0: c GLOBAL 'copy_reg _reconstructor'
25: ( MARK
26: c GLOBAL '__main__ Person'
43: c GLOBAL '__builtin__ object'
63: N NONE
64: t TUPLE (MARK at 25)
65: R REDUCE
66: ( MARK
67: d DICT (MARK at 66)
68: V UNICODE 'name'
74: V UNICODE 'Guido van Rossum'
92: s SETITEM
93: b BUILD
94: . STOP
オペコードとオペランドを分離してくれました。さらに、オペコードにいい感じのニーモニックを付けてくれました。少しは食が進みそうです。
このpickleは14ステップからなるようです。各ニーモニックの意味は、pickleのソースコード内の説明と実装を読めばわかります。
- GLOBAL:2つの引数(modname, name)をとる。modname.nameを探して、スタックにプッシュする。
- MARK:マークオブジェクトをスタックにプッシュする。
- NONE:Noneをスタックにプッシュする。
- TUPLE:スタック内の要素をマークオブジェクトまでポップし、プッシュされた順でタプルを構築する。構築したタプルをスタックにプッシュする。
- REDUCE:スタックからポップしたタプルを引数とし、スタックの最上段にある関数に与えて実行する。関数を実行結果で置き換える。
- DICT:スタック内の要素をマークオブジェクトまでポップし、プッシュされた順(キー、値、キー、値…)で辞書を構築する。構築した辞書をスタックにプッシュする。
- UNICODE:unicode文字列をスタックにプッシュする。
- SETITEM:スタックから2つポップし、値、キーとする。スタックの最上段にある辞書に、キーと値のペアを加える。
- BUILD:スタックからポップしたstateを元に、スタック最上段のインスタンスを更新する。インスタンスに__setstate__が定義されていればそれを呼ぶことにより、定義されていなければ__dict__を更新するかsetattrすることにより、インスタンスに属性を追加する。
- STOP:スタックから要素をポップし、処理を終了する。
一口ずつ味わってみることにしましょう。この例では最適化の結果、メモを使わなくなったので、1ステップごとに、スタックの状態がどう変わるか見ていきましょう。
ステップを追う
0: c GLOBAL 'copy_reg _reconstructor'
- copyregモジュールの_reconstructor関数をスタックにプッシュする。
function _reconstructor |
25: ( MARK
- マークオブジェクトをスタックにプッシュする。
MARK |
function _reconstructor |
26: c GLOBAL '__main__ Person'
- Personクラスをスタックにプッシュする。
class 'Person' |
MARK |
function _reconstructor |
43: c GLOBAL '__builtin__ object'
- ビルトインのobjectクラスをスタックにプッシュする。
class 'object' |
class 'Person' |
MARK |
function _reconstructor |
63: N NONE
- Noneをスタックにプッシュする。
None |
class 'object' |
class 'Person' |
MARK |
function _reconstructor |
64: t TUPLE (MARK at 25)
- スタック内の要素をマークオブジェクトまでポップし、プッシュされた順でタプルを構築する。構築したタプルをスタックにプッシュする。
(class 'Person', class 'object', None) |
function _reconstructor |
65: R REDUCE
- スタックからポップしたタプルを引数とし、スタックの最上段にある関数に与えて実行する。関数を実行結果で置き換える。
Person object |
66: ( MARK
- マークオブジェクトをスタックにプッシュする。
MARK |
Person object |
67: d DICT (MARK at 66)
- スタック内の要素をマークオブジェクトまでポップし、プッシュされた順(キー、値、キー、値…)で辞書を構築する。構築した辞書をスタックにプッシュする。
{} |
Person object |
68: V UNICODE 'name'
- unicode文字列をスタックにプッシュする。
'name' |
{} |
Person object |
74: V UNICODE 'Guido van Rossum'
- unicode文字列をスタックにプッシュする。
'Guido van Rossum' |
'name' |
{} |
Person object |
92: s SETITEM
- スタックから2つポップし、値、キーとする。スタックの最上段にある辞書に、キーと値のペアを加える。
{'name': 'Guido van Rossum'} |
Person object |
93: b BUILD
- スタックからポップしたstateを元に、スタック最上段のインスタンスを更新する。インスタンスに__setstate__が定義されていればそれを呼ぶことにより、定義されていなければ__dict__を更新するかsetattrすることにより、インスタンスに属性を追加する。
Person object |
94: . STOP
- スタックから要素をポップし、処理を終了する。
要点をコードで表すと以下のとおりです。
class Person:
pass
from copyreg import _reconstructor
p = _reconstructor(Person, object, None)
p.__dict__['name'] = 'Guido van Rossum'
漬け込まれていたパパが、新鮮なパパになって帰ってきました。
pickleのリスク
オペコード(ニーモニック)のうち、GLOBALとREDUCEが、なかなかに凶悪です。
試しに、以下のpickleをテキストエディタで作成し、「hello_world.pickle」という名前を付けて、カレントディレクトリに保存しましょう。
cos
system
(Vecho hello, world >> hello_world.txt
tR.
pickleの内容は、「echo hello, world >> hello_world.txt」の実行をシェルに指示するものです。このpickleをloadすると、カレントディレクトリに「hello_world.txt」という名前のファイルが作成・更新されます。カレントディレクトリに「hello_world.txt」という名前のファイルが既に存在していて、重要なものであるなら、退避しておいてください。カレントディレクトリは、以下のコードを実行することで取得できます。
import os
print(os.getcwd())
それでは、pickleをloadしてみましょう。
import pickle
with(open('hello_world.pickle', 'rb')) as f:
p = pickle.load(f)
カレントディレクトリに「hello_world.txt」が作成・更新されたでしょうか。
「hello_world.pickle」の逆アセンブル結果は以下のとおりです。
0: c GLOBAL 'os system'
11: ( MARK
12: V UNICODE 'echo hello, world >> hello_world.txt'
50: t TUPLE (MARK at 11)
51: R REDUCE
52: . STOP
これは、以下コードと等価です。
from os import system
system('echo hello, world >> hello_world.txt')
以上のように、pickleをloadさせることにより、シェルコードを実行させることが可能です。
Pythonのコードを実行させることもできます。例えば、以下pickleは、1-15までのFizzBuzzをprintします。
ctypes
FunctionType
(cmarshal
loads
(cbase64
b64decode
(S'4wAAAAAAAAAAAQAAAAQAAABTAAAAc2YAAAB4YHQAZAFkAoMCRABdUn0AfABkAxYAZARrAnImdAFkBYMBAQBxDHwAZAYWAGQEawJyPHQBZAeDAQEAcQx8AGQIFgBkBGsCclJ0AWQJgwEBAHEMdAF0AnwAgwGDAQEAcQxXAGQAUwApCk7pAQAAAOkQAAAA6Q8AAADpAAAAAFoIRml6ekJ1enrpAwAAAFoERml6eukFAAAAWgRCdXp6KQPaBXJhbmdl2gVwcmludNoDc3RyKQHaAWmpAHILAAAA+lovVXNlcnMvdGFudWtpL3ByaXZhdGUvcHl0aG9uL3dvcmtzcGFjZS9pbnRyb2R1Y3Rpb24vZGVsaWNpb3VzLXBpY2tsZXMvZGVsaWNpb3VzX3BpY2tsZXMucHnaA2ZvbzQAAABzEAAAAAABEAEMAQoBDAEKAQwBCgI='
tRtRc__builtin__
globals
(tRS''
tR(tR.
これは、以下コードと等価です。
from types import FunctionType
from marshal import loads
from base64 import b64decode
decoded = b64decode('4wAAAAAAAAAAAQAAAAQAAABTAAAAc2YAAAB4YHQAZAFkAoMCRABdUn0AfABkAxYAZARrAnImdAFkBYMBAQBxDHwAZAYWAGQEawJyPHQBZAeDAQEAcQx8AGQIFgBkBGsCclJ0AWQJgwEBAHEMdAF0AnwAgwGDAQEAcQxXAGQAUwApCk7pAQAAAOkQAAAA6Q8AAADpAAAAAFoIRml6ekJ1enrpAwAAAFoERml6eukFAAAAWgRCdXp6KQPaBXJhbmdl2gVwcmludNoDc3RyKQHaAWmpAHILAAAA+lovVXNlcnMvdGFudWtpL3ByaXZhdGUvcHl0aG9uL3dvcmtzcGFjZS9pbnRyb2R1Y3Rpb24vZGVsaWNpb3VzLXBpY2tsZXMvZGVsaWNpb3VzX3BpY2tsZXMucHnaA2ZvbzQAAABzEAAAAAABEAEMAQoBDAEKAQwBCgI=')
loaded = loads(decoded)
func = FunctionType(loaded, globals(), '')
func()
リスクシナリオと対策
pickleの存在する場所が、サーバ、ネットワーク、クライアントの三箇所だとして、以下のリスクシナリオが考えられます。
- サーバに置かれているpickleが、そもそも悪意のあるものだった
- サーバに脆弱性があり、pickleが悪意のあるものに置き換えられた
- ネットワークが暗号化されておらず、pickleが悪意のあるものに置き換えられた
- クライアントに脆弱性があり、外部or内部の人間によって、pickleが悪意のあるものに置き換えられた
対策としては、pickleを、信頼できるソースから、信頼できる方法で受け取って、信頼できる方法で管理する、ということになるでしょう。
公式ドキュメントの重要性
scikit-learnのような、ちゃんとしたライブラリでは、pickleか、joblib拡張版のpickleを利用してモデルを永続化するよう記載すると共に、セキュリティ上のリスクについても明記してあります。Qiitaの記事やサードパーティの書籍も有用ですが、一度は公式ドキュメントに目を通すことが重要だと思います。
まとめ
英語では、困っている、苦境にあることを、I'm in a pickleと言うそうです。信頼できないpickleをloadして、文字どおり「I'm in a pickle」なんてことにならないよう、十分注意して、美味しい漬け物を楽しみましょう。