60
40

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-10-14

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」なんてことにならないよう、十分注意して、美味しい漬け物を楽しみましょう。

60
40
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
60
40

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?