有關Python序列化和存在的反序列化缺陷思考
0x00 面試被問到了
前段時間面試,問到了Python序列化漏洞,問我瞭解嗎。我說平時用過,也大概知道其序列化後是一種什麼形式,但序列化漏洞沒怎麼關注過。心裡想,Python序列化漏洞難道不是和Java還有PHP一樣,都是因為敏感操作引起的嗎,過分信任了輸入,其實也是程式碼注入的一種,沒什麼好說的啊。
結果是自己太 naive ,Python序列化方面的問題遠沒有這麼簡單。
0x01 序列化的方式
Python的序列化主流有兩種方式,一種是使用pickle模組(cPickle是pickle的C語言版本,效能有提升),一種是使用JSON格式。還有個更古老的模組叫做 marshal,不過並不推薦,官方文件說marshal存在是為了用來支援 .pyc 檔案。
那pickle和marshal之間有什麼區別呢?
- pickle會跟蹤已經序列化的物件,如果有引用相同的物件,那麼它將不會被再次序列化。但marshal依然會。
- marshal不能序列化使用者自定義的物件。
- marshal不能保證在多個Python版本間傳遞。
同樣的,pickle和json方式之間有什麼區別嗎?
- json的序列化輸出是文字物件(unicode),而pickle序列化的輸出是二進位制位元組(bytes),如果不明白這兩者的區別可以看我的上一篇部落格:Python2和Python3之間關於字串編碼處理的差別。
- json可讀性更好。
- json擁有廣泛的相容性,能夠在Python以外的很多地方使用。而pickle只針對Python。
- json只能表示一部分Python內建的型別,不能表示使用者自定義的類物件。當你嘗試序列化一個自定義的類物件時,會丟擲一個 TypeError 。而pickle則能表示大多數物件,包括使用者自定義的類(絕大部分)。
還有個模組叫做 pickletools 用來分析pickle產生的資料。
所以綜上,pickle是一個相對較好的Python序列化模組。
0x02 怎麼使用
序列化:
pickle.dump(obj, file, protocol=None, *, fix_imports=True)
,最重要的是前兩個引數,該方法將obj物件序列化為位元組(bytes)輸出到file檔案中。
pickle.dumps(obj, protocol=None, *, fix_imports=True)
反序列化:
pickle.load(file, *, fix_imports=True, encoding="ASCII", errors="strict")
,從一個物件檔案中讀取序列化資料,將其反序列化之後返回一個物件。
pickle.loads(bytes_object, *, fix_imports=True, encoding="ASCII", errors="strict")
,將一個bytes物件反序列化並返回一個物件。
同時,pickle模組還提供一個Pickler類和Unpickler類,用法類似,不過是隻能從檔案中讀取和輸出到檔案。
0x03 什麼型別可以序列化和反序列化(Python3)?
- None, True, 和 False
- 整形、浮點、複數
- strings, bytes, bytearrays
- 元組, 列表, 集合, 和 只包含可序列化物件的字典
- 定義在模組頂層的函式(lambda表示式不可以)
- 定義在模組頂層的內建函式
- 定義在模組頂層類
- 最後一個本人翻譯的不好,直接給英文原文:instances of such classes whose
__dict__
or the result of calling__getstate__()
is picklable.
0x04 關於類例項的序列化
當一個類的例項被反序列化時,它的__init__()
方法不會被呼叫,預設的方式是建立一個沒有初始化的物件然後恢復它的屬性。
以下是官方給的示例程式碼,說明了在序列化(save)和反序列化(load)時所做的操作:
def save(obj):
return (obj.__class__, obj.__dict__)
def load(cls, attributes):
obj = cls.__new__(cls)
obj.__dict__.update(attributes)
return obj
0x05 反序列化時的程式碼執行缺陷
其實如果僅僅是自定義序列化的資料,然後程式又過分信任這個資料,造成程式碼注入,那撐死只是開發人員安全意識不到位的問題,“所有輸入都是不可信的”這是一條基本準則。其實只要不將資料過分的濫用(比如某個屬性值當作函式名或者直接eval某個資料),造成的危害還是有限的,稍微長點心還是能夠避免的。但我萬萬沒想到有個魔術方法竟然能達到任意程式碼執行的地步。
官方文件中說過,pickle是個不安全的模組,永遠別去反序列化不信任的資料。
這一切都是因為__reduce__
魔術方法,它在序列化的時候會完全改變被序列化的物件,這個方法相當的強大,官方建議不要直接操作這個方法,用更高階的介面 __getnewargs(), __getstate__() and __setstate__()
等代替。
這個方法有兩種返回值方式:
- 如果返回值是一個字串,那麼將會去查詢字串值對應名字的物件,將其序列化之後返回。
- 如果返回值是元組(2到5個引數),第一個引數是可呼叫(callable)的物件,第二個是該物件所需的引數元組,剩下三個可選。
第一種方式先暫且不談,重要的是第二種方式,看下面例子:
在反序列化的時候就執行程式碼了,毫無防備。說直接點,只要你用了物件序列化模組cPickle,並且反序列化的輸入來源能被控制,那麼就是妥妥的任意程式碼執行。
我們來理解一下發生了什麼:
1. 我們將惡意程式碼插入一個物件中,並將其序列化,得到位元組序列
c__builtin__\neval\np1\n(S"os.system(\'net\')"\np2\ntp3\nRp4\n.
從位元組序列我們就能看出來,序列化之後的資料已經完全和Test類沒有關係,只剩下了內建函式 eval 和需要執行的系統命令。
2. 資料被傳送給了反序列化函式 cPickle.loads()
3. 因為cPickle是C語言實現,我就著pickle模組看了一下,發現它是基於詞法和語法分析(想想編譯原理)來完成解析的,對每一個字元都註冊相應的處理函式,挨個分析,分行讀取處理(所以你會看到那麼多 \n
),最後在 R 標誌符的時候執行呼叫操作,所以物件呼叫是在解析階段就已經完成:
這是pickle模組的實現,估計cPickle也沒什麼區別,就是用C寫的而已。
0x06 怎麼解決
對於cPickle,我毫無辦法,它是C語言實現,我嘗試去 from cPickle import Unpickler
, 但是失敗了,說明用C寫的時候並沒有用到這個類,也沒有提供其他的介面。
對於 pickle ,我覺得可以嘗試去為Unpickler 新增一個自己寫的裝飾器,HOOK剛剛的 load_reduce 函式 ,用白名單的思想去解決。
為什麼不用黑名單?
我舉個例子你就明白了:
我嘗試寫了個demo,去攔截非白名單的可呼叫物件:
0x07 小結
Python的反序列問題遠遠比Java和PHP嚴重,特別是cPickle模組 ,讓我很糾結,速度提升很大,但卻存在如此大的缺陷,用還是不用?最好在保證輸入來源可信的情況下去使用這個模組的反序列功能。
再者,就是開發人員的安全意識問題了,你偏要用不可信屬性值當函式名去呼叫,沒人攔的住你。。。
參考文件: