PickleとPythonのPolyglotを作る
まえがき §
労働していると「時間は大きく割かないが調査と思考は要求するパソカタパズル」程度でしか脳味噌が動いてくれない。というわけでそのようなパズルとして今回はPickleでPolyglotを構成する。具体的にはpickle.loads()
に渡しても組み込み関数のeval
に渡してもシェルが起動する(os.system("/bin/sh")
が実行される)バイト列を構成する。
Prerequisite §
- Pickleの内部構造
- https://github.com/python/cpython/blob/main/Lib/pickle.pyの
_Unpickler
を雑に読むだけで良いです
- https://github.com/python/cpython/blob/main/Lib/pickle.pyの
1. 使えるオペコードを探す §
Pythonのコードを構成する文字より、Pickleのオペコードとして使える文字の方が圧倒的に少ないのでこっちで使えるものを探す。特にPickleがある種のスタックマシンのような仕組みをしている以上、先頭の命令でいきなりpopをするような命令は使えない1ため、これでも大きく絞ることが出来る。例えば、"2"
という文字/バイトはPythonでは単なる整数リテラルで、PickleではDUPというスタックトップを複製するという比較的大人しいオペコードなのだが、スタックに何も無い状態で実行するとエラーを吐くため使えない。
また、後に格闘することになるが、eval
にバイト列を入れてしまうと内部でdecode()
メソッドが呼ばれてUTF-8として有効なバイト列でないとUnicodeDecodeError
を吐かれてしまう。このことから、Protocol 2以降のascii-printableでないバイト列を使うのは(面倒なので)諦める。
次の絞り込みとして使えそうな条件は「Pythonのキーワードや文字/バイト列のprefixとして使えること」である。これを満たしていなくてもident = ...
のような形で新たに定義される変数の先頭の文字とすればPythonのコードとしては有効になるが、今回はexec
ではなくeval
で評価される形にしたかったので2、代入のような文で使われる表現に限定されるとなると使いづらい。
以上を踏まえてPickleでもPythonでもコードの先頭で使えそうなものをリストアップすると次のような候補が残る
Pickleのオペコード | Pickleでの用法 | Pythonでの用法 |
---|---|---|
U (SHORT_BINSTRING) | 256バイトまでの文字列をpush | Unicode文字列のprefix |
( (MARK) | スタック間の区切りとなるMARKをpush3 | 丸括弧の左側 |
B (BINBYTES) | 4バイト分読み込んで長さとみなし、その分を更に読み込んで得たバイト列をpush | バイト列のprefix |
T (BINSTRING) | 4バイト分読み込んで長さとみなし、その分を更に読み込んで得た文字列をpush | True の先頭 |
ここから更に下2つのB
とT
が使えないことを示す。Pickleではこれらのオペコードに続いて4バイトだけ読み込まれ、それを非負整数とした分だけバイト列や文字列として更に読み込むのだが、4バイトに相当する数値は32bitであり、GB級の長さだけ読み込むことに繋がる。
T
の方に関しては真偽値リテラルであるTrue
の一部として用いられる都合上、rue
(区切りとしての空白込)の部分が数値となるため1920296224バイト(だいたい1.8GB)のバイト列を後続に用意しなくてはならない。
B
の方に関してはPythonのコードでB"\x00\x00\x00 ... "
という形になり、一見これはリトルエンディアンで"\x00\x00\x00
に相当する4バイトの数値(34バイト)として扱えそうなのだが、evalに突っ込むとSyntaxError: source code string cannot contain null bytes
という無惨な結果が返ってくる。というわけで最低でも各バイトに\x01
は付与せざるを得ず、こちらも非常に大きなバイト列を要求される。
これで先頭の文字/バイトとして使えるものを2つに絞ることが出来た。これらで色々頑張った結果、次の節で示すようにU
の使い勝手が良かったのでこちらを採用する。
2. 構造を考える §
これまで見てきたPolyglotの例では、各言語のコメントアウトを駆使していたのでそれを元に構造を考える。PickleではコメントやNOPに相当する無意味なバイト列は特に存在しないが、b"." (STOP)
というバイト列に行き着くと直ちに停止する。仕様上それ以後に読み込んでいないバイト列があったとしても特に問題は無いため、PickleとPythonのコードが入り混じっている部分の後ろにSTOPオペコードを付与することで、後続の部分はPythonのコードとしての意味しか持たなくなる。
このように文字/バイト列の後半で自由にPythonのコードを書けるようになったことから、Pythonにおけるシェル起動のメイン処理は後半に記述し、前半でPickleにおけるシェル起動の処理を記述するという発想が当たり前のように出てくる。ここで活きるのが先程選んだU
で、Pythonコードにおいてはただの文字列のprefixに過ぎないため、引用符の内部に書かれたコードは全部文字列リテラルの一部として扱われ特に意味を持たない。
PickleにおいてもU
は後続から1バイトをサイズとして読み込んでその分だけ後続から文字列を読み込むという処理であるため、B
やT
で発生したコードがクソ長くなるという問題は無くなる。PythonではU"..."
のように引用符が続くため、"
か'
の違いはあるが、これに相当するだけのバイト列を読み込んでpushするという操作になる。
そして、読み込んだ後のバイト列からまたPickleのオペコードが実行されるため、ここにシェルを起動するコードを入れておけばPickleでもシェルが起動する。
以上より、今回のPolyglotは次のような構造になる。
U" <padding> <pickle rce code>."<python rce code>
<python rce code>
の部分は文字列リテラルに続いて実行される形ならなんでも良いのでand __import__("os").system("/bin/sh")
を入れた4。
3. UTF-8として問題ない形にする §
これでPythonのRCE用のコードは完成したのでPickleの方を考える。前述したように、Python側で実行する際に呼ばれる.decode()
で文字コード関連のエラーを出したくないので古いプロトコルのオペコードを使うことになるのだが、os.system
をスタックに積む際に問題が起こる。
古いプロトコルではos.system
をスタックに積むためにGLOBAL
オペコード(c
)を用いるが、これはc <module> \n <name> \n
(見易さのために空白で区切っているが本当は存在しない)という改行を含む形になる。しかし、eval
で実行する際に改行が含まれていると引用符の中にPickleのバイトコードを収めているという構造が破綻してPythonの文法的にも問題がある。よって、ここばかりは新しいプロトコルで似た動作をするSTACK_GLOBAL
(\x93
) を使う必要がある。
単にこれだけを放り込んでしまうとeval
時にUnicodeDecodeError
が発生するため、UTF-8として問題無い形でb"\x93"
を使う方法が必要になる。最も簡単な方法として("\x93").encode()
を実行するとb"\xc2\x93"
になるため、部分的なPickleコードとしてb"\xc2\x93"
を持つようなコードを考える。
b"\xc2"
はPickleのオペコードにならないので、何かのオペコードの引数として与える必要がある。STACK_GLOBAL
(\x93
) は予めスタックに積んでおいた文字列を必要とするため、このオペコードがスタックに何らかの影響を及ぼすことは避けたい。該当するオペコードを探すと、1バイト読み込んだ値をインデックスとして、スタックトップをメモに送る(送ってスタックからpopするようなことはしない)BINPUT (q)
が見つかる。よって、b"q\xc2\x93"
というバイト列は"q\x93"
という文字列にデコードされ、Pickleバイトコードの動作としては、メモのインデックス0xc2にスタックトップを送って、STACK_GLOBAL
を実行するという処理になる。
ところで、先日行われたDiceCTF 2024 - Unipickleという問題がこの節と似たような問題設定なので、興味がある人は問題リンクから是非解いてみてください。
4. (おまけ) 短縮 §
Pickleにおいて、先頭でU
を使って無駄な文字列をpushしているので、これをコマンド文字列("/bin/sh # ..."
)としてメモに送り込み、使う時にBINGET
で再度pushする。
Code §
import pickle
# RCE
cmd = "/bin/sh"
pad = f"{cmd} #"
pad += "X" * (ord('"') - (len(cmd) + 2))
# must be unicode-safe ("\x93" becomes b"\xc2\x93" by decode() so I sanitize b"\xc2" with BINPUT)
pickle_rce = "qqU\x02osU\x06systemq\x93(hqtR."
python_rce = f'and __import__("os").system("{cmd}")'
b4 = f'U"{pad}{pickle_rce}"{python_rce}'
print(b4)
b4 = b4.encode()
pickle.loads(b4) # shell 1
eval(b4) # shell 2
ちなみに、pickletools.dis
に放り込むとスタックを消費し尽くしてないとかいう理由でキレられる。pickle_rce
変数の先頭をqqU
からqq0U
にすればメモに放り込んで用済みになったコマンド文字列をpopしてくれるので、それでちゃんと逆アセンブルされる。実際の結果は次の通り。
0: U SHORT_BINSTRING '/bin/sh #XXXXXXXXXXXXXXXXXXXXXXXXX'
36: q BINPUT 113
38: 0 POP
39: U SHORT_BINSTRING 'os'
43: U SHORT_BINSTRING 'system'
51: q BINPUT 194
53: \x93 STACK_GLOBAL
54: ( MARK
55: h BINGET 113
57: t TUPLE (MARK at 54)
58: R REDUCE
59: . STOP
highest protocol among opcodes = 4
(引き続き) 募集中 §
もうPickleはネタ切れ気味です。
- Pickleで変なことをやるネタ
- これより短いPolyglot
- これより面白いPolyglot
POPが呼ばれた際にチェックを行う_pickle.cの該当部分。pickle.pyの方はスタックがリストなのでおそらくIndexErrorを吐く。
結果としてRCEという同一の「機能」のようなものを実現しているが、これに取り組み始めた最初は同一の「評価結果」を返すようなものから始めていたという背景に因る
厳密にはスタックをmetastackと呼ばれる領域に退避して新しいスタックを構成するような実装になっており、"push special markobject on stack"という説明とは異なる
or __import__...
にすると、先頭の文字列がTrue
に評価されて肝心の後続が実行されない