Python subprocess 模組使用教學

Posted on  Jun 16, 2023  in  Python 程式設計 - 中階  by  Amo Chen  ‐ 5 min read

有時候開發功能不需要從頭寫到尾,利用他人開發的函式庫(library), API 之外,也可以整合既有的指令工具,不僅可以節省開發時間,也能夠完成需求。

我的碩士論文就是用 Python 作為膠水語言整合各種指令工具所完成的, Python 的 subprocess 模組在其中扮演不可獲缺的角色, subprocess 模組讓人可以執行各種指令,例如常見的 awk, sort, sed, uniq 等指令,並擷取其輸出(stdout / stderr),讓 Python 程式可以讀取,並進一步做其他處理。

本文將介紹 Python subprocess 模組的使用方法,以及應該注意的資安問題。

本文環境

  • macOS
  • Python 3

subprocess 模組簡介

subprocess 模組提供開發者在 Python 程式中執行外部指令的方法,例如用 subprocess 執行 ping 指令:

>>> import subprocess
>>> subprocess.run(['ping', '127.0.0.1'])
PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.076 ms
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.162 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.175 ms

有了 subprocess 模組的幫忙,就可以整合各種外部指令,以迅速完成想要的功能。

subprocess 模組使用方法

subprocess.run()

Python 官方最推薦的 subprocess 使用方法,就是呼叫 subprocess.run() , 該函式提供相當多的參數,不過都是選填(optional),基本上只需要將指令以空格做為分隔,變成 Python list 帶入 subprocess.run() 即可,例如 ls -alh 需要變成 ["ls", "-alh"] 帶入:

>>> import subprocess
>>> subprocess.run(['ls', '-alh'])
total 1248
drwxr-xr-x   14 usr  staff   448B Jun 16 14:34 .
drwxr-xr-x   21 usr  staff   672B Jun 16 10:09 ..
drwxr-xr-x   15 usr  staff   480B Jun 16 14:33 .git
-rw-r--r--    1 usr  staff    19B Jan  6 14:22 .gitignore
CompletedProcess(args=['ls', '-alh'], returncode=0)

上述範例可以看到 subprocess.run() 會輸出執行結果,並回傳 1 個 CompletedProcess 實例(instance),該實例有 args, returncode, stdout, stderr 等屬性可以存取,可以視需求使用。

噢,如果不想自己手動轉換指令成 Python list, 可以用 shlex 模組 幫忙轉換:

>>> import shlex
>>> shlex.split('ls -alh')
['ls', '-alh']
capture_output / text / encoding 參數

預設外部指令的輸出(stdout / stderr)會直接列印出來,而且也不會存到 CompletedProcess 實例(instance)的 stdout 屬性中,例如:

>>> r = subprocess.run(['ls', '-alh'])
total 1248
drwxr-xr-x   14 usr  staff   448B Jun 16 14:34 .
drwxr-xr-x   21 usr  staff   672B Jun 16 10:09 ..
drwxr-xr-x   15 usr  staff   480B Jun 16 14:33 .git
-rw-r--r--    1 usr  staff    19B Jan  6 14:22 .gitignore
>>> print(r.stdout)
None

如果需要處理指令的輸出,則需要將 capture_output 參數設定為 True , 輸出就會改成存到 CompletedProcess 實例中:

>>> r = subprocess.run(['ls', '-alh'], capture_output=True)
>>> print(r.stdout)
b'...(略)...'

值得注意的是 stdout 屬性預設是存 bytes, 如果想轉為 string 可以設定 text 參數為 True

>>> r = subprocess.run(['ls', '-alh'], capture_output=True, text=True)

或者你的指令輸出是其他種編碼的文字,也可以指定 encoding 參數進行解碼(decode):

>>> r = subprocess.run(['ls', '-alh'], capture_output=True, encoding='utf-8')
check 參數

如果是希望在外部指令執行發生錯誤時,讓 Python 也跟著拋出例外(exception)的話,可以設定 check 參數為 True , 該參數會讓 Python 檢查指令的回傳碼(returncode),只要不是 0, 就會拋出錯誤:

