深入Python虚拟机:LOAD_CONST漏洞剖析
引言
一年前,我编写了一个Python脚本来利用Python虚拟机中的一个漏洞:目的是完全控制Python虚拟处理器,然后通过操作VM来执行原生代码。python27_abuse_vm_to_execute_x86_code.py脚本并不自解释,因此我相信只有少数人真正花时间理解了其背后的原理。本文旨在解释该漏洞、如何控制VM以及如何将漏洞转化为更有用的东西。这也是从底层视角了解Python虚拟机工作原理的绝佳机会:我们非常喜欢这样,对吧?
但在深入之前,我想澄清几点:
- 我并未发现这个漏洞,它相当古老且为Python开发者所知(以安全性换取性能),因此不要惊慌,这不是0day或新漏洞;但可能是一个很酷的CTF技巧。
- 显然,我知道我们也可以使用
ctypes模块“逃逸”虚拟机;但这是一个功能而非漏洞。此外,在Python的沙箱实现中,ctypes总是被“移除”。
另外,请记住我将专注于Windows上的Python 2.7.5 x86;但这显然适用于其他系统和架构,因此有兴趣的读者可以自行练习。
好了,让我们进入第一部分:这部分将重点介绍VM的基础知识和Python对象。
目录
- 引言
- Python虚拟处理器
- 引言
- 虚拟机
- 万物皆对象
- 调试会话:单步执行VM的艰难之路
- 漏洞
- 遍历PoC
- 在虚拟栈上推送攻击者控制的数据
- 游戏结束,LOAD_FUNCTION
- 结论与想法
Python虚拟处理器
引言
如您所知,Python是一种(非常酷的)解释型脚本语言,官方解释器的源代码可在此处获取:Python-2.7.6.tgz。该项目用C语言编写,非常可读;请下载源代码并阅读,您将学到很多东西。
现在,您编写的所有Python代码在某个时刻都会被编译成一些“字节码”:可以说这与您的C代码被编译成x86代码完全相同。但对我们来说,酷的是Python架构比x86简单得多。
以下是Python 2.7.5中所有可用操作码的部分列表:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
In [5]: len(opcode.opmap.keys())
Out[5]: 119
In [4]: opcode.opmap.keys()
Out[4]: [
'CALL_FUNCTION',
'DUP_TOP',
'INPLACE_FLOOR_DIVIDE',
'MAP_ADD',
'BINARY_XOR',
'END_FINALLY',
'RETURN_VALUE',
'POP_BLOCK',
'SETUP_LOOP',
'BUILD_SET',
'POP_TOP',
'EXTENDED_ARG',
'SETUP_FINALLY',
'INPLACE_TRUE_DIVIDE',
'CALL_FUNCTION_KW',
'INPLACE_AND',
'SETUP_EXCEPT',
'STORE_NAME',
'IMPORT_NAME',
'LOAD_GLOBAL',
'LOAD_NAME',
...
]
|
虚拟机
Python VM完全实现在PyEval_EvalFrameEx函数中,您可以在ceval.c文件中找到。该机器通过一个简单的循环构建,使用一堆switch-case处理操作码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
PyObject *
PyEval_EvalFrameEx(PyFrameObject *f, int throwflag)
{
//...
fast_next_opcode:
//...
/* Extract opcode and argument */
opcode = NEXTOP();
oparg = 0;
if (HAS_ARG(opcode))
oparg = NEXTARG();
//...
switch (opcode)
{
case NOP:
goto fast_next_opcode;
case LOAD_FAST:
x = GETLOCAL(oparg);
if (x != NULL) {
Py_INCREF(x);
PUSH(x);
goto fast_next_opcode;
}
format_exc_check_arg(PyExc_UnboundLocalError,
UNBOUNDLOCAL_ERROR_MSG,
PyTuple_GetItem(co->co_varnames, oparg));
break;
case LOAD_CONST:
x = GETITEM(consts, oparg);
Py_INCREF(x);
PUSH(x);
goto fast_next_opcode;
case STORE_FAST:
v = POP();
SETLOCAL(oparg, v);
goto fast_next_opcode;
//...
}
|
该机器还使用虚拟栈在不同操作码之间传递/返回对象。因此,它看起来非常像我们习惯处理的架构,没有什么异国情调。
万物皆对象
VM的第一条规则是它只处理Python对象。Python对象基本上由两部分组成:
第一部分是头部,该头部对所有对象都是必需的。定义如下:
1
2
3
4
5
6
7
8
|
#define PyObject_HEAD \
_PyObject_HEAD_EXTRA \
Py_ssize_t ob_refcnt; \
struct _typeobject *ob_type;
#define PyObject_VAR_HEAD \
PyObject_HEAD \
Py_ssize_t ob_size; /* Number of items in variable part */
|
第二部分是变量部分,描述了对象的特定内容。以下是PyStringObject的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
typedef struct {
PyObject_VAR_HEAD
long ob_shash;
int ob_sstate;
char ob_sval[1];
/* Invariants:
* ob_sval contains space for 'ob_size+1' elements.
* ob_sval[ob_size] == 0.
* ob_shash is the hash of the string or -1 if not computed yet.
* ob_sstate != 0 iff the string object is in stringobject.c's
* 'interned' dictionary; in this case the two references
* from 'interned' to this object are *not counted* in ob_refcnt.
*/
} PyStringObject;
|
现在,有些人可能会问自己“当Python收到指针时,如何知道对象的类型?”。事实上,这正是ob_type字段的作用。Python导出一个_typeobject静态变量,描述对象的类型。例如,PyString_Type:
1
2
3
4
5
6
7
8
9
10
|
PyTypeObject PyString_Type = {
PyVarObject_HEAD_INIT(&PyType_Type, 0)
"str",
PyStringObject_SIZE,
sizeof(char),
string_dealloc, /* tp_dealloc */
(printfunc)string_print, /* tp_print */
0, /* tp_getattr */
// ...
};
|
基本上,每个字符串对象的ob_type字段都将指向该PyString_Type变量。通过这个巧妙的小技巧,Python能够进行类型检查:
1
2
3
4
5
6
7
8
|
#define Py_TYPE(ob) (((PyObject*)(ob))->ob_type)
#define PyType_HasFeature(t,f) (((t)->tp_flags & (f)) != 0)
#define PyType_FastSubclass(t,f) PyType_HasFeature(t,f)
#define PyString_Check(op) \
PyType_FastSubclass(Py_TYPE(op), Py_TPFLAGS_STRING_SUBCLASS)
#define PyString_CheckExact(op) (Py_TYPE(op) == &PyString_Type)
|
通过前面的技巧和PyObject类型定义如下,Python能够以通用方式处理不同的对象:
1
2
3
|
typedef struct _object {
PyObject_HEAD
} PyObject;
|
因此,当您在调试器中并想知道对象的类型时,可以使用该字段轻松识别您正在处理的对象的类型:
1
2
3
|
0:000> dps 026233b0 l2
026233b0 00000001
026233b4 1e226798 python27!PyString_Type
|
一旦您完成了这一点,您可以转储描述对象的变量部分以提取您想要的信息。
顺便说一句,所有原生对象都在Objects/目录中实现。
调试会话:单步执行VM的艰难之路
是时候更深入一点了,到汇编级别,我们属于那里;因此,让我们定义一个虚拟函数如下:
1
2
|
def a(b, c):
return b + c
|
现在使用Python的dis模块,我们可以反汇编函数对象a:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
In [20]: dis.dis(a)
2 0 LOAD_FAST 0 (b)
3 LOAD_FAST 1 (c)
6 BINARY_ADD
7 RETURN_VALUE
In [21]: a.func_code.co_code
In [22]: print ''.join('\\x%.2x' % ord(i) for i in a.__code__.co_code)
\x7c\x00\x00\x7c\x01\x00\x17\x53
In [23]: opcode.opname[0x7c]
Out[23]: 'LOAD_FAST'
In [24]: opcode.opname[0x17]
Out[24]: 'BINARY_ADD'
In [25]: opcode.opname[0x53]
Out[25]: 'RETURN_VALUE'
|
请记住,正如我们之前所说,万物皆对象;因此函数是对象,字节码也是对象:
1
2
3
4
5
6
7
8
9
10
11
12
|
typedef struct {
PyObject_HEAD
PyObject *func_code; /* A code object */
// ...
} PyFunctionObject;
/* Bytecode object */
typedef struct {
PyObject_HEAD
//...
PyObject *co_code; /* instruction opcodes */
//...
} PyCodeObject;
|
是时候将我的调试器附加到解释器,看看在这个奇怪的机器中发生了什么,并在PyEval_EvalFrameEx上设置条件断点。
一旦您完成了这一点,您可以调用虚拟函数:
1
2
3
4
5
6
7
8
9
10
11
12
|
0:000> bp python27!PyEval_EvalFrameEx+0x2b2 ".if(poi(ecx+4) == 0x53170001){}.else{g}"
breakpoint 0 redefined
0:000> g
eax=025ea914 ebx=00000000 ecx=025ea914 edx=026bef98 esi=1e222c0c edi=02002e38
eip=1e0ec562 esp=0027fcd8 ebp=026bf0d8 iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00200246
python27!PyEval_EvalFrameEx+0x2b2:
1e0ec562 0fb601 movzx eax,byte ptr [ecx] ds:002b:025ea914=7c
0:000> db ecx l8
025ea914 7c 00 00 7c 01 00 17 53 |..|...S
|
好的,完美,我们正处于VM中,我们的函数正在被评估。寄存器ECX指向正在评估的字节码,第一个操作码是LOAD_FAST。
基本上,此操作码从fastlocals数组中获取一个对象,并将其推送到虚拟栈上。在我们的案例中,正如我们在反汇编和字节码转储中看到的,我们将加载索引0(参数b),然后加载索引1(参数c)。
在调试器中看起来是这样的;第一步是加载LOAD_FAST操作码:
1
2
3
4
5
6
|
0:000>
eax=025ea914 ebx=00000000 ecx=025ea914 edx=026bef98 esi=1e222c0c edi=02002e38
eip=1e0ec562 esp=0027fcd8 ebp=026bf0d8 iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00200246
python27!PyEval_EvalFrameEx+0x2b2:
1e0ec562 0fb601 movzx eax,byte ptr [ecx] ds:002b:025ea914=7c
|
在ECX中,我们有一个指向正在评估函数的操作码的指针,即我们的虚拟函数。0x7c是LOAD_FAST操作码的值,正如我们所见:
1
|
#define LOAD_FAST 124 /* Local variable number */
|
然后,函数需要检查操作码是否有参数,这是通过将操作码与称为HAVE_ARGUMENT的常量值进行比较来完成的:
1
2
3
4
5
6
|
0:000>
eax=0000007c ebx=00000000 ecx=025ea915 edx=026bef98 esi=1e222c0c edi=00000000
eip=1e0ec568 esp=0027fcd8 ebp=026bf0d8 iopl=0 nv up ei pl zr na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00200246
python27!PyEval_EvalFrameEx+0x2b8:
1e0ec568 83f85a cmp eax,5Ah
|
再次,我们可以验证该值以确保我们理解我们在做什么:
1
2
|
In [11]: '%x' % opcode.HAVE_ARGUMENT
Out[11]: '5a'
|
C中HAS_ARG的定义:
1
|
#define HAS_ARG(op) ((op) >= HAVE_ARGUMENT)
|
如果操作码有参数,函数需要检索它(它是一个字节):
1
2
3
4
5
6
|
0:000>
eax=0000007c ebx=00000000 ecx=025ea915 edx=026bef98 esi=1e222c0c edi=00000000
eip=1e0ec571 esp=0027fcd8 ebp=026bf0d8 iopl=0 nv up ei pl nz na pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00200206
python27!PyEval_EvalFrameEx+0x2c1:
1e0ec571 0fb67901 movzx edi,byte ptr [ecx+1] ds:002b:025ea916=00
|
正如预期的那样,对于第一个LOAD_FAST,参数是0x00,完美。
之后,函数将执行流分派到LOAD_FAST情况,定义如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
#define GETLOCAL(i) (fastlocals[i])
#define Py_INCREF(op) ( \
_Py_INC_REFTOTAL _Py_REF_DEBUG_COMMA \
((PyObject*)(op))->ob_refcnt++)
#define PUSH(v) BASIC_PUSH(v)
#define BASIC_PUSH(v) (*stack_pointer++ = (v))
case LOAD_FAST:
x = GETLOCAL(oparg);
if (x != NULL) {
Py_INCREF(x);
PUSH(x);
goto fast_next_opcode;
}
//...
break;
|
让我们看看它在汇编中的样子:
1
2
3
4
5
6
|
0:000>
eax=0000007c ebx=00000000 ecx=0000007b edx=00000059 esi=1e222c0c edi=00000000
eip=1e0ec5cf esp=0027fcd8 ebp=026bf0d8 iopl=0 nv up ei ng nz na po cy
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00200283
python27!PyEval_EvalFrameEx+0x31f:
1e0ec5cf 8b54246c mov edx,dword ptr [esp+6Ch] ss:002b:0027fd44=98ef6b02
|
获取fastlocals后,我们可以检索一个条目:
1
2
3
4
5
6
|
0:000>
eax=0000007c ebx=00000000 ecx=0000007b edx=026bef98 esi=1e222c0c edi=00000000
eip=1e0ec5d3 esp=0027fcd8 ebp=026bf0d8 iopl=0 nv up ei ng nz na po cy
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00200283
python27!PyEval_EvalFrameEx+0x323:
1e0ec5d3 8bb4ba38010000 mov esi,dword ptr [edx+edi*4+138h] ds:002b:026bf0d0=a0aa5e02
|
还请记住,我们使用两个字符串调用了我们的虚拟函数,因此让我们实际检查它是否是一个字符串对象:
1
2
3
|
0:000> dps 025eaaa0 l2
025eaaa0 00000004
025eaaa4 1e226798 python27!PyString_Type
|
完美,现在根据PyStringObject的定义:
1
2
3
4
5
6
|
typedef struct {
PyObject_VAR_HEAD
long ob_shash;
int ob_sstate;
char ob_sval[1];
} PyStringObject;
|
我们应该在对象中直接找到字符串的内容:
1
2
3
|
0:000> db 025eaaa0 l1f
025eaaa0 04 00 00 00 98 67 22 1e-05 00 00 00 dd 16 30 43 .....g".......0C
025eaab0 01 00 00 00 48
|