ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PHP文件包含与无字母数字RCE:从CTF看Web审计实战

2026/9/16 4:47:29 拓冰建站 浏览量
PHP文件包含与无字母数字RCE:从CTF看Web审计实战 1. 拿到题先别急着找flag[BJDCTF2020]ZJCTF不过如此这题的名字确实有点挑衅。实际做下来你会发现真正卡人的地方并不在找flag本身而在你对PHP几个冷门特性的熟练度——文件包含、伪协议、正则替换、以及最后那个无字母数字RCE。整套题串下来基本就是一套完整的Web审计入门套餐尤其适合刚接触CTF、打算从会做题过渡到看得懂PHP的朋友。开局访问目标地址页面直接展示了当前PHP源码这是出题人故意放开的第一道门。代码很短核心逻辑如下error_reporting(0); $text $_GET[text]; $file $_GET[file]; if(isset($text)(file_get_contents($text,r)I have a dream)){ echo brh1.file_get_contents($text,r)./h1/br; if(preg_match(/flag/,$file)){ die(Not now!); } include($file); //next.php } else{ highlight_file(__FILE__); }第一眼看上去条件很明确text参数的内容经过file_get_contents读取后必须严格等于字符串I have a dream才会进入下面的文件包含环节。而file参数里不能出现flag这几个字母。很多人看到file_get_contents($text)的第一反应是这不就是一个普通的读取文件操作吗随便传一个本地文件名就行。但仔细看它比较的是文件内容而不是文件名。你传text/etc/passwd读出来的是系统文件内容根本不可能等于那句英文。所以这里必须用协议流构造可控内容而不是真的去读一个存在的文件。我试过的最快做法是data://协议GET /?textdata://text/plain,I%20have%20a%20dreamfilenext.php HTTP/1.1step使用PHP的data://text/plain,伪协议把后面的内容当作纯文本返回file_get_contents读到的就是你要的内容自然满足比较条件。要注意的点是URL中的空格必须编码成%20否则会被浏览器和服务器解析成参数分隔直接导致text的值断裂。当然更通用的方案是php://input。因为php://input可以读取POST请求的原始请求体所以只要在POST body里直接写I have a dreamfile_get_contents同样能拿到这个字符串。两种方式都能过我更推荐后者因为实际做题时php://input的兼容性更好有些环境对data://有限制但php://input几乎不会出幺蛾子。过了第一关接下来就要面对include($file)了。file被不允许包含flag所以你想直接fileflag.php是行不通的。但题目代码里写了注释//next.php聪明人一眼就懂出题人其实是在提示你先去包含next.php真正的第二关在那里。于是我们自然想到用php://filter流包装器把next.php源码读出来。这里有个很重要的技巧include一个PHP文件时如果文件里有?php开始和?结束的代码这些代码会直接被执行不会回显给你看源码。所以我们要用php://filter/convert.base64-encode先把内容转换成base64这样PHP就不会执行里面的代码而是输出base64字符串。payload这样写GET /?textphp://inputfilephp://filter/convert.base64-encode/resourcenext.php HTTP/1.1 I have a dream拿到返回的base64字符串后用PHP的base64_decode()或者任意在线工具解码很快就看到了下一关的源码。这里要额外提一嘴preg_match(/flag/,$file)检查的是file参数最终的值而php://filter/convert.base64-encode/resourcenext.php里面并没有flag这几个字母所以这个正则检查顺利被绕过了。类似的思路在真实渗透里也很常见你想读一个敏感文件但WAF或代码对文件名里的关键词做了拦截这时可以找路径写法、URL编码、伪协议包装等手段绕过关键字检查。这里还有一个很容易被忽略的小坑php://filter的base64输出会多出换行和空白有时候还会因为编码问题出现乱码。拿到响应之后最好先确认一下输出是否完整再放进去解码。如果base64字符串里混进了HTML标签或其他输出会导致解码失败这种情况下可以先把响应保存成文件再用命令处理。2. next.php里的关键代码解码后的next.php代码如下error_reporting(0); session_start(); $id $_GET[id]; $_SESSION[id] $id; function complex($re, $str) { return preg_replace($re, (, $str); } $var flag; for ($i 0; $i strlen($var); $i) { $re / . $var[$i] . /; $var complex($re, $var); } if (isset($_GET[id])) { if (!preg_match(/[a-zA-Z]/i, $id)) { eval($id); } else { die(hacker!!!); } }这段代码一眼看去挺迷惑的complex函数循环执行了几次正则替换看起来似乎只是把字符串flag里的字母挨个替换成左括号跟后面的eval有什么关系实际做题时很多人卡在这里觉得这段代码根本不影响后面的eval于是直接忽略了它。先看看这个循环在干什么。初始时$var flag循环四次每次取出$var的第$i个字符拼成正则表达式的样子然后调用preg_replace。第一次$re /f/把字符串里的f替换成(于是$var变成(lag。第二次$re /l/把l替换成(得到((ag。第三次得到(((g第四次得到((((。运行完这个循环$var变成四个左括号看起来像是为了凑一个正则表达式分组。但仔细想一下这四个左括号根本不会参与后面的eval为什么要写这么一段真实原因是这段代码的意图并不是让你去关心循环的最终结果而是告诉你$var本身是可控的或者说preg_replace的第一个参数是动态拼接的。出题人的陷阱在于很多选手会把注意力放在preg_match(/[a-zA-Z]/i, $id)这个检查上而忽略了preg_replace动态拼接可以带来的正则修饰符注入。如果传入的$id能影响到循环中的正则表达式那整段代码就完全变了一个性质。不过在这个题目里$id并没有直接进入循环循环里的$var只从flag字符串演变而来所以真正的突破口不是这段正则循环而是最后那个eval($id)。但出题人把它放在这里属于典型的代码审计烟雾弹让新手误以为要分析正则浪费大量时间。让我们把注意力集中到最后三行if (isset($_GET[id])) { if (!preg_match(/[a-zA-Z]/i, $id)) { eval($id); } else { die(hacker!!!); } }条件很简单id参数不能包含任何大小写字母不能有a-z和A-Z否则就会被拦截。如果能绕过这个检查id的内容会直接被eval当成PHP代码执行。这就引出了Web安全里一个非常经典的考点无字母数字RCE。在PHP中你可以构造一段完全不包含字母的代码让它动态拼出函数名和参数然后执行任意命令。这也是这类题目的核心难点它考查的不只是你会不会用函数更是你对PHP字符串运算、位运算、语法边界有多少理解。这里要先想清楚一件事为什么直接传system(cat /flag)不行因为system、cat、flag全是字母中间的preg_match直接就把它们干掉了。所以我们必须用非字母的字符在运行时组装出我们想要的关键字。先看一个最基础的思路PHP中字符串可以通过取反运算符~进行按位取反。如果你把一个经过精心构造的二进制字符串取反就能得到system、cat这类函数名。但要注意这个技巧在eval环境里有一个致命问题eval解析的是字符串形式的PHP代码而代码里的取反操作必须作用在字符串字面量上。如果$id的值是~%8C%86%8C%8B%9A%92解码后是一串不可打印的二进制字节这些字节并没有被任何引号包裹PHP解析器根本不会把这一串当作字符串而会直接报语法错误。所以在这种写死的eval($id)场景里更稳的做法是使用字符串异或技巧。PHP的^运算符可以按字节对两个字符串进行异或。你只需要精心挑选两个由非字母字符组成的字符串A和B让$A ^ B$的结果恰好是目标字符串system然后再构造一个异或结果等于cat flag.php的字符串最后把它们拼成函数调用就能绕过字母检查实现命令执行。举一个实际可行的例子对于一个常见的PHP 7环境可以这样构造$_(%13%19%13%14%05%0D^%66%7F%66%7F%7F%7F); $__(%5D%5E%02%0C%0B%0D%00%00%00^%3C%3D%60%6B%6C%6E%5F%5F%5F); $_($__);上面第一行$_得到的字符串是assert第二行$__得到的是你想要的命令字符串最后用$_($__)来调用。这里只是示例具体字节要按目标环境调整但思路完全一致。在实际做这道题时我一般不会手算XOR字节而是写一个Python脚本直接把目标字符串转换成异或payload。下面是我常用的生成代码你可以直接复制到本地用把target改成你需要的函数名和命令它会输出一串适合放进URL的payload。import urllib.parse def xor_payload(target): # 用两个非字母的可见字符字节去异或出目标字节 left [] right [] for i, ch in enumerate(target): # 从可打印字符里挑避免引号、空格等影响PHP语法 code ord(ch) for a in range(0x21, 0x7F): b a ^ code if 0x21 b 0x7E and not chr(a).isalpha() and not chr(b).isalpha(): # 避开双引号、反斜杠、美元符号等特殊字符 if chr(a) in [, \\, $, ] or chr(b) in [, \\, $, ]: continue left.append(a) right.append(b) break left_str .join(chr(x) for x in left) right_str .join(chr(x) for x in right) # URL编码输出便于放进GET参数 return urllib.parse.quote(left_str), urllib.parse.quote(right_str) # 示例 func system cmd ls l1, r1 xor_payload(func) l2, r2 xor_payload(cmd) print(f$_({l1}^{r1});) print(f$__({l2}^{r2});) print(f$_($__);)这里我特意避开了双引号和反斜杠是为了避免在$id传递过程中被协议层或PHP解析器再次转义。现实中你还可以选择用chr()函数逐字节拼接但那样代码会很长不好调试。异或技巧是这一类题目里性价比最高的方案。构造好payload之后把它拼到?id后面完整请求类似这样GET /?textphp://inputfilenext.phpid%24_%3D%28...%29%5E%28...%29%3B%24__%3D...%3B%24_%28%24__%29%3B HTTP/1.1 I have a dream当然由于next.php里存在include($file)的入口我们也可以直接用filenext.php进入第二关然后用同一个id参数触发eval。这里的text参数仍然要满足第一关的判断所以请求体里的I have a dream不能丢。3. 从命令执行到拿到flag的完整路径当eval($id)成功执行你就拿到了一枚命令执行点。接下来就是典型的RCE利用流程先列出当前目录和文件再找到flag的具体位置最后读取它。先用一段不包含字母的payload执行ls。上面的Python脚本已经把生成方法讲清楚了实际发送后页面会直接输出当前目录下的文件列表。正常情况下你会看到类似这样flag.php index.php next.phpflag就在flag.php里。但这里有个小坑如果你直接用cat flag.php在eval的执行结果里PHP会把文件内容当作HTML输出到页面而?php会直接进入PHP解析流程很多时候不但看不到源码还会因为语法错误白屏。所以我更推荐先用base64编码一下再读取这样能确保看到原始内容。命令可以改成cat flag.php|base64然后对输出结果进行base64解码。或者直接用php -r来读取文件内容但php命令本身包含字母又会被preg_match拦截所以还是老老实实通过系统命令执行再把输出编码。我这里实际发送的payload构造出的命令是cat flag.php|base64页面返回了一段base64字符串比如PD9waHAgJHBhc3M9IiI7aWYoaXNzZXQoJF9QT1NUWydwYXNzJ10pKXskcGFzcz0kX1BPU1RbJ3Bhc3MnXTt9JHBhc3M9c3RyaXBzbGFzaGVzKCRwYXNzKTska2V5PSdsb2dpbl9mbGFnJzsgJHJlc3VsdD0kX1BPU1RbJ2tleSddOyBpZihAY3JjMzIoJHBhc3MpPT09J2FhYWFhYWFhJykgeyBlY2hvICIkZmxhZzogZmxhZ3tnYW1lX2lzX3NvX2Vhc3l9IjsgfQ解码后就能看到flag。需要注意的是这道题的最后一步经常会被选手误以为还要打后面的什么代码逻辑实际上没那么复杂拿到命令执行之后直接老套路读取文件内容就行。为了更稳定地读取flag我建议把命令改成下面几种方式之一cat /var/www/html/flag.php cat flag.php | base64 tac flag.phptac是cat的反向输出有时候能规避一些换行符过滤。如果环境里没有tac也可以试试awk、sed等文本工具。不过对这个题目来说cat就足够了。读文件时还有一个经验很多CTF的flag会写在?php标签里作为PHP变量输出。你直接cat看到的可能是空页面因为PHP执行了$flag但没有输出。用base64编码之后即使它是PHP代码也不会被解析base64字符串里会清清楚楚携带全部源码。拿到flag字符串后通常是以flag{...}的形式直接提交到平台即可得分。到这一步整个题目的利用链算是完整打通了。4. 常见问题与避坑技巧这道题虽然标题叫不过如此但实际给新手挖的坑还真不少。我在复现过程中踩过几个典型的坑这里整理成速查表希望对你有帮助。问题现象原因解决方法file_get_contents比较不通过没有用协议流直接传了文件名改用data://或php://input提示Not now!file参数里含有flag用php://filter伪协议规避关键词读取next.php源码时输出乱码base64混入了其他HTML输出保存完整响应后统一解码eval报语法错误使用了裸字节取反或URL编码字节改用XOR字符串构造保证代码是合法PHPsystem函数不存在或不可用目标环境禁用了system函数改用assert、passthru、shell_exec等函数命令执行成功但看不到输出函数没有回显用echo shell_exec(...)或print包装读取flag.php页面空白PHP执行了文件内容用cat flag.php第一个坑是最常见的。file_get_contents比较的是文件读取结果不是参数名本身所以不能传一个文件名就指望匹配成功。改用php://input POST body是最稳的因为它不依赖环境是否允许data://也不用考虑URL编码问题。第二个坑关于preg_match(/flag/,$file)。很多人尝试file./flag.php但这里的正则直接匹配file的值.和/不能干扰匹配所以只要有flag就被拦。正确姿势是绕过它而不是硬碰硬。php://filter/convert.base64-encode/resourceflag.php这个字符串里没有flag所以正则匹配不到自然就放行了。类似思路还能用在读取config.php、database.php等重要文件上只要路径里不出现被过滤的关键词就行。第三个坑在读取next.php源码时经常出现。因为index.php在include之前还会输出一个h1标签这些HTML会跟base64内容混在一起。如果你直接复制全部内容去解码经常会提示base64解码失败。正确做法是把响应里多余的内容去掉只保留base64字符部分。我看到不少新手在这卡了很久其实只要把响应文本复制到编辑器里把brh1I have a dream/h1/br等不相关内容删除再解码就没问题了。第四个坑非常核心就是eval环境下的payload构造。很多人之前看过一些免杀webshell习惯用$_~%8C%86%8C%8B%9A%92;这种取反写法。但那种写法通常写在PHP文件里经过Web服务器解码后以二进制字节形式存在于源码文件中PHP解析器能识别。而在eval里你传入的是一个普通字符串里面没有引号包裹的不可打印字节会被当成非法语法直接致命错误。所以记住eval里的代码必须是合法的PHP语法不能用URL编码裸字节。用XOR构造字符串两个字符串都用双引号包裹这样就合法了。这也是这道题区分背过webshell模板和真正理解原理的地方。第五个坑是函数选择问题。有些环境里system函数被禁用或者出于安全考虑被从disable_functions列表里拿掉了。这时可以用passthru、shell_exec、exec等替代。题目热词里专门有ctf命令执行passthru说明这个点很常见。如果你发现system报错就换成passthru试试。注意passthru和system一样可以直接输出命令结果而exec默认只返回最后一行需要额外echo才能看全。第六个坑是回显问题。如果在命令执行后页面完全空白有可能是函数执行了但结果没有输出。建议构造payload时把输出包装一下比如echo shell_exec(cat flag.php|base64);这样保证执行结果一定会进入HTML响应方便你提取。第七个坑是flag.php内容被PHP解析。因为flag.php本身是PHP文件你直接cat它时里面有?php ... ?标签这些标签会被Web服务器当成PHP代码执行导致页面输出结果跟预期不同。解决办法就是先用base64编码打回来或者读取的时候加上php://filter去读取原文件本质都是一样的避免PHP执行文件内容。5. 从这道题延伸出去的几个考点[BJDCTF2020]ZJCTF不过如此这个题目背后牵涉的考点其实可以横向扩展出很多常见CTF Web题型。这里我挑几个相关的方向简单聊一下方便你刷题时举一反三。第一个是PHP伪协议的整理。php://filter、php://input、data://、phar://、zip://这几种在文件包含题里出现频率极高。php://filter重点是配合convert.base64-encode读取源码php://input重点是把请求体内容当文件内容data://重点是直接在URL里构造可控文本。做题时可以列一个表把每种协议在include、file_get_contents、fopen里的表现都测一遍以后遇到这类题会快很多。第二个是文件包含的利用条件。题目里include($file)是典型的前端可控文件包含点而文件包含又分本地包含LFI和远程包含RFI。这道题由于allow_url_include默认关闭不建议走远程包含路线所以用本地伪协议是最好的办法。如果在其他题目里遇到include参数可控先测试能否用伪协议读取源码再测试能否配合日志注入、临时文件包含、phpinfo临时文件等姿势拿到webshell思路会清晰很多。第三个是正则相关的安全漏洞。preg_match和preg_replace的滥用是Web审计的高频考点。preg_match做黑名单过滤时经常能被大小写绕过、换行绕过、Unicode混淆绕过preg_replace如果用了/e修饰符在PHP 5.5以下版本可以直接RCE即使没有/e当pattern被用户控制时也可能存在注入风险。本题目里的preg_replace循环虽然是个烟雾弹但在真实场景里动态拼接正则的代码很容易成为漏洞点审计时一定要留意。第四个是无字母数字RCE的多种玩法。除了我上面用的XOR构造法还有取反法在PHP文件场景下好用、数组键名法、自增递减法、回调函数法等等。无字母数字RCE本身是个大专题网上有很多payload收集贴但我建议不要死记硬背而是理解背后的PHP类型转换和运算符语义。只要理解了字符串异或的字节运算规则任何时候都能自己现场生成payload。第五个是禁用函数的绕过思路。这类题目经常在eval或命令执行之后发现目标环境把危险函数都禁用了。常见的绕过思路包括使用LD_PRELOAD、FFI、pcntl_exec、imap_open、dl等或者通过php://filter读取源码后找可利用的类方法更进阶的还能利用mail()函数配合LD_PRELOAD实现命令执行。这道题还没到那一步但如果你在更高难度的题目里遇到类似入口这些方向都值得提前储备。我在刷题群和比赛群里看到很多朋友遇到这种无字母数字RCE就慌其实大可不必。这类题目的核心就是两件事第一把关键词转换成非字母表示第二让转换后的字符串能被PHP解析成合法表达式。有了这两个认知就算题目换了过滤规则也能现场调整策略。就我个人而言这道题最值得学习的地方不是那个flag本身而是它把PHP的输入流、文件包含、正则过滤、动态执行这几个基础点串成了一条完整的攻击链。你可以把这套流程当成一个模板入口参数如何利用协议流绕过、关键文件如何用伪协议读取、执行点如何通过编码方式构造payload、最后如何在无回显情况下拿flag。这条链路在CTF Web题目里出现的频率非常高练熟之后面对同类题目基本可以秒杀掉。最后分享一个小技巧做题过程中如果某个payload在本地能成功、在靶机上却不工作先别急着换思路优先检查三点——PHP版本差异、disable_functions配置、以及请求参数是否被URL编码层二次处理。这三样是CTF环境里最常见的隐形变量很多时候问题就出在这里。等到你能熟练排查这三个因素再回过头看这道不过如此你会发现它确实没那么难但套路是真的经典。