深入Python虚拟机:LOAD_CONST漏洞剖析

本文详细分析了Python 2.7.5中LOAD_CONST操作码的漏洞机制,通过控制虚拟栈实现任意代码执行。涵盖Python VM架构、对象模型、调试技巧及漏洞利用原理,展示如何从内存操作到最终获得调用原语的全过程。

深入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中,我们有一个指向正在评估函数的操作码的指针,即我们的虚拟函数。0x7cLOAD_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
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计