Python Subprocess庫在使用中可能存在的安全風險總結
0×00. 前言
在各大熱門語言排行榜中,Python語言多次名列前茅,其高效的開發效率和優雅的程式設計風格吸引不少開發人員的青睞,不少公司將技術棧切換至Python。隨著Python 語言的愈來愈流行,其安全問題也愈發受到安全人員的關注。作為新一代的語言,雖然其相較於PHP等傳傳統(資格老一些的語言)語言在安全性上有諸多改進,但仍然面臨不少安全問題,本文以最為流行的Python 子程序庫subprocess為例分析其在使用中常見的安全陷阱,詳文如下。
0×01. 函式呼叫死鎖風險
1)死鎖形式1
subprocess.call subprocess.check_call subprocess.check_output
以上三個函式在使用stdout=PIPE or stderr=PIPE 存在死鎖風險
處理方案:
若要使用stdout=PIPE or stderr=PIPE,建議使用popen.communicate()
subprocess 官方文件在上面幾個函式中都標註了安全警告:
2) 死鎖形式2
對於popen , popen.wait() 可能會導致死鎖
處理方案:
那死鎖問題如何避免呢?官方文件裡推薦使用 Popen.communicate()。這個方法會把輸出放在記憶體,而不是管道里,所以這時候上限就和記憶體大小有關了,一般不會有問題。而且如果要獲得程式返回值,可以在呼叫 Popen.communicate() 之後取 Popen.returncode 的值。
3)死鎖形式3
call、check_call、popen、check_output 這四個函式,引數shell=True,命令引數不能為list,若為list則引發死鎖
處理方案:
引數shell=True時,命令引數為字串形式
0×02. 關閉subprocess.Popen 子程序時存在子程序關閉失敗而成為殭屍程序的風險
Python 標準庫 subprocess.Popen 是 shellout 一個外部程序的首選,它在 Linux/Unix 平臺下的實現方式是 fork 產生子程序然後 exec 載入外部可執行程式。
於是問題就來了,如果我們需要一個類似“夾具”的子程序(比如執行 Web 整合測試的時候跑起來的那個被測試 Server), 那麼就需要在退出上下文的時候清理現場,也就是結束被跑起來的子程序。
最簡單粗暴的做法可以是這樣:
@contextlib.contextmanager
def process_fixture(shell_args):
proc = subprocess.Popen(shell_args)
try:
yield
finally:
# 無論是否發生異常,現場都是需要清理的
proc.terminate()
proc.wait()
if __name__ == '__main__':
with process_fixture(['python', 'SimpleHTTPServer', '8080']) as proc:
print('pid %d' % proc.pid)
print(urllib.urlopen('http://localhost:8080').read())
那個 proc.wait() 是不可以偷懶省掉的,否則如果子程序被中止了而父程序繼續執行, 子程序就會一直佔用 pid 而成為殭屍,直到父程序也中止了才被託孤給 init 清理掉。
這個簡單粗暴版對簡單的情況可能有效,但是被執行的程式可能沒那麼聽話。被執行程式可能會再fork 一些子程序來工作,自己則只當監工 —— 這是不少 Web Server 的做法。 對這種被執行程式如果簡單地 terminate ,也即對其 pid 發 SIGTERM , 那就相當於謀殺了監工程序,真正的工作程序也就因此被託孤給 init ,變成畸形的守護程序…… 嗯沒錯,這就是我一開始遇到的問題,CI Server上明明已經中止了 Web Server 程序了,下一輪測試跑起來的時候埠仍然是被佔用的。
處理方案:
這個問題稍微有點棘手,因為自從被執行程式 fork 以後,產生的子程序都享有獨立的程序空間和pid ,也就是它超出了我們觸碰的範圍。好在 subprocess.Popen 有個 preexec_fn 引數,它接受一個回撥函式,並在 fork 之後 exec 之前的間隙中執行它。我們可以利用這個特性對被執行的子程序做出一些修改,比如執行 setsid() 成立一個獨立的程序組。Linux 的程序組是一個程序的集合,任何程序用系統呼叫 setsid 可以建立一個新的程序組,並讓自己成為首領程序。首領程序的子子孫孫只要沒有再呼叫 setsid 成立自己的獨立程序組,那麼它都將成為這個程序組的成員。 之後程序組內只要還有一個存活的程序,那麼這個程序組就還是存在的,即使首領程序已經死亡也不例外。 而這個存在的意義在於,我們只要知道了首領程序的 pid(同時也是程序組的 pgid ), 那麼可以給整個程序組傳送 signal ,組內的所有程序都會收到。
因此利用這個特性,就可以通過 preexec_fn 引數讓 Popen 成立自己的程序組, 然後再向程序組傳送 SIGTERM 或 SIGKILL ,中止 subprocess.Popen 所啟動程序的子子孫孫。當然,前提是這些子子孫孫中沒有程序再呼叫 setsid 分裂自立門戶。
前文的例子經過修改是這樣的:
import signal
import os
import contextlib
import subprocess
import logging
import warnings
@contextlib.contextmanager
def process_fixture(shell_args):
proc = subprocess.Popen(shell_args, preexec_fn=os.setsid)
try:
yield
finally:
proc.terminate()
proc.wait()
try:
os.killpg(proc.pid, signal.SIGTERM)
except OSError as e:
warnings.warn(e)
Python 3.2 之後 subprocess.Popen 新增了一個選項 start_new_session ,Popen(args, start_new_session=True) 即等效於 preexec_fn=os.setsid 。這種利用程序組來清理子程序的後代的方法,比簡單地中止子程序本身更加“乾淨”。基於 Python 實現的 Procfile 程序管理工具 Honcho 也採用了這個方法。當然,因為不能保證被執行程序的子程序一定不會呼叫 setsid , 所以這個方法不能算“通用”,只能算“相對可用”。如果真的要百分之百通用,那麼像 systemd 那樣使用 cgroups 來追溯程序建立過程也許是唯一的辦法。也難怪說 systemd是第一個能正確地關閉服務的 init 工具。
0×04. 引數拼接引發的命令注入風險
1)命令注入場景1:shell=True時,命令引數可控
案例:
s=subprocess.Popen('ls;id', shell=True, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
處理方案:
1)shell=True,使用 pipes.quote() 對引數進行過濾
如果是python3,推薦使用shlex.quote()
2)shell=False,引數使用list,此時能防止部分命令注入(其他風險見2))
缺點是寫引數時會稍微麻煩點
2)命令注入場景2:shell=False時,引數選項拼接引發的命令注入風險
使用subprocess執行命令的時候,如果使用外部傳入引數,且引數可控,要注意,引數不要變成命令中的 引數選項
像subprocess.call([]) 執行的是list 拼接起來的命令,如果可控引數 在拼接之後使得引數變成了引數選項,則存在命令注入風險
案例:
import subprocess
query = '--open-files-in-paper=id;'
r = subprocess.call(['git', 'grep', '-i', '--line-number', query, 'master'], cwd='/root/op-scripts')
預設情況下,python的subprocess接受的是一個列表。我們可以將使用者輸入的query放在列表的一項,這樣也就避免了開發者手工轉義query的工作,也能從根本上防禦命令注入漏洞。但可惜的是,python幫開發者做的操作,也僅僅相當於是PHP中的escapeshellarg。我們可以試試令query等於–open-files-in-pager=id;:
php 中方式命令注入的兩個函式
escapeshellcmd
escapeshellarg
二者分工不同,前者為了防止使用者利用shell的一些技巧(如分號、反引號等),執行其他命令;後者是為了防止使用者的輸入逃逸出“引數值”的位置,變成一個“引數選項”。
如果開發者在拼接命令的時候,將$query直接給拼接在“引數選項”的位置上,那用escapeshellarg也就沒任何效果了,與之類似的是如果將$query直接給拼接在“引數選項”的位置上,python中的shlex.quote() 或者pipes.quote() 也沒了作用
為什麼 shlex.quote() 不會奏效?
1)git grep -i --line-number '--open-files-in-pager=id;' master
2)git grep -i --line-number --open-files-in-pager=id; master
1)和 2)沒有區別,單引號並不是區分一個字串是“引數值”或“選項”的標準。
處理方案:
解決此類命令注入風險的關鍵是如何讓shell 認為 ‘–open-files-in-pager=id;’ 不是個引數選項
在前面加上 — 就可以, 比如這樣:git grep -i –line-number — ‘–open-files-in-pager=id;’ master
這樣–open-files-in-pager 就不會作為引數選項了,原理如下:
在命令列解析器中,–的意思是,此後的部分不會再包含引數選項(option):
A -- signals the end of options and disables further option processing. Any arguments after the -- are treated as filenames and arguments. An argument of - is equivalent to --.
If arguments remain after option processing, and neither the -c nor the -s option has been supplied, the first argument is assumed to be the name of a file containing shell commands. If bash is invoked in this fashion, $0 is set to the name of the file, and the positional parameters are set to the remaining arguments. Bash reads and executes commands from this file, then exits. Bash's exit status is the exit status of the last command executed in the script. If no commands are executed, the exit status is 0. An attempt is first made to open the file in the current directory, and, if no file is found, then the shell searches the directories in PATH for the script.
-e 與 – 具有等同效果