在CTF圈子里,"无数字字母RCE"几乎是每个做过Web题的人都会撞上的经典课题。我第一次看到这类题目名的时候,内心是拒绝的:eval把所有数字和字母全过滤了,剩下$()[];~^!这一堆特殊符号,还想执行命令?这不是开玩笑吗?但研究了一圈之后,我发现自己对PHP语法灵活性的理解还是太浅。这篇文章就从我的视角,把"无数字字母RCE"怎么来的、底层原理是什么、实战里怎么一步步组装、以及防御方怎么拆招,完整拆开聊一遍。适合正在刷CTF Web题的新手,也适合写PHP代码或者做WAF规则的同行做参考。
1. 从一道CTF题说起:当eval只放过特殊符号
1.1 题目给我的第一印象
那是一次线下赛,题目长得非常诚实,标题就叫"无数字字母RCE"。打开页面,源码直接甩在脸上:
<?php error_reporting(0); if (isset($_GET['code'])) { $code = $_GET['code']; if (preg_match('/[a-z0-9]/i', $code)) { die('no no no'); } eval($code); } else { highlight_file(__FILE__); }这段代码的逻辑很清楚:接收一个code参数,先用正则把所有字母和数字全部过滤掉,然后把剩下的字符串原封不动丢给eval执行。换句话说,我能传给eval的内容里,不允许出现任何一个小写字母、大写字母和数字。
常规的Web Shell思路到这里全部作废。system、exec、assert、phpinfo,凡是带字母的函数名都没法直接写;$_GET['cmd']这种也是死的,因为cmd是字母。我第一次看到这种过滤规则,第一反应是"无解",直接想放弃。
1.2 为什么安全从业者必须搞懂这种题
很多人觉得"无数字字母RCE"不过是CTF里人为构造的变态题目,实际业务环境根本遇不到。这个想法大错特错。真实开发中,确实会有开发者为了"防注入",手写一个过滤函数,把用户输入里的危险内容去掉,正则写得宽泛一点,字母数字一起拦掉的情况并不罕见。
更关键的是,WAF(Web应用防火墙)和防护规则的思路与这类题目高度重合:它们看到的是请求特征,而不是代码语义。攻击者如果能构造一个不含字母数字的恶意输入,很多基于"关键字匹配"的防护设备直接就瞎了。理解这类绕过的底层逻辑,是在给防御打基础。我自己在后来排查某次线上可疑请求时,就是靠这套思路反推出攻击者意图的。
1.3 破题方向的转变
真正让我转换思路的,是一个看似无聊的细节:PHP的变量名、函数名、字符串,本质上都是"字节序列"。过滤规则拦的是"字母数字的明文",但如果我能在运行时通过运算把这些明文"算"出来,过滤规则就形同虚设。
举个例子:正则拦下了字母a,但如果我用两个特殊字符做异或,运算结果恰好是a,那么这个a在请求里并不存在,它是进入PHP之后才"诞生"的。正则检查的是请求原文,而我要的是"运算结果",这两者之间有一条巨大的缝隙,所有无数字字母RCE的骚操作都在这个缝隙里完成。
顺着这个方向想下去,答案其实就藏在PHP的字符串运算、自增特性和动态函数调用机制里。下一节我展开讲。
2. 三把钥匙:取反、异或与自增的构造原理
要理解无数字字母RCE,必须先把PHP里三套最基础的字符构造手段吃透。它们不复杂,但组合起来非常灵活。
2.1 第一把钥匙:字符串按位取反
PHP的~运算符可以对字符串做按位取反操作。对字符串里每一个字节,先转成整数的ASCII码,取反之后再转回字符。最直白的用法是这样的:
<?php $s = "phpinfo"; $t = ~$s; // 得到一串几乎不可读的内容 echo $t; // 输出乱码 echo ~$t; // 还原成 phpinfo关键在于:phpinfo被取反后,得到的字符串里既没有字母也没有数字,全部是高位字符和特殊符号。过滤规则只检查"请求里出现什么字符",不会检查"这个字符执行时会不会变成别的字符"。所以如果我直接提交~"phpinfo"被取反后的乱码,正则扫描通过,而eval在内存里再取反一次,就能还原出phpinfo并执行。
不过这里有个很现实的问题:PHP代码里写字符串需要引号,而引号本身能不能用特殊字符拼出来?这就要看第二把钥匙了。
2.2 第二把钥匙:异或构造字符串
PHP的^运算符可以对两个字符串逐字节做异或。两个字符异或,会得到一个新的字符。只要我控制的输入里允许出现两个"合法特殊符号",我就能通过异或计算出任意需要的字母。
<?php $char1 = "`"; $char2 = "?"; $result = $char1 ^ $char2; // 两个非字母非数字字符,异或出一个字母 echo $result;这里""和"?"`都不是字母数字,但它们异或的结果可能恰好是某个小写字母或大写字母。也就是说,我可以用一堆"看起来人畜无害"的特殊符号,在运行时碰撞出字母和数字。
更有意思的是,PHP的字符串异或会把两个等长字符串的每个对应位分别异或。如果两个字符串长度不同,长出来的部分会被截断。利用这一点,可以构造任意长度的字符串,而不需要输入对应的字母数字。
异或是无数字字母RCE最常用的"钥匙",因为它的可控性最好。你给我特殊符号,我帮你算出字母,这个过程中正则完全没有报警依据。
2.3 第三把钥匙:PHP字符串自增特性
PHP里有一个特别诡异的特性:字符串可以自增。'a'++得到'b','z'++得到'aa'。如果以大写字母开头,'A'++得到'B','Z'++得到'AA'。这种自增只能在字符串上发生,而且是从最后一个字母字符开始递增的。
这个特性有什么用?如果我能先拿到任意一个字母字符,哪怕只是一个大写的A,我就能通过反复自增得到B、C、D一直到任意大写字母序列。再把大写字母做一次strtolower或者ASCII偏移处理,小写字母也出来了。
问题回到原点:第一个字母字符从哪来?答案五花八门。最经典的做法是把数组和字符串拼接。在PHP 5系列和部分PHP 7版本里,[] . ''这样的表达式会把数组转成字符串,结果就是"Array",于是我们白赚了一个大写的A:
<?php $_ = [] . ''; // 得到字符串 "Array"拿到A之后,一路自增下去,B、C、D……几乎所有大写字母都能拿到。这个技巧在早期PHP环境里非常稳定,也是大量题解的基础零件。
2.4 三把钥匙怎么选
实际构造时,这三把钥匙不是孤立的,经常要混着用:自增负责"从无到有"拿到字母,异或负责"精确碰撞"拿到需要的字符,取反负责"大段还原"整个字符串。我个人的经验是:能取反就取反,因为还原一个完整函数名只需要一对~,代码最短;取反搞不定(比如引号问题、括号问题)时,就转异或慢慢拼;自增适合环境比较宽松、允许重复写很多次++的场景。
3. 从下划线到命令调用:一次组装的全过程
原理讲完,现在进入实战环节。我用一个典型的构造思路,把"无数字字母RCE"从零到一的组装过程还原出来。需要提前说明:为了不让这篇文章变成一份攻击载荷手册,我不会给出完整可粘贴的最终脚本,只展示拼装逻辑和关键零件。理解思路之后,你完全可以在自己的测试环境里把它搭出来。
3.1 第一块拼图:得到下划线变量名
PHP变量名的规则比较宽松:$_、$__都是合法变量,下划线在变量名里是被允许的。如果我能构造出一个下划线,就能用它当变量名,再把各种运算结果存进去。
下划线字符_怎么来?最常用的办法是用两个特殊符号做异或。比如某个特殊字符和另一个特殊字符异或,结果恰好是下划线。由于PHP字符串异或是逐字节进行的,我可以用一个字节和另一个字节碰撞出_的ASCII码95。
还有一种思路是利用PHP数组的键名。在无字母数字的前提下,传入code参数的请求里,$_GET数组内部存在一些由P字符组成的键名,这些键名的内容不能在源码里直接写,但可以通过遍历数组取出来。把自己可控的内容藏在POST数据或者HTTP头里,再用get_defined_vars()之类的函数把它们捞出来,是后面要讲的进阶玩法。
3.2 第二块拼图:构造任意函数名
拿到变量名之后,理论上就有地方存"字符串"了。剩下的问题是:怎么把目标函数名,比如system、phpinfo、assert这类字符串,用允许的符号拼出来?
取反路线最直接。如果我能先构造出字符串phpinfo的反码,那么在代码里对它做一次~,就能变回phpinfo。但这个反码串本身也得用引号包起来,而引号在请求里可以用"和',它们都不在过滤正则的拦截范围里,所以这条路是通的。难点在于:反码串里那一堆乱码要能在URL里传输,有些高位字符在HTTP传输或者浏览器解析时会被转义,需要通过URL编码还是二次编码去规避,这是实操里的坑。
异或路线更灵活。我可以用很多组成对的特殊字符,拼出一个长度足够的目标字符串。先算好每个目标字符对应哪两个特殊符号的异或结果,然后把整串特殊符号用^运算符连接起来。这个过程中,代码里出现的全是特殊符号,没有字母数字,正则自然拦不住。
自增路线则适合在源码层面慢慢推:先用[] . ''拿到A,再一路++到S、Y、T等需要的大写字母,再想办法把大小写转换处理掉。
3.3 第三块拼图:动态函数调用与参数传递
PHP支持把函数名放在变量里,然后用$var()这样的语法调用。只要我的变量里存的是字符串system,执行$var("id")就等价于执行system("id")。
但这里又出现一个问题:system需要一个参数,而参数内容如果直接写id,两个字母id会被正则拦下来。怎么办?继续用同一个思路解决:把参数也变成特殊符号的运算结果,执行时再计算出来。比如先构造出id字符串,再传给被调用的函数。
如果目标是执行无参数函数,比如phpinfo(),那就不需要传参,构造起来相对容易。这也是为什么很多题目的第一步是先执行phpinfo验证代码执行能力,因为它的构造成本最低。
3.4 一次组装过程的推演
我把整个思路串成一条逻辑链:
- 用特殊符号异或得到
_,存入变量$_。 - 用取反/异或/自增中的任意一种,在另一个变量里还原出目标函数名。
- 调用
$_()动态执行。 - 如果需要传参,再把参数构造出来放进去。
这条链路上的每一步都依赖PHP的动态类型和运行时运算。正则过滤器看到的是静态请求内容,而我要的是执行时的一连串运算结果,两者之间存在天然的时间差,这就是绕过的本质原因。
我在自己搭的PHP测试环境里验证过这条链路:把过滤正则原样搬过来,再一点点替换掉构造中的字母数字,最终能稳定执行出phpinfo输出。看到页面上那一大堆PHP版本信息喷涌而出的时候,你会突然意识到,所谓的"无数字字母限制"其实只是拦住了最笨的写法。
4. 更硬的过滤:无参数RCE与隐藏的输入面
如果题目把过滤规则做得更绝,比如连特殊符号也拦掉一部分,只允许函数调用但完全不传参,那就进入"无参数RCE"的范畴了。这名字听着玄乎,但本质思路很简单:不通过参数传入攻击载荷,而是利用PHP已有的超全局变量和执行环境里本来的数据。
4.1 从哪里白嫖攻击数据
getallheaders()这个函数在不少Web环境里可用,它会返回所有的HTTP请求头。攻击者可以把恶意字符串放在自定义的HTTP头里,比如User-Agent、Referer、或者随便一个X-Custom-Header。由于HTTP头不在code参数里,过滤正则管不到,但它确实会进入PHP进程的运行时空间。
更常用的还有get_defined_vars(),它返回当前作用域下所有已定义变量的数组。如果你能通过某种方式往GET、POST、COOKIE或者$_FILES里塞内容,这些内容都在$_GET、$_POST等超全局变量里,get_defined_vars()能直接把整个数组拎出来,再配合数组操作提取出需要的那一段。
这些"从环境白嫖"的技巧把问题从"如何无中生有构造字母"变成了"如何从现有数据里提取字母",难度一下子就降下来了。因为服务器上往往已经存在大量包含字母和数字的数据,只要我能找到读取它们的方法,字母数字的限制就被绕过了。
4.2 数组操作是真正的幕后英雄
无数字字母RCE里,数组操作的地位被严重低估。[].''能白拿A,是因为数组转字符串;$_GET[]能读取键值,是因为超全局数组;get_defined_vars()能返回值,是因为数组的天然属性。几乎每一套trick背后都有数组的身影。
一个很有意思的小技巧是:$_=[]可以把空数组赋给变量,然后$_=@$_或者$__=++$_这些操作会改变数组的行为。进一步地,数组的键名可以是数字,键名本身甚至可以通过运算来指定。这些细节单独看没什么,组合起来就构成了极其灵活的原语库。
4.3 无参数构造的常见路线
无参数RCE最常见的路线是这样的:先用getallheaders()拿到HTTP头,在HTTP头里藏好命令字符串,再通过数组操作提取出来,最后动态调用执行函数。整个过程不需要一个字母数字出现在请求参数里,所有需要的数据都藏在请求的其他位置里。
这个路线的价值不只是CTF。真实WAF经常只对某个参数做深度过滤,而对HTTP头、Cookie、其他参数的检查力度明显薄弱。攻击者把恶意内容藏到薄弱位置,再通过PHP自身的函数搬运到执行点,这种"数据与执行分离"的套路至今仍在真实攻击中被反复使用。
5. 防御视角:让花式组装失去用武之地
聊了这么多攻击侧的思路,如果只停在"好神奇"这个层面,那这篇文章的价值就砍掉了一大半。我始终认为,理解了构造原理之后,最该做的事情是回到防御端,想想怎么让这些绕过的计算过程全部白费。
5.1 过滤与检测资源的对比
先看一套简单的对比:
| 层面 | 常见做法 | 短板 |
|---|---|---|
| 入口过滤 | 正则拦字母数字或危险关键字 | 防不住运行时运算出来的字符 |
| 函数禁用 | 禁用system、exec等危险函数 | 漏掉assert、array_map等间接执行方式 |
| 动态函数调用 | 限制变量函数调用 | 防不住无参数RCE对超全局变量的利用 |
| 运行时监控 | 跟踪危险函数调用链 | 需要较强的插桩能力,生产环境部署成本高 |
从这张表能看出来,单一手段都有明显死角。攻击者在赌"你的过滤只看表面",所以防御方也必须想得更深。
5.2 过滤规则设计的正确姿势
如果必须在入口做过滤,我强烈建议放弃"黑名单+字母数字"的思路,换成"白名单"思路:明确列出允许出现的字符集合,只要不在白名单里,直接拒绝。白名单方案能让异或、取反这类需要大量特殊符号参与的计算瞬间失去空间,因为很多运算所依赖的特殊符号也进不来了。
不过白名单也不是万能的。如果业务本身就要接收用户输入里的各种符号,白名单可能误伤正常请求。这时候就要靠执行层面的防御来兜底。
5.3 执行层的纵深防御
真正靠谱的防御一定要到执行层。具体来说就三件事:
第一,禁用一切不必要的高危函数。可以用PHP的disable_functions配置,把system、exec、shell_exec、passthru、proc_open、popen这些命令执行函数全部关掉。如果业务确实需要执行系统命令,那也要限定到特定白名单命令,而不是直接透传用户输入。
第二,对eval、assert这类动态代码执行函数保持零容忍。正常业务几乎不需要用eval处理用户可控输入,凡是出现这种写法的地方,都应当做代码审计重点。能用模板引擎、能用数据绑定解决的,就彻底不要用eval。
第三,开启PHP的open_basedir,把文件系统访问限制在项目目录内。这不是专门针对无数字字母RCE的,但能显著压缩攻击成功后的利用面。
5.4 检测侧怎么发现这类攻击
日志分析是最后一道防线。无数字字母RCE的请求特征其实很明显:请求参数里通常含有大量百分号编码、高位字符、连续异或符号,或者看似无关的符号堆叠。如果监控系统能看到请求参数的内容,完全可以给"参数中不含字母数字却进入动态执行点"这个行为设置告警。
我在做日志复核时,通常会重点看三类请求:参数里出现eval、assert关键字(哪怕周围全是乱码);参数中包含~、^且后面紧跟括号或变量的请求;以及同一请求中GET和POST、Header差异巨大的请求。这些特征不绝对,但能帮你快速定位可疑样本。
另外,如果公司有条件,建议在PHP层面装一个简单hook,记录所有动态函数调用的目标函数名。这不需要太重的组件,用AOP或者php.ini的auto_prepend_file都能做到轻量监控。提醒一次也好,刻意过滤也好,关键是要能看见。
5.5 依赖组件同样需要关注
无数字字母RCE不只出现在手写过滤的场景里,一些CMS插件、低代码平台的外部输入处理模块也出过类似问题。我遇到过某次告警,攻击者根本不是直接打PHP文件,而是先找到某个存在动态调用的组件入口,把整个恶意载荷拆成几份塞进不同输入点,再让组件自己拼装执行。所以防御清单里一定要有"梳理项目中所有动态调用点"这一项,不只是看入口过滤。
6. 踩坑复盘:我在这类题里犯过的错
最后聊几个实操中的教训,都是我用时间换来的。
6.1 太执着于构造一个超级载荷
我最初做题的时候,总想一次性写出一个完美的无数字字母payload,把所有零件都塞进一行。结果就是调试过程极其痛苦,一会儿URL编码被吞,一会儿异或结果算错,一会儿引号没闭合。后来我学会拆开调试:先在本地PHP环境里单独验证取反、异或、自增每一段的输出,确认无误后再组装。这个习惯让我少踩了很多坑。
6.2 忽略了PHP版本差异
PHP 5和PHP 7在数组转字符串、字符串自增行为上有些细微差别,PHP 8又调整了一些隐式转换规则。我在本地用的是PHP 7,但题目环境是PHP 5,导致[] . ''的返回结果和预期不符。遇到这种问题,第一时间去确认目标环境的PHP版本,按照版本来选择构造方案。
6.3 以为只有命令执行才叫RCE
有些题目不需要直接命令执行,只需要能调用任意无参数函数,输出某个文件内容或者读取配置,就已经算拿到关键信息了。我一开始死磕system("cat /flag"),绕了半天,最后发现题目预期只是phpinfo()就能看到flag。所以做这类题的时候,先尝试最轻量的执行验证,别一上来就奔着重型命令去打。
6.4 对URL编码不够敏感
无数字字母RCE的载荷在传输过程中经常要经过URL编码层。同一个字符,在浏览器地址栏、在Burp套件、在curl命令里,解析出来的结果可能不一样。我吃过好几次亏:在Burp的Repeater里跑得好好的,放到脚本里就失效,最终发现是%的编码层没处理对。建议所有测试统一用工具,不要跨工具混用。
6.5 正向防御时别过度迷信过滤器
回头来看,这类攻击最有价值的启示不是"怎么绕过",而是"绕过永远走在静态过滤前面"。如果你负责一个项目的安全设计,把希望全部寄托在一个层层加码的输入过滤器上,迟早会出问题。真正稳妥的方案是分层防御:入口白名单、执行层禁危险函数、日志层做行为告警,三层各管一段,哪怕某一层被绕过,后面的防线仍然能兜住。我在自己的实验环境里同时开这三层防线之后,再把完整的攻击载荷打上去,发现即便入口层被穿透,执行层也会因为函数被禁用而直接失败,日志里却清清楚楚记录了一次"动态调用尝试"。
这也是我写了这么大一篇的真正目的:研究这类攻击的目的不是教人怎么攻,而是让人在防守时能画出完整的攻击路径,然后沿着路径反推,逐层堵漏。