📘 第十一章 Web的攻击技术
📄 一句话概括

输入拼进 shell 命令,元字符就成了语法。 ;、&、|、反引号在不同平台上含义不同,但只要能碰到 shell 就能插进去。 根治的办法是不经过 shell,参数作为独立元素传进去。

📝 本节和前面几节的关系

上一节是数据变成 SQL,这一节是数据变成命令——宿主换成了操作系统。

一、元字符就是语法

服务端有时候要调外部程序:查 DNS、转图片、压缩文件。 如果代码写成 system("ping -c 1 " + host),那个 host 就是拼进去的。 host 一旦能塞进 ;,shell 就会把它当成「命令分隔符」:前半句还是 ping,后半句是攻击者的命令, 两条都会执行。 同一类元字符还有 &(后台执行/顺序执行)、|(管道)、反引号和 $()(命令替换)。 它们都是 shell 的语法,不是普通字符。 所以「输入里有没有危险字符」这个问题,答案取决于这段输入会被谁解析。 要判断的是解析器,不是字符表。

二、一条实测,两处意外

Demo 里五种输入各跑一遍:正常、分号、和号、管道、反引号。结果有两处和直觉不一样。 第一处:分号在本机没注进去。 因为本机的 shell 是 Windows 的 cmd.exe,它的命令分隔符是 &,不是 ;。 换成 Unix 的 bash,; 立刻就生效。 第二处:反引号也没生效,同样因为 cmd.exe 不做命令替换。真正成功的是「和号」和「管道」这两行。 这说明一件重要的事:同一个漏洞,换个平台,可用的载荷就不一样。 写测试用例时不能只按自己的机器来。

三、不经过 shell 就好了

修正版把命令改成列表:[python, echo, 查询, 输入],shell 完全不参与。 同样五种输入,全部被当成普通字符串原样输出,一行都没注进去。 这一条的机制值得记:列表传参时,进程收到的是一个已经切好的参数数组,没有「再解析一遍」的环节, 元字符就只是元字符。 如果非要用 shell(比如确实需要管道),就只能自己转义——而转义是最容易漏的, 因为每个 shell 的规则都不一样。能用列表就别用 shell。 还有一条底线:不要用固定的、写死的命令去执行用户可控的文件名。 真实案例里出事的往往是「转码」「解压」这种看起来无害的工具。

四、动手 Demo:五种输入,哪几种真注进去了

用 echo 和 whoami 两个无副作用命令做演示。 先把输入拼进 shell 命令跑一遍,看 whoami 有没有被顺带执行;再换成列表传参重跑。

# 朴素版:拼进 shell
cmd = u'echo 查询 %s' % user_input
subprocess.run(cmd, shell=True, capture_output=True)

# 修正版:不经过 shell
subprocess.run([u'echo', u'查询', user_input], capture_output=True)

实跑输出:

--------------------------------------------------------------------
OS 命令注入:分号之后那半句谁执行
--------------------------------------------------------------------
  这一段用 `echo` 和 `whoami` 两个无副作用命令做演示,不动任何文件。

  朴素版:把用户输入直接拼进 shell 命令
  手法         用户输入                   shell 实际输出                     注进去了吗
  正常输入       8.8.8.8                查询 8.8.8.8                     否
  分号         8.8.8.8; whoami        查询 8.8.8.8; whoami             否
  和号         8.8.8.8 & whoami       查询 8.8.8.8 
 / admin           是
  管道         8.8.8.8 | whoami       admin                          是
  反引号        8.8.8.8 `whoami`       查询 8.8.8.8 `whoami`            否

  `whoami` 本来不该被调用。哪几行它真的跑了,看最后一列。

  有一件事这里必须说明白:**本机是 Windows,shell 是 cmd.exe**。
  分号在 Unix 的 shell 里是命令分隔符,在 cmd.exe 里不是——所以上面「分号」那行没注进去,换成「和号」才成功。同一段代码,换个平台注入面就不一样。

  修正版:不经过 shell,参数作为一个独立元素传进去
    正常输入                 查询 8.8.8.8
    分号                     查询 8.8.8.8; whoami
    和号                     查询 8.8.8.8 & whoami
    管道                     查询 8.8.8.8 | whoami
    反引号                   查询 8.8.8.8 `whoami`

  区别                       列表传参时 shell 不参与解析
  元字符会怎样               被当成普通字符原样输出

  这就是为什么各语言都提供「参数数组」形式的进程调用。用它,元字符就只是元字符,没有特殊含义。

最后一列「注进去了吗」有两行是「是」:和号 和 管道。 它们的输出里只剩 admin,whoami 真的跑了。 分号 那一行是「否」——因为本机是 Windows,cmd.exe 不把 ; 当分隔符。 这条差异本身就是收获:载荷要跟着平台换。 反引号 也是「否」,同样是 cmd.exe 不做命令替换。在 bash 上这两行都会成功。 修正版五行的输出里都带着完整输入,包括 ; whoami、& whoami——它们被当成普通字符了, 一个都没执行。

30 秒实验

  1. 把 whoami 换成一个更有说服力的(比如列出当前目录),看输出差多少。
  2. 试试 %PATH%(Windows 的环境变量展开)能不能注进去——它是另一类元字符。
  3. 想一想:如果你的服务跑在 Linux 容器里,这个 Demo 的结论会怎么变?

常见误区

  1. 「过滤 ; 就安全了」——&、|、反引号、$() 都能用。
  2. 「我的代码不调外部命令,所以无关」——转码、压缩、查 DNS 都算调命令。
  3. 「同一个 payload 在哪都能用」——平台不同,分隔符和替换语法都不同。
自测三问(点开对答案) 1. 命令注入的成因是什么? —— 用户输入被拼进 shell 命令,输入里的元字符被当成语法解析。 2. 为什么列表传参能根治? —— shell 不参与,进程收到的是切好的参数数组,元字符没有特殊含义。 3. 为什么分号在本机没生效? —— 本机 shell 是 cmd.exe,命令分隔符是 `&` 不是 `;`。

命令是一类宿主。还有一类更隐蔽:输入被拼进了 HTTP 首部。

相关笔记