寫 Python 潛在會踩到的安全性地雷(二)

Python 的靈活帶來了意想不到的攻擊手段,之二

最近聽了 PyCon APAC 2022 的 Writing secure code in Python,覺得這些手段太有創意了,所以沒想過的資安系列第二篇文就這麼誕生了。

eval 危險吶

eval 作為把使用者輸入的字串直接執行的函數,直觀上就很好想像可作為安全漏洞來攻擊,那麼它是怎麼被利用的呢——

首先最直觀的手段,直接在裡面塞各種惡意字串:

eval("os.system('rm -rf /')")

假設剛好 os 是有被 import 的,那麼- Boom。但作為開發者這端完全沒有反制手段嗎?其實根據 eval 的參數定義:

eval(source, globals=None, locals=None)

我們是可以指定這次執行下能看到的 globals 跟 locals 的範圍,所以第一層的反制出現了:把 globals 拔掉

In []: eval("os.system('rm -rf /')", {})
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [], in <cell line: 1>()
----> 1 eval("os.system('rm -rf /')", {})

TypeError: eval() takes no keyword arguments

然而,其實 Python 裡面存在很多平常不會被注意到的功能,如 import,攻擊者可以透過這個內建函數去把模組重新 import 進來:

eval("__import__('os').system('rm -rf /')", {})

那麼對應的反制就是在跑 eval 的時候也要把 __builtins__ 擋住:

In []: eval("__import__('os').system('rm -rf /')", {"__builtins__": {}})
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Input In [], in <cell line: 1>()
----> 1 eval("__import__('os').system('rm -rf /')", {"__builtins__": {}})
File <string>:1, in <module>
NameError: name '__import__' is not defined

嘿,到目前為止看起來沒有很可怕吧?

接下來進一步地,攻擊者可以透過自創一個新的物件去繞過這個限制:

In []: ().__class__
Out[]: tuple
In []: ().__class__.__base__
Out[]: object
In []: ().__class__.__base__.__subclasses__()
Out[]:
[type,
...
]

透過繞道到 object 之後,可以再透過 object 的子物件清單去接觸到萬物。Sam Anttila 利用這個特性去找到了一個隱藏的 importlib

In []: next(
...: t
...: for t in ().__class__.__base__.__subclasses__()
...: if t.__name__ == 'BuiltinImporter'
...: )
Out []: _frozen_importlib.BuiltinImporter
In []: _.load_module('os')
Out []: <module 'os' (built-in)>

而攻擊者就可以利用這個隱藏版的 importlib 去做到 remote code execution(RCE),以下即是一個可以成功執行的範例:

eval("[t for t in ().__class__.__base__.__subclasses__() if t.__name__ == 'BuiltinImporter'][0].load_module('os').system('echo 1234')", {"__builtins__": {}})

註:在 __builtins__ 被擋住的狀況下我們也會失去 next,所以這邊改用 list 去取值

所以我們可以怎麼做?

我們該做字串處理,來分析裡面需要跑些什麼,然後另外去實現它。

或是對於一些比較簡單的需求可以是採用 ast.literal_eval 代替 eval。它可以將內容等價為幾個型別的資料如字串、byte、數字、布林,tuplelistdictsetNone 轉換為實際的 Python 物件,但不允許運算:

In []: ast.literal_eval('3.14')
Out[]: 3.14
In []: ast.literal_eval('(True, "hello, world!")')
Out[]: (True, 'hello, world!')
In []: ast.literal_eval('1 + 1')
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Input In [], in <cell line: 1>()
----> 1 ast.literal_eval('1 + 1')
(redacted)
File /opt/homebrew/Cellar/python@3.9/3.9.13_3/Frameworks/Python.framework/Versions/3.9/lib/python3.9/ast.py:66, in literal_eval.<locals>._raise_malformed_node(node)
65 def _raise_malformed_node(node):
---> 66 raise ValueError(f'malformed node or string: {node!r}')
ValueError: malformed node or string: <ast.BinOp object at 0x102975640>

picklemarshal

eval 更加進階的,醃黃瓜 pickle 是一個可以把 Python 物件及函數序列化(serialize)為 byte code、及將 byte code 反序列化為 Python 物件及函數的函式庫。在不少地方默默地能看到它的身影,如各種跨行程(process)、跨機器的工作中,因為記憶體不共享,所以它們之間的資料都會透過這種方法把工作序列化、再到終端上反序列回來,並開始工作。

而 Python 世界有可能萬物皆可醃嗎?否,原生的 pickle 只能處理那些可以用純粹的 Python byte code 表示的物件,但我們日常使用的函式庫們或多或少會有 C API 的身影,如 numpy,而 pickle 就不具備把一個 C 的物件打包的功能,因此它也定義了一個介面(interface)用於讓第三方函式庫的開發者可以讓這些函式庫擁有跨執行序的支援:reduce

既然允許自定義,那麼就存在漏洞利用的可能性:

In []: class ExploitObject:
...: def __reduce__(self):
...: return os.system, ('echo 1234', )
...:
In []: pickle.dumps(ExploitObject())
Out []: b'\x80\x04\x95$\x00\x00\x00\x00\x00\x00\x00\x8c\x05posix\x94\x8c\x06system\x94\x93\x94\x8c\techo 1234\x94\x85\x94R\x94.'
In []: pickle.loads(_)
1234
Out []: 0

在這樣的過程中,看起來上我們只是要把一個物件傳到另一個地方執行,但在反序列化的過程中卻被搞了一波 RCE。