>>> r = subprocess.run(['/bin/sh', '-c', 'exit 1'], check=True)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/.../subprocess.py", line 516, in run
    raise CalledProcessError(retcode, process.args,
subprocess.CalledProcessError: Command '['/bin/sh', '-c', 'exit 1']' returned non-zero exit status 1.

上述範例可以看到 Python 拋出 subprocess.CalledProcessError 例外。

p.s. CompletedProcess 實例(instance)也有 check_returncode() 方法可以呼叫,作用跟 check 參數一樣。

cwd / env 參數

cwd 參數可以改變 subprocess.run() 的當前工作目錄(current working directory, cwd):

>>> subprocess.run(['pwd'], cwd='/Users')
/Users
CompletedProcess(args=['pwd'], returncode=0)

env 參數則是可以設定 subprocess.run() 執行時的環境變數,該參數接受字典型別的值,例如:

>>> subprocess.run(['env'], env={'MY_ENV': 'Hello'})
MY_ENV=Hello
CompletedProcess(args=['env'], returncode=0)
shell 參數

如果想讓 subprocess.run() 直接執行字串形式的指令的話,可以將 shell 參數設定為 True, 這代表指令會交給 shell 執行(預設為 /bin/sh ),如此一來就不再需要將指令換成 list 形式:

>>> subprocess.run('ls -alh', shell=True)
total 1248
drwxr-xr-x   14 usr  staff   448B Jun 16 14:34 .
drwxr-xr-x   21 usr  staff   672B Jun 16 10:09 ..
drwxr-xr-x   15 usr  staff   480B Jun 16 14:33 .git
-rw-r--r--    1 usr  staff    19B Jan  6 14:22 .gitignore
CompletedProcess(args='ls -alh', returncode=0)

不過我們不推薦這種用法,這會導致一種稱為 shell injection 的漏洞產生,下一章節將詳細介紹 shell injection 。

input 參數

如果有些指令需要從 stdin 輸入資料的話,就需要使用 input 參數將資料帶入,例如 grep 指令可以從 stdin 帶入資料,並進行資料篩選:

>>> data = b"""
... a = 1;
... b = 2;
... c = 3;
... """
>>> subprocess.run(["grep", "b ="], input=data)
b = 2;
CompletedProcess(args=['grep', 'b = 2'], returncode=0)

input 預設只接受 bytes 作為輸入,因此上述的 data 是 bytes, 如果想接受字串的話,可以多帶個參數 text=True

>>> data = """
... a = 1;
... b = 2;
... c = 3;
... """
>>> subprocess.run(["grep", "b = 2"], input=data, text=True)
b = 2;
CompletedProcess(args=['grep', 'b = 2'], returncode=0)

組合多個指令

有時候,單單一個指令無法完成需求,經常需要用 Pipe 或稱管道 | 組合多個指令完成需求,例如 ls -l | grep .py 是 ls 指令列出所有檔案後,交由 grep 指令濾出含 .py 字串的檔案,這種組合多個指令的方式,就需要分別設定 input 與 stdout 2 個參數進行資料傳遞,也就是前 1 個指令的 stdout 是後 1 個指令的 input 。

ls -l | grep *.py 為例,可以寫成:

import subprocess

r1 = subprocess.run(
    ["ls", "-l"],
    capture_output=True,
    text=True,
)
r2 = subprocess.run(
    ["grep", ".py"],
    input=r1.stdout,
    capture_output=True,
    text=True,
)
print(r2.stdout)

subprocess.Popen()

subprocess.run() 的底層其實是 subprocess.Popen() ,如果你需要針對新產生的 process 做更細部的設定,例如 user, group, umask, pipe 的 buffer size 等等,就可以考慮直接使用 subprocess.Popen() , 不過一般來說用 subprocess.run() 就足夠。

Shell Injection 攻擊

當我們使用 subprocess 模組時,要特別注意 shell injection 的問題,例如以下程式,可以讓使用者自行輸入 IP 位址並執行 ping 指令:

import subprocess

s = input('ip = ')

subprocess.run(f'ping {s}', shell=True)

但是使用者只要輸入以下字串,就可以讓 ping 指令結束工作,並且把你的系統中的帳號列出來⋯⋯,這種手法就被稱為 shell injection 或稱 command injection:

-c 1 127.0.0.1; cat /etc/passwd

上述字串執行結果如下:

PING 127.0.0.1 (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.113 ms

--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.113/0.113/0.113/0.000 ms
##
# User Database
#
# Note that this file is consulted directly only when the system is running
# in single-user mode.  At other times this information is provided by
# Open Directory.
#
# See the opendirectoryd(8) man page for additional information about
# Open Directory.
##
nobody:*:-2:-2:Unprivileged User:/var/empty:/usr/bin/false

這就是為何使用 shell=True 時要注意資安問題,若設定 shell=False 則不會有問題,這是由於 shell=False 的情況下,指令的執行不是透過 shell, 因此 shell 相關的指令就不會被接受,而是視為其中 1 個參數或值處理,例如下列 Python 程式碼在遇到 shell injection 攻擊時完美地防禦:

import subprocess

s = input('ip = ')

subprocess.run(['ping', s])

上述程式碼執行結果:

ip = -c 1 127.0.0.1; cat /etc/passwd
ping: invalid count of packets to transmit: ` 1 127.0.0.1; cat /etc/passwd'

總結

subprocess 是 1 個強大的工具模組,可以幫助我們在 Python 中執行外部指令,提供更多元的實作彈性與應用。不過該模組仍然需要一些額外的認識,才能正確使用以避免資安問題。

希望本文能夠幫助大家更好地理解和使用 Python subprocess 模組。

Happy coding!

References

subprocess — Subprocess management — Python 3.11.4 documentation

Security Considerations

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

追蹤新知

看完這篇文章了嗎?還意猶未盡的話,追蹤粉絲專頁吧!

我們每天至少分享 1 篇文章/新聞或者實用的軟體/工具,讓你輕鬆增廣見聞提升專業能力!如果你喜歡我們的文章,或是想了解更多特定主題的教學,歡迎到我們的粉絲專頁按讚、留言讓我們知道。你的鼓勵,是我們的原力!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!