失效的假设 “我只是拼了一条命令,用户填的是参数”
所属组 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 → 再次印证"谁是解析器"最难答对。

思考题

  1. §二 里双引号挡住了 ;,却挡不住 $()。如果改成用单引号包裹,而攻击者输入本身包含一个单引号,'echo "Hello {name}"' 换成单引号包裹 "echo 'Hello {name}'" 后会发生什么?据此说明"手工加引号"为什么是条歧路。
  2. §三 的参数注入里,echo -n 只是少了个换行,看着无害。设想你调用的是一个"下载 URL 到文件"的工具,用户能让自己的输入以 - 开头,可能造成什么后果?(只需说明能力,不要写具体 payload。)
  3. 为什么"最小权限运行"对命令注入是必要的纵深,却不能作为主防御?用 §五 ① 与 ④ 的关系说明。
  4. Shellshock 的触发点是"HTTP 头进了环境变量、bash 解析了环境变量"。用第 1 篇的三问(谁是解析器/拼接在哪/结构是否随数据变)分析它——其中"拼接在哪一步"这一问,答案有什么特别之处?
  5. 团队想加一条静态检查禁止命令注入。你会让它报哪些模式?(至少三个)它会漏掉 §四 表格里的哪一类?
  6. 有个需求确实需要 shell 的管道(a | b | c)。在不给用户任何 shell 能力的前提下,你会怎么实现这条管道?
  7. 把命令注入和第 3 篇的表达式注入放一起:两者的"根治"手法(argv 数组 / 换掉求值器)本质上是不是同一招?用第 1 篇的定义说明它们共同拿掉了什么。

相关第 1 篇:注入的一般形式 · 第 3 篇:表达式语言注入 · Web 安全机制篇 · 事件分析