命令执行与命令注入¶
本文中的示例仅用于自己拥有或明确获授权的本地 CTF 靶场。示例命令只输出文本或当前用户信息,不包含持久化、反向连接、破坏数据或规避审计的内容。
一、先区分四类问题¶
“命令执行”常被用作宽泛称呼,但审计和修复时必须区分数据最终进入了哪一种解释器。
| 类型 | 危险汇点 | 典型根因 | 主要修复方向 |
|---|---|---|---|
| OS 命令注入 | Shell 或命令解释器 | 把输入拼进命令字符串 | 不调用 Shell;参数数组;固定映射 |
| 代码执行 | PHP/Python/JS 等语言运行时 | 不可信数据进入 eval、动态加载或反序列化链 |
删除动态求值;使用数据解析器和固定分派 |
| 模板注入 | 服务端模板引擎 | 把用户输入当模板源码编译 | 用户输入只作为模板数据;启用沙箱并缩小能力 |
表达式/eval 注入 |
表达式引擎、规则引擎、SQL 外的 DSL | 把输入当表达式而非值 | 结构化 AST、允许的操作符、参数绑定 |
它们可能最终都表现为“执行了 whoami”,但修复边界不同。例如对 Shell 参数做转义不能修复 PHP eval(),模板 HTML 转义也不能阻止模板引擎执行服务端对象方法。
本文重点讨论 OS 命令注入。过滤绕过为何反复发生,见配套文章命令执行过滤绕过。
二、命令注入如何产生¶
后端有时会调用系统工具完成压缩、媒体处理、网络诊断或版本查询。如果开发者把用户输入拼接进字符串,再交给 Shell 解释,输入就可能从“参数数据”变成“命令语法”。
危险 PHP API 和模式包括:
system()、exec()、shell_exec()、反引号运算符。passthru()、popen(),以及字符串形式的proc_open()。- 把拼接后的字符串传给
/bin/sh -c、cmd.exe /c或 PowerShell。 - 间接调用脚本,而脚本内部再次拼接命令。
风险不仅来自“命令名”参数。文件名、目录、选项、环境变量、主机名、压缩包条目和数据库中的历史值都可能到达危险汇点。
三、Shell 元字符类别¶
了解元字符是为了识别危险边界和设计测试,不是为了维护黑名单。具体解释取决于 Shell、引用状态和平台。
3.1 POSIX Shell¶
| 类别 | 常见语法 | 作用 |
|---|---|---|
| 命令分隔/条件执行 | ;、换行、&、&&、|| |
结束、后台运行或按状态连接命令 |
| 管道 | | |
把一个进程的输出连接到另一个进程 |
| 命令替换 | $(...)、反引号 |
先执行内部命令,再把输出代入 |
| 重定向 | >、>>、<、2> |
改变标准输入输出目标 |
| 参数与通配 | 空白、*、?、[...] |
切分参数或展开文件名 |
| 变量与展开 | $VAR、${...}、~、花括号展开 |
由 Shell 生成新文本 |
| 引用与转义 | 单引号、双引号、反斜杠 | 改变其他字符的解释方式 |
即使过滤了分号,换行、命令替换、参数注入或被调用程序自身的危险选项仍可能产生影响。因此,“禁止几个字符”没有建立可靠边界。
3.2 Windows cmd.exe¶
常见语法包括:
&、&&、||用于命令连接。|用于管道,>、>>、<用于重定向。%NAME%与启用延迟扩展后的!NAME!用于环境变量展开。^是常见转义字符,双引号与反斜杠的行为取决于调用层。- CR/LF、括号和批处理上下文会改变解析结果。
3.3 PowerShell¶
PowerShell 不是 cmd.exe 的同义词。它拥有自己的解析器、对象管道、变量、子表达式和调用运算符。单引号、双引号、反引号和 --% 等规则也与 cmd.exe 不同。
Windows 上经常同时经过多层解析:PHP/运行库先构造命令行,随后 cmd.exe 或 PowerShell 解析,被调用程序再按自己的规则拆参数。只在其中一层调用转义函数,很难证明端到端安全。
四、回显与盲命令注入¶
4.1 有回显¶
应用直接返回标准输出、标准错误或退出码,测试者能看到额外输出。这类问题容易确认,也容易泄露系统信息。
4.2 盲注入¶
应用不显示命令结果,但执行差异仍可能通过以下信号体现:
- 响应时间差异。
- HTTP 状态码或统一错误页面的细微变化。
- 自有本地文件、日志或测试接收器中的可观察事件。
- 后台任务状态或资源消耗变化。
在本地实验中,应优先通过应用日志、退出码和可控测试桩确认,不要制造长时间延迟或外连。时间差只是推断信号,网络抖动、进程调度和缓存都会造成误判,应重复采样并设置严格超时。
五、本地 PHP 易受攻击示例¶
下面代码仅用于隔离靶场。它试图打印一个标签,但把输入拼入 Shell 字符串:
<?php
// 仅在隔离的本地 CTF 靶场运行。
$tag = $_GET['tag'] ?? 'guest';
$command = "printf 'Current user: '; whoami; printf 'Tag: " . $tag . "\n'";
echo '<pre>' . htmlspecialchars(shell_exec($command) ?? '', ENT_QUOTES, 'UTF-8') . '</pre>';
问题不在于 printf 或 whoami 本身,而在于 $tag 与 Shell 语法处于同一个字符串中。只要输入能够改变引用边界、增加分隔符或触发展开,数据就可能被解释为语法。
用 escapeshellarg() 包裹单个参数在某些明确的 POSIX 场景中可降低风险,但它不是首选架构:平台规则不同,命令字符串仍会经过 Shell,被调用程序还可能把以 - 开头的值解释为选项。
六、安全修复一:不调用外部命令¶
如果需求只是格式化和展示文本,直接使用语言 API:
<?php
$tag = $_GET['tag'] ?? 'guest';
if (!is_string($tag) || !preg_match('/\A[a-zA-Z0-9_-]{1,32}\z/', $tag)) {
http_response_code(400);
exit('Invalid tag');
}
echo '<pre>' . htmlspecialchars("Tag: {$tag}\n", ENT_QUOTES, 'UTF-8') . '</pre>';
这是最强的结构性修复:没有 Shell,也没有不必要的子进程。输入验证仍然用于保证业务数据格式,而不是尝试识别所有攻击字符串。
七、安全修复二:参数数组¶
确实需要调用外部程序时,使用参数数组,并显式绕过 Shell。下面示例只调用固定路径的 printf:
<?php
$tag = $_GET['tag'] ?? 'guest';
if (!is_string($tag) || !preg_match('/\A[a-zA-Z0-9_-]{1,32}\z/', $tag)) {
http_response_code(400);
exit('Invalid tag');
}
$process = proc_open(
['/usr/bin/printf', 'Tag: %s\n', $tag],
[
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes,
null,
['PATH' => '/usr/bin:/bin', 'LANG' => 'C'],
['bypass_shell' => true]
);
if (!is_resource($process)) {
http_response_code(500);
exit('Process could not start');
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
if ($exitCode !== 0) {
error_log('printf failed: ' . $stderr);
http_response_code(500);
exit('Command failed');
}
echo '<pre>' . htmlspecialchars($stdout, ENT_QUOTES, 'UTF-8') . '</pre>';
安全要点:
- 可执行文件使用绝对路径,不受
PATH搜索和同名程序影响。 - 命令和参数按数组传递,不构造 Shell 字符串。
- 环境变量最小化且显式指定。
- 用户输入先按业务格式校验。
- 标准错误只进入服务端日志,不原样暴露内部路径。
- 设置进程超时和输出上限;PHP 原生
proc_open需要外围逻辑负责终止超时进程。
Windows 上是否完全绕过命令解释器取决于 PHP 版本和启动方式。应在实际部署平台验证参数边界;若运行库无法提供可靠的数组 API,使用专门的作业服务或固定包装程序。
八、安全修复三:严格功能映射¶
如果用户只是选择一个预定义动作,不要让用户提交可执行文件名:
<?php
$actions = [
'identity' => ['/usr/bin/whoami'],
'version' => ['/usr/bin/printf', 'lab-tool 1.0\n'],
];
$action = $_GET['action'] ?? '';
if (!is_string($action) || !array_key_exists($action, $actions)) {
http_response_code(400);
exit('Unknown action');
}
$process = proc_open(
$actions[$action],
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
null,
['PATH' => '/usr/bin:/bin', 'LANG' => 'C'],
['bypass_shell' => true]
);
映射表同时固定了程序和参数结构。不要把映射结果再次拼入 /bin/sh -c,否则边界会重新消失。
九、参数注入不是命令分隔符注入¶
即使不经过 Shell,用户输入仍可能被目标程序当成选项。例如某些工具的参数支持读取配置、指定输出文件或加载插件。修复时还需:
- 按业务允许值验证参数,而不是允许任意字符串。
- 在工具支持时用
--标记选项结束。 - 禁止用户控制输出路径、配置路径、插件、解释器或脚本路径。
- 阅读目标程序的参数语义,把它当成独立攻击面。
- 使用专用库代替功能复杂的命令行工具。
参数数组解决的是 Shell 注入,不会自动使被调用程序的所有功能安全。
十、代码执行、模板与 eval 的修复¶
10.1 动态代码执行¶
避免将用户输入传入 eval()、动态 include、反序列化回调或运行时编译接口。需要计算表达式时,使用只支持明确操作符的解析器,将输入解析为受限 AST;需要选择处理器时,使用固定映射。
$formatters = [
'upper' => static fn(string $value): string => strtoupper($value),
'lower' => static fn(string $value): string => strtolower($value),
];
10.2 模板注入¶
模板文件应由开发者控制,用户输入只作为数据传给模板。不要把用户提交的字符串作为模板源码调用 render/compile。确有用户模板需求时,应使用专门的受限语言、禁用对象遍历和任意函数调用,并在隔离进程中执行。
10.3 多层解释器¶
特别警惕“应用语言 → Shell → PowerShell/Python → 目标工具”的链条。每增加一层解释器,就增加一套引用、编码、环境和权限规则。最可靠的修复是删除中间解释器并调用结构化 API。
十一、最小权限与隔离¶
预防注入是第一目标,最小权限用于降低漏网问题的影响:
- Web 服务使用无登录权限的专用低权限账号运行。
- 只授予必需目录的读写权限,代码和配置默认只读。
- 不让 Web 进程持有部署密钥、云管理权限或容器控制接口。
- 使用容器/沙箱、系统调用限制和强制访问控制,但不要把容器视为完整安全边界。
- 将高风险媒体转换等任务放到隔离工作进程,限制 CPU、内存、进程数、网络和执行时间。
- 使用固定、最小的环境变量,避免从请求继承代理、语言运行时或加载器配置。
- 记录可执行文件、固定动作 ID、退出码和耗时,不记录敏感参数全文。
十二、测试清单¶
仅在授权或本地靶场中执行:
- 搜索所有进程创建、Shell、
eval、动态模板和动态加载 API。 - 从每个危险汇点反向追踪 HTTP、数据库、文件、队列和第三方输入。
- 确认命令调用使用固定可执行文件和参数数组,没有
sh -c、cmd /c等中间层。 - 对 POSIX 与 Windows 分别测试分隔、换行、引用、变量、通配和重定向类别。
- 测试以
-开头的值和目标程序危险选项,识别参数注入。 - 检查 URL 解码、Unicode 规范化和多层转义是否在验证后改变输入。
- 对无回显路径使用本地日志和测试桩确认,避免外连和长时间延迟。
- 验证超时、输出上限、并发限制和失败后的子进程清理。
- 确认 Web 账号无法写代码目录、读取无关密钥或访问管理接口。
- 确认错误响应不返回命令行、工作目录、环境变量和堆栈。
- 为固定映射和参数验证添加单元测试,为进程封装添加集成测试。
十三、防御清单¶
- 能用语言库完成的功能不启动外部程序。
- 删除不可信数据到 Shell、模板源码和
eval的路径。 - 必须启动进程时使用参数数组、绝对路径和最小环境。
- 用户选择映射到固定动作,不允许提交程序名或完整命令。
- 参数按业务语义使用允许列表,并处理选项注入。
- 高风险任务在低权限、无网络或受限网络的隔离工作进程中执行。
- 对进程设置时间、内存、输出、并发和文件系统限制。
- 保留可关联的审计日志和安全告警。
十四、总结¶
命令注入的根因不是“漏过滤了一个符号”,而是数据与可执行语法共享了解释器。首选修复是不用外部命令;其次是固定功能映射和不经过 Shell 的参数数组。输入验证、最小权限、超时和隔离是必要的纵深措施,但不能替代结构性边界。