| 失效的假设 | “我只是拼了一条命令,用户填的是参数” |
| 所属组 | W1 · 注入 |
| 前置 | 第 1 篇:注入的一般形式。命令注入是那篇里"换个解析器"的完整展开 |
| 复现环境 | Python 3 标准库,无外部依赖;所有 payload 都是无害的 echo |
一、又是拼字符串,只不过这次交给 shell
你在做一个"给上传的图片生成缩略图"的功能。系统里已经有个现成的命令行工具能干这事,你顺手拼了条命令调它:
convert 用户传来的文件名 -resize 200x200 缩略图.jpg
用户传的文件名是 photo.jpg,一切正常。
用户传的文件名是 photo.jpg; rm -rf /data——两条命令都执行了。
打个比方
你去一家店点菜,把菜名写在纸条上递给服务员。你以为你写的只是"菜名"。
可这家店的服务员认一整套暗号:写个 ; 表示"另起一单",写个 | 表示"把上一道菜端回后厨再加工一遍",写个 $( ) 表示"先照括号里的做,做出来什么就上什么"。
你只知道其中一两个暗号,他知道全部。所以你写下的每一个符号,他都在按自己那套规则读——而不是当成菜名的一部分。
⭐ 这个比方里最关键的一句是:认暗号的是他,不是你。 你能不能想到某个符号有特殊含义,完全不影响它有没有特殊含义。
下面把这件事跑一遍。为了看得清楚,把场景换成最小的那个——一个"跟用户打个招呼"的小功能:
import subprocess
# 一个"跟用户打招呼"的功能。开发者用 shell 字符串拼出命令。
def greet(name):
out = subprocess.run(f"echo Hello {name}", shell=True,
capture_output=True, text=True)
return out.stdout.strip()
print("正常 :", repr(greet("alice")))
# shell 的元字符远不止一个分号,每一个都是一条独立的注入路径:
print("; 串联命令 :", repr(greet("alice; echo PWNED")))
print("&& 条件串联 :", repr(greet("x && echo PWNED")))
print("| 管道 :", repr(greet("alice | rev")))
print("$() 命令替换 :", repr(greet("$(echo PWNED)")))
正常 : 'Hello alice'
; 串联命令 : 'Hello alice\nPWNED'
&& 条件串联 : 'Hello x\nPWNED'
| 管道 : 'ecila olleH'
$() 命令替换 : 'Hello PWNED'
; 串联命令 那行也许你已经料到了。真正要看的是后面三行:
&& x 不存在,命令失败,然后 echo PWNED 照跑
| 把 "Hello alice" 通过管道喂给 rev,输出被整个改写
$() 命令替换:括号里的先执行,结果再嵌回来
⭐ 这就是命令注入专属的第一个坑:shell 的元字符是一整套,不是一个分号。 ; | & && || $() 反引号 > < * ? ~ \n……每一个都是一条独立的路径。任何"我把分号过滤掉"的想法,在这一整套面前都是漏的——这正是第 1 篇那句"黑名单在定义上就做不对"的又一个现场。
而根源还是第 1 篇那三问的第一问:谁是解析器? 你以为你在"执行一条命令",其实你把一个字符串交给了 /bin/sh,由它决定这串字符是一条命令还是五条。
二、“那我给变量加上引号”
这是几乎所有人的第二反应:把用户填的部分用引号包起来,元字符不就失效了吗?
一半对,一半致命。
import subprocess
# "那我把变量用引号包起来" —— 常见的第二反应
def greet_quoted(name):
out = subprocess.run(f'echo "Hello {name}"', shell=True,
capture_output=True, text=True)
return out.stdout.strip()
print("双引号挡住了 ; :", repr(greet_quoted("alice; echo PWNED"))) # ; 被引号包住,失效
print("但 $() 照样穿透 :", repr(greet_quoted("$(echo PWNED)"))) # 双引号不阻止命令替换
双引号挡住了 ; : 'Hello alice; echo PWNED'
但 $() 照样穿透 : 'Hello PWNED'
第一行看着成功了:; echo PWNED 被双引号包住,变成了字面文本,没有执行。于是你以为问题解决了。
第二行说明你没有。 双引号在 shell 里不阻止命令替换——$(...) 和反引号在双引号内部照样求值。所以 $(echo PWNED) 穿透了引号。
⚠️ 这是个特别危险的"看起来有效":双引号能挡住一部分元字符,于是测试里那几个常见 payload 都被挡下了,给你一种"加引号管用"的错觉。可攻击者只要换用 $(),引号就形同虚设。一个只在你测的那几个 payload 上有效的防御,比没有防御更坏——因为它让你以为安全了。
(顺便:单引号比双引号强,但一旦用户输入里自己带一个单引号把你的引号闭合掉,后面就又是 shell 的地盘了。手工给 shell 加引号,是在和一套你多半没完全掌握的引用规则较劲。)
三、argv 根治了 shell,但没根治"参数"
正确解法和第 1 篇说的一致:别把命令交给 shell 去解析。 用数组形式传参,每个元素就是一个参数,shell 从头到尾不参与。
import subprocess, tempfile, os
# 用 argv 数组:整个 name 是一个参数,shell 从不参与
def greet_argv(name):
return subprocess.run(["echo", "Hello", name],
capture_output=True, text=True).stdout.strip()
print("元字符全部失效:", repr(greet_argv("alice; echo PWNED $(id)")))
元字符全部失效: 'Hello alice; echo PWNED $(id)'
; echo PWNED $(id) 里所有元字符全部失效,因为没有 shell 来解析它们——整串被 echo 当成一个普通参数原样打印。这就是根治:不是"过滤掉危险字符",是根本没有那个会把它们当语法的解析器。
现在看这一篇专属的第二个坑,很多人不知道它:
argv 挡住了 shell 的解析,没挡住被调用的那个程序自己的解析。
下面这个功能是"把用户点名的文件排序后显示给他"。它规规矩矩用了 argv,一个字符串都没交给 shell:
import subprocess, tempfile, os
d = tempfile.mkdtemp()
open(os.path.join(d, "data.txt"), "w").write("banana\napple\n")
def sort_files(names, guard=False):
"""把用户点名的文件排序后显示。用了 argv 数组,shell 全程不参与。"""
argv = ["sort"] + (["--"] if guard else []) + names
r = subprocess.run(argv, cwd=d, capture_output=True, text=True)
return (r.stdout or r.stderr).strip().replace("\n", " / ")
print("正常用法 :", sort_files(["data.txt"]))
# 攻击者把"文件名"填成一个选项。sort 的 -oFILE 意思是"把结果写进 FILE"。
print("文件名以 - 开头 :", repr(sort_files(["-opwned.txt", "data.txt"])))
print("磁盘上冒出 pwned.txt :", os.path.exists(os.path.join(d, "pwned.txt")))
os.remove(os.path.join(d, "pwned.txt"))
print("同一个输入,加了 -- :", sort_files(["-opwned.txt", "data.txt"], guard=True))
print("磁盘上冒出 pwned.txt :", os.path.exists(os.path.join(d, "pwned.txt")))
正常用法 : apple / banana
文件名以 - 开头 : ''
磁盘上冒出 pwned.txt : True
同一个输入,加了 -- : sort: No such file or directory
磁盘上冒出 pwned.txt : False
看第三行。 一个"只读的排序功能",往磁盘上写了一个文件——文件名和内容都是攻击者说了算的。没有 shell,没有元字符,sort 只是老老实实地把 -opwned.txt 当成了它的 -o 选项。
这叫参数注入(argument injection):用户输入不再是"数据",而变成了你调用的那个程序的开关。
它的杀伤力完全取决于你调的是什么程序。很多常用命令行工具都有"能写文件"“能执行钩子"“能改变输出目标"的选项——一旦用户能让自己的输入变成这些选项,即使你规规矩矩用了 argv,也可能被撬开。(这里不展开具体武器化写法;记住那条判断:用户可控的参数若能以 - 开头,就得当成潜在的选项注入来防。)
最后两行是修复:-- 告诉 sort“到此为止,后面全是文件名,不再是选项”。同一个输入,这次 sort 去找一个真的叫 -opwned.txt 的文件,没找到,报错退出——没有文件被写出来。
subprocess.run(["somecmd", "--", user_input]) # -- 之后的一切都是位置参数,不再当选项
⭐ 注意这两个坑的关系:argv 拿掉了 shell 这个解析器,但 sort 自己也是一个解析器——它解析自己的命令行。第 1 篇那句"谁是解析器"在这里要问两遍。
⚠️ 第二遍是很多人想不通的地方。前面刚说完"argv 是根治”,这里又说它不够,听起来像自相矛盾。它不矛盾——argv 确实彻底根治了 shell 那一层,一个元字符都不剩。只是这一层之下还有一层,而那一层的解析器是你调用的那个程序本身。根治了一个解析器,不等于根治了所有解析器。
四、为什么它常年霸榜,以及一个特殊变体
命令注入的机制简单到近乎"上古”,可它一直在 OWASP 榜上。原因和第 1 篇 §四 一样:总有一个地方,你没意识到那儿有个 shell。
你以为在做 背后其实起了一个 shell
------------------------------------------------------
调一个命令行工具转码 subprocess(..., shell=True)
让 cron / 任务跑个脚本 脚本里 eval 了一段带用户数据的字符串
ping 一下用户填的主机名 system("ping " + host)
用模板/占位符生成命令行 拼好字符串再交给 shell
有一个变体值得单独知道——Shellshock(2014,CVE-2014-6271):
Bash 会把某些环境变量的值当成【函数定义】来解析并执行。
而 Web 服务器(CGI)会把 HTTP 头塞进环境变量。
于是一个精心构造的 User-Agent 头,就能让 bash 在解析环境变量时执行代码。
⭐ 它之所以经典,是因为连"拼命令"这一步都没有——开发者根本没调用任何用户输入拼成的命令,只是起了个会读环境变量的 bash 子进程。这是"谁是解析器"的极端案例:解析器(bash)在你完全没预期的地方,对你完全没当成代码的东西(一个 HTTP 头),做了求值。
它出现过的地方
展开在事件分析里:
2014.09 Shellshock / CVE-2014-6271 bash 把环境变量的值当函数定义解析,经 CGI 波及大量服务器
五、防御
① 首选:用 argv 数组,永不起 shell
subprocess.run(["cmd", "arg1", user_input]) # 对
subprocess.run(f"cmd arg1 {user_input}", shell=True) # 错,无论你怎么过滤
判断标准:你的代码里有没有一个字符串被交给 shell。 有,就是风险点;把它拆成数组。真的需要管道/重定向,就在程序里用管道 API(把多个 argv 进程用管道连接),而不是拼一条 shell 字符串。
② 能不调外部命令就不调
很多"调命令"其实有库可用:算哈希、压缩、图像处理、发 HTTP 请求,都有语言内的库。进程边界是最容易出注入的地方,能不跨就不跨。
③ 防参数注入
· 能白名单就白名单(主机名、文件名限定字符集,且不以 - 开头)
· 用户输入前加 -- 显式终止选项解析
· 清楚你调的那个程序有哪些"危险选项"(能写文件、能执行钩子的)
④ 纵深
最小权限 跑命令的进程别用 root,限制它的文件系统与网络出口
禁用 shell 代码规范/静态检查里禁止 shell=True 与 os.system 拼接用户输入
⚠️ 注意这里没有"过滤危险字符"这一条。原因就是第一节那张元字符表:你列不全,而漏一个就等于没防。argv 是把解析器拿掉,那才是根治。
六、这些防御的边界
① argv 不防参数注入。 见 §三。用了数组不等于万事大吉,还要管住"以 - 开头的用户输入"。
② 有些接口天生要 shell。 少数场景(复杂管道编排)确实难免 shell。那就把"由用户控制的部分"降到最小,且绝不让用户数据进入命令的结构位置(命令名、选项、管道符),只允许进入被 -- 保护的、最末端的数据参数。
③ 间接起 shell 防不胜防。 某些库函数、某些语言特性会在你没写 shell=True 的情况下也过一层 shell(历史上不同语言/版本都出现过)。涉及具体函数要查它这个版本到底走不走 shell——本篇不给"某函数一定安全"的结论。
关于时效:shell 的元字符语义极其稳定,这一篇的机制不会过时。会变的是各语言"起不起 shell"的默认行为和某些工具的危险选项清单,涉及具体版本时要核实。
七、本篇小结
把用户输入拼进命令,解析它的是 shell —— 而 shell 的元字符是一整套,不是一个分号。
⭐ 加引号救不了:双引号内 $() 反引号照样求值;单引号能被用户自带的单引号闭合
⭐ 根治 = 用 argv 数组,让 shell 根本不参与解析
⚠️ 但 argv 只挡住 shell,挡不住"被调程序自己的选项解析" → 参数注入
→ 管住以 - 开头的用户输入,或用 -- 终止选项解析
防御里【没有】"过滤危险字符"这一条 —— 你列不全,漏一个等于没防。
极端案例 Shellshock:连"拼命令"都没有,
只是起了个会读环境变量的 bash → 再次印证"谁是解析器"最难答对。
思考题
- §二 里双引号挡住了
;,却挡不住$()。如果改成用单引号包裹,而攻击者输入本身包含一个单引号,'echo "Hello {name}"'换成单引号包裹"echo 'Hello {name}'"后会发生什么?据此说明"手工加引号"为什么是条歧路。 - §三 的参数注入里,
echo -n只是少了个换行,看着无害。设想你调用的是一个"下载 URL 到文件"的工具,用户能让自己的输入以-开头,可能造成什么后果?(只需说明能力,不要写具体 payload。) - 为什么"最小权限运行"对命令注入是必要的纵深,却不能作为主防御?用 §五 ① 与 ④ 的关系说明。
- Shellshock 的触发点是"HTTP 头进了环境变量、bash 解析了环境变量"。用第 1 篇的三问(谁是解析器/拼接在哪/结构是否随数据变)分析它——其中"拼接在哪一步"这一问,答案有什么特别之处?
- 团队想加一条静态检查禁止命令注入。你会让它报哪些模式?(至少三个)它会漏掉 §四 表格里的哪一类?
- 有个需求确实需要 shell 的管道(
a | b | c)。在不给用户任何 shell 能力的前提下,你会怎么实现这条管道? - 把命令注入和第 3 篇的表达式注入放一起:两者的"根治"手法(argv 数组 / 换掉求值器)本质上是不是同一招?用第 1 篇的定义说明它们共同拿掉了什么。
相关:第 1 篇:注入的一般形式 · 第 3 篇:表达式语言注入 · Web 安全机制篇 · 事件分析