對於一組不確定內容的 pickle 字串,可以利用內建的 pickletools.dis 去檢查裡面的 byte code:

    0: \x80 PROTO      4
    2: \x95 FRAME      36
   11: \x8c SHORT_BINUNICODE 'posix'
   18: \x94 MEMOIZE    (as 0)
   19: \x8c SHORT_BINUNICODE 'system'
   27: \x94 MEMOIZE    (as 1)
   28: \x93 STACK_GLOBAL
   29: \x94 MEMOIZE    (as 2)
   30: \x8c SHORT_BINUNICODE 'echo 1234'
   41: \x94 MEMOIZE    (as 3)
   42: \x85 TUPLE1
   43: \x94 MEMOIZE    (as 4)
   44: R    REDUCE
   45: \x94 MEMOIZE    (as 5)
   46: .    STOP
highest protocol among opcodes = 4

然後就會發現它似乎有參進奇怪的東西了。

marshal 進來攪局

這時候來聊聊另一個序列化資料的函式庫 marshal,它也是一個序列化與反序列化的模組,但較 pickle 低階——它僅支援一些比較基礎的物件,並且對平台較為敏感,並不是一個通用的解決方案。

實作上我們可使用 marshal 去序列化一段程式碼(但不序列化物件本體),這可以方便地把一些不過度複雜的工作丟到遠端去執行、並繞開一些 pickle 上的限制:

In []: def rce():
...: import os
...: os.system('echo 1234')
...:
In []: base64.b64encode(marshal.dumps(rce.__code__))
Out []: b'4wAA...AQgB'
In []: code = marshal.loads(base64.b64decode(b'4wAA...AQgB' ))
In []: func = types.FunctionType(code, globals())
In []: func()
1234
In []: pickle.dumps(func) # 補充:這個的函數無法直接被 pickle
---------------------------------------------------------------------------
PicklingError Traceback (most recent call last)
Input In [], in <cell line: 1>()
----> 1 pickle.dumps( func)
PicklingError: Can't pickle <function rce at 0x1055f0dc0>: attribute lookup rce on __main__ failed

以同樣的手段,如果攻擊者蓄意去製造出一組同等於這類行為的 byte code:

import base64, marshal, types
code = marshal.loads(base64.b64decode(b'4wAA...AQgB'))
func = types.FunctionType(code, globals())
func()

那麼就會在 pickle.load 階段觸發 RCE,並且這其 byte code 就算有人為檢視,也只會看到其中的 base64.b64decode, marshal.loads 等過程,主要的 RCE code 其實無法被直接注意到。

所以我們可以怎麼做?

方法一,如果今天只是需要傳送資料,那麼我們可以選用某些原生無法注入額外行為的格式,如 JSON。

方法二,如果真的需要傳送資料到遠端去跑,那麼我們可以加上 HMAC 驗證,這塊可以搭配內建的 hmac 模組達成。

pip install

pip install 的預設行為是:

  1. wheel 格式存在時,從 wheel 直接安裝
  2. 當其格式可從原始碼轉換為 wheel 時,編譯為 wheel 並安裝
  3. 以上皆非時,執行 setup.py

setup.py 畢竟就真的是一隻直接叫起來的 Python 檔案,要塞惡意程式進去完全沒有難度。

現實面的利用則是 typosuatting,即將惡意程式註冊為某些大家很常用的函式庫的相似名稱,等有人打錯字的時候就會中標。例如今年四月就有抓到有惡意程式命名為 seabron 及 tensorfolw 等。

所以我們可以怎麼做?

作者的建議是在拉檔案的時候可以考慮加上 --require-hashes 選項(並帶入 hash 做檢查);這樣算起來如果是用 poetrypipenv 並從 lock 檔安裝的時候大概率可以繞過這個問題,但如果是在開發初期作為第一次添加到 depenency list 的時候也只能小心了。

另外就是不要使用 root 權限去跑 pip,這樣就算好死不死被駭了至少權限不大很多 C2 需要跑的動作都跑不起來。

過期的相依、過期的 Python

後面的水分愈來愈多了

老調常談、使用一些比較老的函式庫會導致攻擊者可以利用那些已經被揭露的漏洞來打,所以永遠在呼籲大家要升級。

不僅止於第三方函式庫,官方的 Python 標準函式庫有時候也會挖出安全漏洞、故一樣也是建議跟著版本升級。另外作者提及要特別注意被標註為過期(deprecated)的函數們,他們不全然只是因為 Python 版本更迭而被淘汰,有些也是因為具有已知漏洞、但無法在延續既有行為的情況下修復才被標記為淘汰的。

random 不是真亂數

不確定這算不算常識——random 這整個函式庫的產出都不是真亂數,所以安全性相關的東西,如密碼,就不建議從這裡生。

有需要生密碼之類的就走 os.urandom,不過另外注意這個 API 應該是有 blocking 的行為,所以如果有平行運算的狀況下就得改走 os.getrandom 搭配一些 flag 服用。

解壓炸彈

這不算是 Python 環境特有的陷阱,單純就如果從網路上抓了 xml、zip 及 tar 之類的都要小心踩到 解壓炸彈,即一個很小的檔案在解壓縮之後直接把儲存資源塞滿的狀況。

對於 xml 的解壓炸彈有 defusedxml 可以檢測。

一些檢測工具

作者建議了幾個工具給大家參考,可用於檢查 code 確保安全性:

open source 的工具:

對 open source 專案免費的工具:

總結

最後的最後,作者留下了幾個建議話給大家,雖然不少建議其實跟 Python 無關、放諸四海皆適用、但是為了 prod 系統的穩定性放眼望去皆無視

  1. 永遠不要相信使用者輸入的值
  2. 避免用 sudo / root 權限去跑 Python 腳本
  3. 系統記得升級
  4. 讀文件吧!那些已知漏洞會被在文件上標註
  5. 跑個靜態檢查吧