虚拟机逃逸
前言
1. TCTF 2017 final Python
之前在TCTF的线下赛上碰到了Python的一道沙箱逃逸题目,虽然最后由于主办方题目上的一些疏漏导致了非预期解法的产生,但是本身真的是不错的沙箱逃逸案例,如果是按照预期解法,可以说以后别的沙箱逃逸题如果不改Python的源码感觉已经没啥可出的必要了。
题目的话,不用担心没有题目,你就想成一个除了sys模块,连file object都用不了的Python2就行了,其实用真的Python2然后自己不用这些就可以模拟这道题目啦。
Python的沙箱逃逸在之前的CTF就有出现过,不过大多是利用Python作为脚本语言的特性来逃逸,相当于换其他方式达到相同目的,比如没了file,通过别的方式拿到file,这次的题目其实也是可以这样搞的,因为stdin等等对象是file对象,可以用来拿到file对象,这样就可以做到在服务器上进行任意读写,之后比如可以写/proc/self/mem或者编译一个c写的python module然后写到/tmp里之后考虑去import,这些其实都是非预期解法,预期解法就相当有意思了,用的方法是通过Python的字节码来获取,这里我们也就需要重点讲这个方面的内容了。
2. Python沙箱?不存在的
作为前言的一小部分,我还想提一个问题,python,到底有没有沙箱?
其实这跟我看过的一个presentation,演讲者问台下,chroot到底是不是安全机制,是一个道理。python我个人认为,没有沙箱这一说。我估计我这么说应该好多人不同意,但是事实就是python在设计的时候根本没有考虑这方面的因素,原因?一会我们看看代码就知道了。
调试环境
• os: manjaro linux 17.01
• python: python2.7.13 debug版本(自己编译的),更改了两个可能在debug下出错的地方,主要是ceval.c:825,改为release版本的写法,还有924行,这一段的define都改为没有LLTRACE的写法。
Python虚拟机原理
1.对象
Python的虚拟机的源码有一个很典型的特点,那就是一切皆对象。虽然代码是用C写的,但是面向对象的思路倒是用的非常细致,我们首先来看几个典型的对象:
PyObject:https://github.com/python/cpython/blob/2.7/Include/object.h
首先总结以下Python object的基本特点:
1)除了Type Object(一会提到),其他object一律分配在堆上;
2)object都有引用计数来确保垃圾回收功能的正常;
3)Object有一个type,创建时候一个object的type就固定了,type自己也是object,这就是Type Object;
4)Object的内存和地址保持不变,如果是变量的,通过指向变量内存的指针实现;
5)Object的类型是PyObject *。
实现:
这部分主要是PyObject的定义和PyVarObject的定义,是Python中对象的内部表示。
至于Type Object由于代码较长,我认为对理解运行原理帮助也不大,就不截下来了,最主要的就是需要理解用来表示一个Python对象的类型的也是一个对象。
至于用来检查对象类型的方法:
可以看出,检查方法也就是通过ob,也就是在PyObject_HEAD里的信息来检查。
2.code对象
通过之前的讨论,我们知道了Python对对象的表示方式,只要在结构体里最开始部分写 PyObject_HEAD 或者 PyObject_VAR_HEAD 就可以是一个PyObject或者PyVarObject对象了。那么Python代码是怎么表示的呢?
答案就是——code对象:https://github.com/python/cpython/blob/2.7/Include/code.h
这里的代码不是太有意思我就不解释了,从这里我们可以知道两点:
1)一个PyCode对象包含了一段代码对于Python来说所需要的所有信息,其中比较重要的是字节码;
2)检查一个PyCode对象的类型是通过检查HEAD部分的内容的,HEAD的内容是在创建PyCode的时候指定的,根据之前对象的原则,创建之后就不再改变了。
3.运行原理
运行有关代码:https://github.com/python/cpython/blob/2.7/Python/ceval.c
其中用来运行的代码_PyEval_EvalFrameDefault, 从第1199行的switch(opcode)即是运行的主要部分,通过不同的opcode进行不同的操作。
其实整个Python的运行过程就是首先通过compile构建一个PyCodeObject,得到代码的字节码,之后根据不同的字节码进行不同的操作,过程还是比较简单的。
由于Python是基于栈的,所以会看到一系列操作stack的函数,其实就理解成一个栈结构,这个栈结构里存的是一系列对象就可以了。
搞事情
1.运行任意字节码
好了,原理讲的差不多了,大家应该都明白Python大致的运行机制了,那么我们就结合这个机制来思考一下。
Python的运行是首先compile得到PyCodeObject对吧,那么,PyCodeObject里边的字节码决定了执行什么样的字节码对吧,如果,我能够控制这个字节码,是不是就可以执行我想要的字节码了?
答案是,对的。而且Python并不限制你这么做,毕竟动态语言嘛,你想干嘛也拦不住你。想要操作这个字节码也很简单,types就可以,我们现在来试试。
这里最重要的就是codestring,是字节码的字符串表示,其他的都不是太重要(注意不要直接复制我这一段代码运行,UTF-8的问题,加个UTF-8或者删掉中文可以运行),然后我们运行试试。
seg fault了,不出所料,原因?
我们来调试一下。这里我自己下源码编译了一个有debug符号和源码的Python2.7方便调试。
这是解析LOAD_CONST字节码的内容,可以看到首先通过GETITEM得到code object中consts和oparg的参数的内容,之后处理引用计数,然后PUSH了相应的值!
GETITEM是从一个tuple中去取出值,我们看看segfault的地方:
0xbeef就是我们输入的值,也就是说我们控制了GETITEM的参数。这里就说明了一个很大的问题:我们是可以控制运行的字节码的。最后segfault的原因嘛,这个值取不了,有问题,于是就segfault了。
其实到这,针对Python沙箱的论述也差不多了,毕竟我们已经可以控制运行的字节码,但是毕竟我们最终的目的是拿到shell对吧,那么接下来怎么做?
2.从运行任意字节码到任意代码执行
1)基本思路
好了,我们可以执行任意字节码了,不过还不够。如何执行任意代码?我们需要一个函数指针,反正啥都可以改,我们改掉这个函数指针就可以了。我们也十分幸运,恰巧就有这么一个神奇的函数指针:
https://github.com/python/cpython/blob/5eb788bf7f54a8e04429e18fc332db858edd64b6/Objects/call.c
好了函数指针有了,现在总结一下调用到函数指针的整个流程:
ceval.c:https://github.com/python/cpython/blob/2.7/Python/ceval.c
总结一下需要调用到函数指针的过程:
i.字节码类型是CALL_FUNCTION,进入call_function;
ii.call_function中,PyCFunction_Check或者nk==0不成立,之后PyMethod_Check或者PyMethod_GET_SELF(func) != NULL不成立,然后PyFunction_Check不成立,进入do_call;
iii.do_call中PyCFunction_Check不成立,进入PyOBject_Call;
iv.PyObject_call中,func的ob_type的tp_call就是我们要调用的函数指针。
看代码有点烦,通俗地讲:
i.字节码是CALL_FUNCTION;
ii.不是function类型也不是method类型,不过是object类型;
iii.这个object类型的type object里的tp_call就是调用的函数指针。
这么看就简单多了,type object虽然是一开始静态分配的,但是反正又不检查,不是静态分配又如何?伪造一个嘛。
2)最终思路
i.构造一个object,构造为type object的形式,不过tp_call指向想要执行的位置;
ii.构造第二个object,使得type指向第一个object;
iii.构造第三个object,指针指向第二个object;
iv.构造字节码:1.通过extended_arg构造offset参数,offset为consts和第三个object的偏移,2.通过load_const指令,由于按照consts是tuple,会再解一次引用,于是使得第二个object被push进栈,3.通过call_function,进入调用过程;
v.将字节码设置进入某个function的func_code;
vi.执行这个function,即执行我们构造好的func_code。
3)poc.py
这个poc稍微显得有点乱,但是基本能够表达清楚思路。主要是有一些偏移量的计算不太好算,所以我采用了动态调试的方法,直接看内存结构,然后相减得到的偏移,看起来虽然乱了,但是却是计算偏移最简单的方法,偏移量其实很多时候不是很好静态计算,可能有一些你没想到的细节,如果动态去调着看的话,就一定是正确的偏移了。
运行这个POC,我们可以使rip指向0xdeadbeef了。
3.从POC到EXP,任意执行到shell
其实到这,剩下的步骤虽然还有一些,但是思路上已经全部清晰了,我们可以执行任意代码,现在需要的是:
i.找到system的地址;
ii.传入参数。
1)任意读
根据之前的讨论,我们知道了我们可以随意更改字节码,执行任意字节码,那么想要构造一个新的object也不是难事。想要读取信息,就需要一个指针,而Python有指针的地方实在是太多了。
我们采取的方法是使用ByteArrayObject,ByteArrayObject代码如下:
所以,想要任意读,伪造一个BYteArrayObject,伪造方法和之前一样,然后直接读就可以了,好了,现在的问题只剩下,读哪儿?
2)system地址
想要找到system的地址,就需要libc地址,libc地址其实还花了我一些时间,不过最终用到一个方法:
sys.stdin的f_fp字段存有_IO_2_1_stdin的地址,这个地址是位于libc data段的,可以利用这个去拿到libc地址,最终拿到system地址,读取方法就根据上一节的PyByteArrayObject的方法就可以。
3)参数
有了system,可以劫持rip,最后的问题是传入参数。这里就需要注意到之前call的调用方式了:
func是一个指针,指向我们构造的“第一个对象”,所以,我们只需要把第一个对象的开始部分设置为"/bin/sh",由于ob_next并没有用到,所以改为字符串并不会影响其他结果,最后就可以system("/bin/sh")了。
4.exp.py
这个exploit是我自己的环境下的,并且是自己编译的debug版本,执行不正常是可能出现的,因为偏移量不一样,甚至具体代码都有可能有一些不一样,所以仅供参考。最后还是需要自己手动调试才行(特别是各种偏移量)。
结论
1.Python真的没有沙箱,本文提出的方法几乎适合于任何情况的Python沙箱,除非有大更改。毕竟整个过程中用的都是Python必须的东西,原生的东西,没有依赖不必要的。
2.调试过程中尽量动态去算偏移,除非是真的必须要静态来看出原理。静态看偏移经常会出错。
注意
1.本文的情况和TCTF final的情况不完全一样,他的情况还有一些地方需要处理。比如没有id函数可以拿到任意对象的地址,并且开启了PIE。本文中的情况考虑了PIE,但是id函数需要自己处理一下。我目前想到的id的处理方式,是通过一个方法,比如a = ""; a.ljust.__str__()也是可以达到id函数的效果的,其他类型也可以相应的去找他有的方法来leak出地址。
2.本文的情况都是基于debug版本的,release版本应该会有一些小差别,但是方法是通用的,不过由于时间关系我没有再调试一遍release版本,release版本调试起来也会比较费时间,方法是能用的。
最后更新于
这有帮助吗?