1. 程式人生 > >有關Python序列化和存在的反序列化缺陷思考

有關Python序列化和存在的反序列化缺陷思考

0x00 面試被問到了

前段時間面試,問到了Python序列化漏洞,問我瞭解嗎。我說平時用過,也大概知道其序列化後是一種什麼形式,但序列化漏洞沒怎麼關注過。心裡想,Python序列化漏洞難道不是和Java還有PHP一樣,都是因為敏感操作引起的嗎,過分信任了輸入,其實也是程式碼注入的一種,沒什麼好說的啊。

結果是自己太 naive ,Python序列化方面的問題遠沒有這麼簡單。

0x01 序列化的方式

Python的序列化主流有兩種方式,一種是使用pickle模組(cPickle是pickle的C語言版本,效能有提升),一種是使用JSON格式。還有個更古老的模組叫做 marshal,不過並不推薦,官方文件說marshal存在是為了用來支援 .pyc 檔案。

那pickle和marshal之間有什麼區別呢?

  1. pickle會跟蹤已經序列化的物件,如果有引用相同的物件,那麼它將不會被再次序列化。但marshal依然會。
  2. marshal不能序列化使用者自定義的物件。
  3. marshal不能保證在多個Python版本間傳遞。

同樣的,pickle和json方式之間有什麼區別嗎?

  1. json的序列化輸出是文字物件(unicode),而pickle序列化的輸出是二進位制位元組(bytes),如果不明白這兩者的區別可以看我的上一篇部落格:Python2和Python3之間關於字串編碼處理的差別
  2. json可讀性更好。
  3. json擁有廣泛的相容性,能夠在Python以外的很多地方使用。而pickle只針對Python。
  4. 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)

,該方法將obj物件序列化並返回一個bytes物件(在Python2中名字叫str)。

反序列化:
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)?

  1. None, True, 和 False
  2. 整形、浮點、複數
  3. strings, bytes, bytearrays
  4. 元組, 列表, 集合, 和 只包含可序列化物件的字典
  5. 定義在模組頂層的函式(lambda表示式不可以)
  6. 定義在模組頂層的內建函式
  7. 定義在模組頂層類
  8. 最後一個本人翻譯的不好,直接給英文原文: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__() 等代替。

這個方法有兩種返回值方式:

  1. 如果返回值是一個字串,那麼將會去查詢字串值對應名字的物件,將其序列化之後返回。
  2. 如果返回值是元組(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模組 ,讓我很糾結,速度提升很大,但卻存在如此大的缺陷,用還是不用?最好在保證輸入來源可信的情況下去使用這個模組的反序列功能。
再者,就是開發人員的安全意識問題了,你偏要用不可信屬性值當函式名去呼叫,沒人攔的住你。。。

參考文件: