【Python】pickle（漬け物）は仮想マシンだった——仕組みとリスク

Pythonでは、pickle（英：漬け物）という仕組みを利用することで、オブジェクトをシリアライズ化できます。これにより、構造をもつデータの転送や、永続化——データを生成したプログラムが終了してもそのデータを存続させること——が可能となります。

pickleを利用するよう自分で設計することもあるでしょう。あるいは、ライブラリのマニュアルにpickleを利用するよう記載されていることもあるでしょう。例えば、[scikit-learn](https://ja.wikipedia.org/wiki/Scikit-learn)では、pickleか、joblib拡張版のpickleを利用してモデルを永続化するよう[記載](http://scikit-learn.org/stable/modules/model_persistence.html)があります。機械学習つながりで言えば、書籍の付録として学習済みの重みがpickleで提供されることもあるでしょう。

pickleは、その利便性と、[公式ドキュメント](https://docs.python.jp/3/library/pickle.html)や[Pythonチュートリアル](https://docs.python.jp/3/tutorial/inputoutput.html#saving-structured-data-with-json)における、物騒な警告で有名です。

- 警告：pickle モジュールはエラーや不正に生成されたデータに対して安全ではありません。信頼できない、あるいは認証されていないソースから受け取ったデータを非 pickle 化してはいけません。
- pickle は…デフォルトでは安全でなく、信頼できない送信元から送られてきた、スキルのある攻撃者によって生成された pickle データをデシリアライズすると、攻撃者により任意のコードが実行されてしまいます。

デシリアライズに伴うリスクは、例えば[Java](https://codezine.jp/article/detail/9175)にもあるわけですが、Pythonの場合、具体的にどこがどう危険なのでしょう。英語文献では、pickleの仕組み、リスクについて説明するものが存在します。

- 仕組み
  - [pickletools.py——pickleモジュールの実行可能なドキュメンテーション——のコメント](https://github.com/python/cpython/blob/master/Lib/pickletools.py)
- リスク
  - [pickleの悪用](https://blog.nelhage.com/2011/03/exploiting-pickle/)
  - [酸っぱい漬け物](https://media.blackhat.com/bh-us-11/Slaviero/BH_US_11_Slaviero_Sour_Pickles_WP.pdf)
  - [pickleによる任意のコード実行](https://checkoway.net/musings/pickle/)
  - [危険な漬け物](https://intoli.com/blog/dangerous-pickles/)
  - [腐った漬け物](https://medium.com/poka-techblog/rotten-pickles-a-quick-introduction-to-offensive-serialization-techniques-83fd4dd36edb)

他方、日本語で説明している文章をあまり見かけないので、解説を試みます。仕組みとリスクを理解したうえで、美味しい漬け物を頂きましょう。

## pickleの使い方

こんな感じで使います。ちなみに[Guido van Rossum](https://gvanrossum.github.io/)は、Pythonパパ、すなわち、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で[文字列まわりの扱いが変わった](https://qiita.com/CS_Toku/items/353fd4b0fd9ed17dc152#%E3%83%A6%E3%83%8B%E3%82%B3%E3%83%BC%E3%83%89%E3%81%A88bit%E3%81%AE%E4%BB%A3%E3%82%8F%E3%82%8A%E3%81%AB%E3%83%86%E3%82%AD%E3%82%B9%E3%83%88%E3%81%A8%E3%83%87%E3%83%BC%E3%82%BF%E3%81%B8)ためでしょう。またdumpでprotocol省略時のデフォルトは、Python2では0、Python3では[デフォルトプロトコル](https://docs.python.jp/3/library/pickle.html#pickle.DEFAULT_PROTOCOL)となります。load時のプロトコルは自動判定されます。

## pickleの仕組み

以下、[pickletools.py](https://github.com/python/cpython/blob/master/Lib/pickletools.py)よりの翻訳です。

> 私たちがpickleと呼んでいるものは、仮想pickleマシン（正確にはunpickleマシン）のためのプログラムです。それは、[オペコード](https://ja.wikipedia.org/wiki/%E3%82%AA%E3%83%9A%E3%82%B3%E3%83%BC%E3%83%89)の列で、仮想pickleマシンによって解釈・実行されます。仮想pickleマシンはとてもシンプルです。ループや判定、条件分岐、数値計算、関数呼び出しはありません。オペコードは一回ずつ、最初から最後へと、STOP命令に到達するまで実行されます。仮想pickleマシンには2つのデータ領域があります。スタックとメモです。

そう、記事のタイトルのとおり、pickleは仮想マシン（のためのプログラム）なのです。

以下、pickleが解釈・実行されるプロセスを見ていきましょう。簡単のため、プロトコルは0、さらに、pickletoolsを使って最適化されたpickleを取り上げます。例題としては、Pythonパパに再度ご登場願いましょう。

### 補足：自分で調べたい人のために

- [オリジナル](https://github.com/python/cpython/blob/master/Lib/pickle.py)のpickleソースを取得します。オペコードに対応するメソッドをデコレートし、スタック（stack、metastack）やメモの状態をprintするようにします。そのような関数を作るということです。さらに、[Cで最適化されたpickle](https://github.com/python/cpython/blob/master/Modules/_pickle.c)のインポートをtryしているので、インポートしないようにします。
- 以下のようなコードを実行します。

```python
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に対する[オペランド](https://ja.wikipedia.org/wiki/%E8%A2%AB%E6%BC%94%E7%AE%97%E5%AD%90)となります。

うん、この漬け物は食欲をそそりませんね。逆アセンブルしちゃいましょう。

## 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
```

オペコードとオペランドを分離してくれました。さらに、オペコードにいい感じの[ニーモニック](http://www.atmarkit.co.jp/icd/root/92/189003792.html)を付けてくれました。少しは食が進みそうです。

このpickleは14ステップからなるようです。各ニーモニックの意味は、[pickleのソースコード](https://github.com/python/cpython/blob/master/Lib/pickle.py)内の説明と実装を読めばわかります。

- 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
```

- スタックから要素をポップし、処理を終了する。

要点をコードで表すと以下のとおりです。

```python
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」という名前のファイルが既に存在していて、重要なものであるなら、退避しておいてください。カレントディレクトリは、以下のコードを実行することで取得できます。

```python
import os
print(os.getcwd())
```

それでは、pickleをloadしてみましょう。

```python
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
```

これは、以下コードと等価です。

```python
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.
```

これは、以下コードと等価です。

```Python
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](https://ja.wikipedia.org/wiki/Scikit-learn)のような、ちゃんとしたライブラリでは、pickleか、joblib拡張版のpickleを利用してモデルを永続化するよう記載すると共に、セキュリティ上のリスクについても[明記](http://scikit-learn.org/stable/modules/model_persistence.html#security-maintainability-limitations)してあります。Qiitaの記事やサードパーティの書籍も有用ですが、一度は公式ドキュメントに目を通すことが重要だと思います。

## まとめ

英語では、困っている、苦境にあることを、[I'm in a pickle](http://context.reverso.net/%E7%BF%BB%E8%A8%B3/%E8%8B%B1%E8%AA%9E-%E6%97%A5%E6%9C%AC%E8%AA%9E/a+pickle)と言うそうです。信頼できないpickleをloadして、文字どおり「I'm in a pickle」なんてことにならないよう、十分注意して、美味しい漬け物を楽しみましょう。