2023年夏天,存命某中型电商平台的令注后台运维系统被拖库了。攻击者没用什么零日漏洞,入漏也没绕过WAF,存命只是令注在一个查询订单的接口里,把 ?入漏id=1001 换成了 ?id=1001;cat /etc/passwd。服务器照单全收,存命把系统用户列表原样吐了回来。令注事后复盘,入漏开发人员说了一句很典型的存命话:“我以为前端已经校验过了。”
这就是令注命令注入。它不挑语言,入漏不挑框架,存命甚至不挑你用了多少层安全设备。令注只要你的入漏代码里出现 system()、exec()、popen()、Runtime.exec() 这类函数,并且参数里混进了用户能碰到的数据,攻击者就能把一条原本查订单的指令,变成删库、反弹shell、或者横向渗透的跳板。
很多人把命令注入和SQL注入混为一谈。区别在于:SQL注入是让数据库执行你的SQL片段,命令注入是让操作系统执行你的shell命令。后者更直接,也更危险。数据库通常还有权限隔离,而一个以 www-data 身份运行的Web进程,往往能读配置文件、能连内网、能写定时任务。攻击面大得离谱。
我见过一个真实的代码片段,来自某开源CMS的文件管理模块:
$filename = $_GET['file']; system("tar -czf backup.tar.gz " . $filename); 开发者大概觉得,$filename 就是个文件名,能出什么事?但攻击者传 file=;rm -rf /var/www,命令就变成了 tar -czf backup.tar.gz ;rm -rf /var/www。分号一加,前一条命令失败与否都不影响后一条执行。更隐蔽的写法是用 | 或者 &&,甚至用反引号 `id` 把结果嵌入参数里。你根本不需要完整控制命令,只要控制参数的一部分就够了。
防御这件事,很多人第一反应是“过滤特殊字符”。黑名单永远漏。今天你禁了分号,明天攻击者用换行符 %0a;你禁了 |,人家用 $IFS 绕过空格限制。真正靠谱的做法只有两条:一是别把用户输入拼进命令行,二是如果非拼不可,用参数化接口。
PHP里用 escapeshellarg() 和 escapeshellcmd(),Java里用 ProcessBuilder 的列表参数形式而不是字符串拼接,Python里用 subprocess.run([...]) 而不是 shell=True。这些不是可选项,是底线。但即便用了这些函数,也得注意顺序和上下文。escapeshellarg 在Windows和Linux下的行为就不一样,跨平台代码里很容易翻车。
另一个容易被忽略的点是:命令注入不一定发生在你直接调用系统命令的时候。有些库、有些框架的底层实现会偷偷调shell。比如某些图片处理库调用ImageMagick的convert命令,某些PDF生成工具调用wkhtmltopdf。你传进去的“文件名”或者“URL”,最后都变成了命令行参数。供应链里的每一层都可能把风险放大。
我自己的判断是:命令注入漏洞的数量在下降,但单次利用的破坏力在上升。因为现在的系统更复杂,一个Web进程能触达的资源比十年前多得多。容器化并没有解决问题——很多容器以root运行,挂载了docker.sock,或者共享了宿主机网络。一旦命令注入成功,攻击者拿到的可能不只是当前容器,而是整个编排集群。
如果你现在正在写代码,看到 exec、system、popen 这几个词,先停下来。问自己一句:这个参数,用户能不能碰到?如果能,改掉。别等扫描器报警,别等甲方发函,别等数据被挂到暗网。命令注入这件事,修起来不难,难的是你根本不知道它藏在哪一行。