跳转至

命令执行与命令注入

本文中的示例仅用于自己拥有或明确获授权的本地 CTF 靶场。示例命令只输出文本或当前用户信息,不包含持久化、反向连接、破坏数据或规避审计的内容。

一、先区分四类问题

“命令执行”常被用作宽泛称呼,但审计和修复时必须区分数据最终进入了哪一种解释器。

类型 危险汇点 典型根因 主要修复方向
OS 命令注入 Shell 或命令解释器 把输入拼进命令字符串 不调用 Shell;参数数组;固定映射
代码执行 PHP/Python/JS 等语言运行时 不可信数据进入 eval、动态加载或反序列化链 删除动态求值;使用数据解析器和固定分派
模板注入 服务端模板引擎 把用户输入当模板源码编译 用户输入只作为模板数据;启用沙箱并缩小能力
表达式/eval 注入 表达式引擎、规则引擎、SQL 外的 DSL 把输入当表达式而非值 结构化 AST、允许的操作符、参数绑定

它们可能最终都表现为“执行了 whoami”,但修复边界不同。例如对 Shell 参数做转义不能修复 PHP eval(),模板 HTML 转义也不能阻止模板引擎执行服务端对象方法。

本文重点讨论 OS 命令注入。过滤绕过为何反复发生,见配套文章命令执行过滤绕过。

二、命令注入如何产生

后端有时会调用系统工具完成压缩、媒体处理、网络诊断或版本查询。如果开发者把用户输入拼接进字符串,再交给 Shell 解释,输入就可能从“参数数据”变成“命令语法”。

不可信输入 + 字符串拼接 + 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 的参数数组。输入验证、最小权限、超时和隔离是必要的纵深措施,但不能替代结构性边界。