有些项目跑得好好的,谁也不愿意去翻自己写过的脚本。直到上周,帮某位朋友复盘一个本地工具,我才又一次意识到:很多人对“本地脚本没有安全问题”这件事,抱有一种近乎迷信的信任。那个工具的功能很简单,压缩本地目录、调用系统命令执行转码,再写个日志,妥妥的“自己电脑上用用”。结果某天临时把命令拼错,直接把半个项目的文件清掉了。没丢代码,但几天的调试产出全没了。
这就是我想写这篇文章的原因:本地Python脚本,完全可以存在命令注入风险。而且恰恰因为“本地”这个前提,很多人把最基本的检查全跳过了。下面我会用一次完整的自查过程,把我踩过的坑、捋清楚的逻辑、以及现在的加固流程,全部摊开来说。
1. 严格来说,“本地运行”掩盖了什么
1.1 一条最简单不过的命令,也能翻车
先看一段常见到不能再常见的代码:
import subprocess import os filename = input("请输入要处理的文件名: ") subprocess.run(f"ls -l {filename}", shell=True)放到任何人电脑上,这段代码都能跑。看起来只是在查看文件的详细信息。可如果输入的是test.txt; rm -rf ~,在类Unix环境下,shell=True会让分号之后的命令一并执行。结果就是,你亲手输入了一个“合法的命令拼接”。
这种漏洞在Web应用里大家都会警惕,但换成本地脚本,就容易想当然:反正使用者是我自己,我不会输入恶意内容。问题是,脚本的使用者不一定是“此刻的你”,也可能是几个月后的你、换了套环境的你、或者不明所以的同事。
1.2 没有“恶意输入”,命令注入照样可能发生
很多人觉得命令注入的前提是“有人故意攻击”,所以在本地脚本里根本不考虑。可实际上更常见的注入来源是这几类:
- 文件名中包含空格、引号、
$、反引号等特殊字符,你的脚本没有处理; - 脚本从环境变量、配置文件里读取的参数被拼接进命令;
- 自动化任务把上游输出直接当成字符串传给了 shell;
- 目录路径本身包含特殊字符,比如某文件夹名叫做
my file (1),一旦拼接进命令,不加引号就会分段。
也就是说,不需要有人“攻击你”,只要数据里的特殊字符碰上了命令拼接,问题就会自己冒出来。这与攻击者无关,是代码结构本身留下了隐患。
1.3 为什么本地环境反而更容易触发
这里有个反直觉的点:本地脚本往往跑在权限最高的账户下,一旦命令拼接出问题,影响范围比远程漏洞更大。远程漏洞至少要绕过权限、找到入口,而本地脚本一旦触发,就直接以当前用户身份执行任意命令。如果你是用管理员账户跑脚本,危险程度再上一个台阶。
很多人给本地脚本赋予了过高权限,比如直接跑sudo python xxx.py,再配合不可信的输入,命令注入就可能变成提权路径。这还没算上脚本常被打包成定时任务、启动项,在后台悄悄运行。
2. 五个隐患点:我查项目时最先盯住的地方
2.1 无意识的字符串拼接,是最大的习惯性错误
最常见的是把subprocess.run或os.system当作“命令行打印器”:
import subprocess path = "/data/backup" subprocess.run(f"rsync -av {path} /nas/", shell=True)如果path来自配置文件、外部传入参数,或者某个目录名里恰好有空格,命令的解析方式就和预期完全不同。假设path等于"/data/backup && shutdown -h now",这就不是同步文件,而是同步完直接关机了。
问题是,这类代码太常见。几乎所有人入门时都是这么写的:字符串拼好,丢进run(),加个shell=True。在本地环境里跑一百次也没出事,于是就一直这么用了。
2.2 从环境变量和配置文件读到的内容,也是“外部输入”
有人说“我的脚本不接受用户输入”,所以安全。但脚本里的数据来源远不止input()一种:
os.environ["HOME"]、os.environ["PATH"]这类环境变量可被 shell 启动环境改变;- 配置文件里的路径、语言参数、回调参数,很多是别人填的;
- 定时任务传入的参数,可能来自上一个脚本的输出;
- 目录下的文件名列表,是文件系统给的数据。
举个例子,某个脚本会遍历一个目录并调用转码工具,文件名被直接拼到命令里:
for f in os.listdir(dir_path): os.system(f'ffmpeg -i "{f}" out.mp4')这里文件名加了引号,看似安全。但文件名如果包含",比如 `foo".avi",引号就被闭合了,后面可以接任意命令。
我见过最离谱的一回,是一个目录名里带着分号,结果脚本把整个目录当成两条命令来执行。不是有人攻击,纯粹是“数据刚刚好长得像命令”。
2.3 把eval/exec/os.popen当万能工具箱
这三个函数是命令注入的重灾区。尤其os.popen,在 Python 2 时代到处都是,到现在还有不少老项目在用。它本质上是启动一个 shell 子进程,把命令字符串直接传给 shell 解释。
result = os.popen(f"ps aux | grep {keyword}")这一行看起来了不起,其实和os.system(f"ps aux | grep {keyword}")没有本质区别,keyword里一旦带上" && curl ..."之类的内容,就会执行额外命令。
eval和exec更危险,它们执行的已经是 Python 代码了。本地脚本里如果对这些函数有不必要的使用,我建议直接重构,而不是想办法过滤输入。因为能过滤的情况太复杂,绕过的可能性又太高。
2.4 启动其它程序时,把文件名直接当作参数传递
有些命令本身没有 shell 参与,但同样会出现问题。比如用subprocess.run(["ls", filename]),这里的filename并没有被 shell 解析,空格、分号、引号都只是普通字符,相对安全。
但有一种情况例外:调用某些支持选项注入的程序。比如文件名以-开头,某些命令会把后面的内容当成参数,而不是文件路径。典型例子是curl,一个-开头的 URL 可能被解释成选项;git、tar也有类似行为。
处理方法通常是在参数前加--分隔符,或者在拼接前强制加./前缀:
subprocess.run(["ls", "--", filename])这类问题更隐蔽,不是标准的命令注入,但效果差不多——命令行为被可控数据改变。
2.5 定时任务、GUI 打包和“看不见的调用者”
本地脚本一旦被crontab调度、被系统服务托管、被打包进 GUI 应用,它的输入来源就会扩大。它不再只是“你终端里手动跑的那个脚本”,而是变成了一个长期运行的、接收各种输入的服务。
我见过有人在 GUI 里做了个“打开当前文件所在目录”的功能,直接写成:
os.system(f"explorer {file_path}")文件路径来自外部传入,当文件名里包含特殊字符时,就可能弹出命令行窗口执行额外命令。本地 GUI 工具因为表面上是“自己用”,这类隐患极易被忽略。
3. 怎么快速自查一个项目:三步走流程
3.1 第一步:用一次搜索,把高风险函数全找出来
我会在项目根目录直接跑一次正则搜索,先圈定所有可疑调用点:
rg -n -e "subprocess" -e "os\.system|popen|os\.exec|eval|exec\(" --glob "*.py" .如果项目里用了subprocess.run、subprocess.call、subprocess.Popen、os.system、os.popen、os.spawn*、commands.getoutput,全部列出来。
这一步的意义不是“所有匹配都有问题”,而是先把排查范围锁定住。你会发现大多数项目的可疑调用点不超过20个,逐个看一遍实际花不了几分钟。
3.2 第二步:画一条数据流,找“从哪里来到哪里去”
对每个可疑调用点,我会问三个问题:
- 这条命令字符串里,哪些部分是变量?
- 这些变量来自哪里?是用户输入、环境变量、文件内容、网络接口,还是固定常量?
- 在拼接进命令之前,有没有经过任何校验或转义?
如果答案是“变量来自外部,且没有校验”,不管代码跑起来多顺,都要当作风险项处理。
这一步可以用最简单的办法:直接给每个可疑点加一行print(repr(command_str)),把实际执行前的命令字符串打印出来,看变量落在原始命令字符串的哪个位置。这样可以直观发现很多问题,比如你是不是想当然认为“引号会保护变量”,但实际上引号根本没闭合。
我曾经在自查时发现,自己写的一个备份脚本,路径变量来自一个YAML配置。配置里填的是D:\Program Files\MyApp\,当我用字符串拼接生成 Windows 命令时,反斜杠被命令解释器当成了转义符,整个路径都被解析乱了。打印出命令字符串的瞬间,问题一下就明白了。
3.3 第三步:用静态扫描工具做辅助
手工排查为主,工具为辅。目前常用的是bandit,它会把有风险的调用点标注出来。使用方法很简单:
pip install bandit bandit -r .它会提示subprocess使用shell=True的风险、eval/exec的风险,以及部分pickle使用问题。这类工具的误报率不低,但它能提示一些你忽略的地方,尤其适合项目代码量大的情况。
不过要提醒一下:工具扫描永远不能替代数据流分析。静态扫描看到的是“有没有用某个函数”,真实风险在于“数据怎么流进函数”。两个项目同样调用了subprocess.run,一个安全,一个危险,工具只能把你带到门口,判断还得自己来。
4. 加固方案:四个层面的实用改法
4.1 最有效的一招:用参数列表替代字符串拼接
比如把:
subprocess.run(f"ls -l {filename}", shell=True)改成:
subprocess.run(["ls", "-l", filename])这个改动直接把“是否需要 shell 参与解析”变成了“否”。参数列表里的每个元素,都会被当作独立的参数直接传给操作系统。空格、引号、分号、$、反引号,全都只是普通字符,不会再被解释成命令语法。这是成本最低、收益最高的改法。
我这个改法唯一要注意的是,某些命令的复杂写法(比如重定向、管道、通配符)不适用于参数列表方式。遇到这种情况,可以选择用 Python 自己处理重定向和管道,也可以谨慎使用shell=True并配合严格的输入校验,而不是直接妥协回字符串拼接。
4.2 绝不轻易出现的shell=True,需要时再想一次办法
shell=True是命令注入风险的放大器。它的存在,意味着字符串会被交给系统的/bin/sh或cmd.exe进一步解释。这一层解释就是注入的基础。
我的处理原则是这样:默认全部写成参数列表,不使用shell=True。如果确实需要管道、重定向这类功能,优先用 Python 自带的机制:
- 重定向:用
stdout=、stderr=参数; - 管道:先启动多个子进程,再用
subprocess.PIPE连接; - 通配符展开:用
glob.glob()或pathlib.Path.glob()先展开再传参。
比如想执行cat *.txt | grep keyword,改成:
import glob import subprocess files = glob.glob("*.txt") p1 = subprocess.Popen(["cat"] + files, stdout=subprocess.PIPE) p2 = subprocess.Popen(["grep", "keyword"], stdin=p1.stdout, stdout=subprocess.PIPE) p1.stdout.close() output, _ = p2.communicate()这样既保留管道能力,又完全不经过 shell。代码略长,但行为完全可控。
4.3 如果绕不过去,起码要校验和转义
有些场景确实绕不开 shell,比如需要执行一段复杂的 shell 逻辑。这时候必须做两层防护:
第一层:白名单校验。只允许明确允许的字符集通过。比如路径校验可以限定为字母、数字、下划线、斜杠和点:
import re def safe_path(s: str) -> str: if not re.fullmatch(r"[A-Za-z0-9_./\+ -]+", s): raise ValueError("路径包含非法字符") return s第二层:用shlex.quote做转义。这个函数会把字符串转成可以在 shell 中安全使用的带引号形式。注意,shlex.quote只对类 Unix 系统有效,Windows 的 cmd.exe 规则完全不同,不能混用。
import subprocess import shlex filename = "my file; rm -rf" subprocess.run(f"ls -l {shlex.quote(filename)}", shell=True)加了这层,filename里就算有特殊字符,也会被当成普通文本。但记住,这是“尽量”安全,不是“绝对”安全。有条件的项目,还是优先走参数列表。
4.4 关于--分隔符和“不以 - 开头”的约定
就算用了参数列表,还有一个细节要注意。当参数可能以-开头时,某些命令会把它当成选项处理。
标准处理是在参数前加--,表示“后面都是位置参数”:
subprocess.run(["grep", "--", pattern, filename])不是所有命令都支持--,所以更稳妥的做法是在数据前面加./:
subprocess.run(["diff", "./" + filename, expected])这两个细节写进代码规范里,能避免不少边角问题。本地脚本虽然不面向公网,但代码只要存在一天,就有被复用的可能。那不如一开始就把边界处理干净。
5. 常见问题速查表与排查心得
5.1 自查时容易被带偏的三个点
第一,以为“转义了就是安全”。转义只能保证变量内容不被当作语法解析,但不能保证所有环境下都有效。Windows 的 cmd.exe 与 POSIX shell 的转义规则差异极大,换平台后shlex.quote就失效了。
第二,以为“加了引号就是安全”。前面举过例子,文件名里如果有引号字符,你加的引号就会被闭合。用参数列表可以彻底绕开这个话题,不信你可以拿foo"; rm -rf /tmp/test试试拼接法。
第三,以为“接受用户输入才叫不安全”。本地脚本的风险恰恰来自那些“看起来不是用户输入”的地方:文件名、路径、环境变量、配置值。只要数据来源于外部,就按外部输入来处理。
5.2 一个实用排查表格
| 场景 | 风险点 | 建议处理方案 |
|---|---|---|
os.system(f"...") | 整个字符串交给 shell | 改用subprocess.run([...]) |
subprocess.run(cmd, shell=True) | shell 二次解析 | 参数列表 + 去除 shell |
subprocess.Popen(cmd_str) | 同上 | 改为参数列表形式 |
os.popen(...) | shell 子进程 | 替换成subprocess+ 参数列表 |
eval()/exec() | 执行任意 Python 代码 | 重构业务逻辑,不再使用 |
| 文件名拼入命令 | 特殊文件名造成命令分割 | 参数列表 /--分隔符 |
| 环境变量路径拼入命令 | 间接输入被注入 | 先校验,再拆分参数 |
| 定时任务脚本输入 | 长时间运行扩大了暴露面 | 记录日志 + 最小权限 |
5.3 附一个随手能用的提醒清单
每次写完本地脚本,我会过一遍这些问题:
- 项目里是否存在
shell=True?如果有,为什么?能不能去掉? - 所有变量在进入命令之前,有没有经过白名单校验或正确的参数化传递?
- 可执行文件的路径是否固定?有没有可能在
PATH中被替换? - 脚本是否以不必要的管理员权限运行?
- 有没有对输入数据做日志记录?万一出事,追查是否方便?
- 代码是否会被其它项目复用?复用时假设的输入范围是否已被更宽松地定义?
这些问题看起来琐碎,但大部分命令注入导致的本地事故,都出现在不假思索的“快捷拼接”上。只要代码写的时候慢一步、写完之后查一遍,就能挡住绝大多数问题。
6. 我个人踩过坑之后的一些体会
刚入职那会儿,我也写过不少“能跑就行”的脚本。那时候的想法和现在大多数人一样:本地脚本自己用,不用考虑安全问题。后来帮朋友排查文件被删的问题时,我看见他的备份脚本里出现一行类似subprocess.run(f"rm -rf {backup_dir}", shell=True)的代码,才真正意识到:一个本地脚本酿成的破坏,从来不需要攻击者。只需要一个藏在文件夹名里的分号,一个没预料到的空格,一次没有校验的路径拼接,就够了。
从那以后,我给自己定了一条规矩:凡是脚本要调用外部程序,一律先把命令打印出来看一眼;凡是字符串要拼进命令,一律先问一句“这里的变量从哪来”。用subprocess时默认不写shell=True,参数列表能解决的问题绝不绕道字符串。这个习惯花不了几秒,却让很多项目避免了我当年踩过的坑。
本地脚本的风险,不在于是否联网,而在于你是否给了数据重新解释命令的机会。很多所谓“安全”其实只要多一行参数化写法、少一个shell=True就能完成。希望这篇整理,能帮你少走一点弯路。