首页 / 资讯中心 / 文章详情

无数字字母RCE深度拆解:从PHP异或取反到安全防御实战

无数字字母RCE深度拆解:从PHP异或取反到安全防御实战 ★ FEATURED ARTICLE
在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里人为构造的变态题目实际业务环境根本遇不到。这个想法大错特错。真实开发中确实会有开发者为了防注入手写一个过滤函数把用户输入里的危险内容去掉正则写得宽泛一点字母数字一起拦掉的情况并不罕见。更关键的是WAFWeb应用防火墙和防护规则的思路与这类题目高度重合它们看到的是请求特征而不是代码语义。攻击者如果能构造一个不含字母数字的恶意输入很多基于关键字匹配的防护设备直接就瞎了。理解这类绕过的底层逻辑是在给防御打基础。我自己在后来排查某次线上可疑请求时就是靠这套思路反推出攻击者意图的。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得到bz得到aa。如果以大写字母开头A得到BZ得到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 正向防御时别过度迷信过滤器回头来看这类攻击最有价值的启示不是怎么绕过而是绕过永远走在静态过滤前面。如果你负责一个项目的安全设计把希望全部寄托在一个层层加码的输入过滤器上迟早会出问题。真正稳妥的方案是分层防御入口白名单、执行层禁危险函数、日志层做行为告警三层各管一段哪怕某一层被绕过后面的防线仍然能兜住。我在自己的实验环境里同时开这三层防线之后再把完整的攻击载荷打上去发现即便入口层被穿透执行层也会因为函数被禁用而直接失败日志里却清清楚楚记录了一次动态调用尝试。这也是我写了这么大一篇的真正目的研究这类攻击的目的不是教人怎么攻而是让人在防守时能画出完整的攻击路径然后沿着路径反推逐层堵漏。
阅读完成 · 觉得有帮助?
咨询建站