开头
PHP特性刷题,是CTF Web方向绕不过去的一段旅程。CTFshow这套PHP特性专题到89-115题这个区间,正好是从“认识特性”到“组合利用”的分水岭——前面的题大多是一个点一个洞,一眼能看出来;到了这一段,你开始接触变量覆盖、正则绕过、弱类型比较的连环嵌套,题目开始有“审计”的味道了。
这个专题适合谁?两类人。一类是刚把PHP基础语法刷完,想知道“学了这些到底能干嘛”的初学者,这套题能让你直观感受到语言特性如何在实战中变成漏洞;另一类是已经能解简单Web题,但遇到代码审计就发怵的朋友,这几十道题能帮你把很多“模模糊糊知道”的知识点,比如intval截断、strpos返回0的坑、md5数组绕过,变成肌肉记忆。
我刷完这个区间最大的感受是:CTF里的PHP特性题,表面考的是“偏门用法”,本质上考的是你对PHP语言设计逻辑的理解。出题人不是要你背payload,而是希望你理解——为什么这个特性会在真实开发中埋雷。这篇日记就把我在89-115里总结的核心知识点、绕过思路和踩坑记录整理出来,给后面刷题的朋友一个参考。
1. 这个区间到底在考什么:PHP特性知识点地图
1.1 从“看懂代码”到“构造输入”的思维转变
很多人刷PHP特性题,最大的障碍不是代码看不懂,而是不知道从哪里下手。89-115这个区间教会我的第一件事:拿到一段源码,不是通读逻辑,而是先找“比较”和“过滤”。
出题人给你一段代码,然后让你传参、得flag,核心套路只有两个方向:要么是“怎么让一个条件成立”,要么是“怎么绕过一个过滤”。而PHP特性题目里,这两种情况往往同时出现。比如代码里先有一个strpos($a, "flag")的检查,要求位置必须为0,后面又有一个==比较需要绕过,这种嵌套设计在89-115里特别常见。
我的做题顺序是这样固定的:
- 找出所有有
if、while、assert、echo等关键语句的地方; - 列出所有参与比较的变量,标注它们来自哪个参数(GET/POST/COOKIE/原始输入);
- 判断每个比较用的是
==还是===,用===的地方基本只能靠类型欺骗或数组报错; - 找所有过滤函数,确认过滤一次还是多次、是替换还是直接拦截。
这个流程看起来简单,但很多新手会直接去看payload,跳过了前面这几步。结果是换一道题、换个参数顺序,就不会做了。特性题真正要训练的就是这套“输入->执行流->绕过点”的分析框架。
1.2 89-115区间覆盖的核心知识点分类
刷完这个区间,我按“考点类型”做了个知识清单,基本能把大部分题目对号入座:
| 类型 | 典型函数/特性 | 常见出现形式 |
|---|---|---|
| 弱类型比较 | ==、===、字符串与数字比较 | 条件判断、MD5校验、登录逻辑 |
| 数字/字符转换 | intval()、is_numeric()、进制转换 | 整数溢出、科学计数法绕过 |
| 哈希绕过 | MD5、sha1,0e碰撞、数组报错 | 参数校验、签名逻辑 |
| 字符串函数误判 | strpos()、strcmp()、str_replace() | 关键词过滤、文件名校验 |
| 正则绕过 | preg_match(),数组/换行/回溯限制 | WAF过滤、命令校验 |
| 变量覆盖 | extract()、parse_str()、$$ | 登录逻辑、配置覆盖 |
| 伪协议与包含 | include()、file_get_contents()、php://filter | 文件读取、代码执行 |
这里我想特别强调一点:这个分类不是我在网上抄的,是刷完题之后自己总结的。你会发现出题人特别喜欢把上面两三种类型组合在一起。比如“弱类型+MD5”几乎是固定套路,比如“str_replace只替换一次+正则匹配”又是一类经典考法。后面几章我会把每个大类拆开细讲,选一些有代表性的题当作案例来复盘。
1.3 为什么出题人偏爱PHP特性而不考框架漏洞
说句实在话,CTF的Web题如果直接考框架漏洞或组件RCE,对新手是非常不友好的——你连框架结构都没摸过,怎么去做利用链?PHP特性题就不同了,它把考点限定在PHP语言自身的“反直觉行为”上。你不需要懂复杂的框架,只需要搞懂==和===的区别,就可能解一道20分的题。
但也不要觉得这类题简单。89-115这个区间有一个明显的趋势:从单一特性到多特性串联。前20道题可能一道题只考一个intval,后20道题开始出现“先绕过preg_match,再用变量覆盖,最后用弱类型比较”这种三层嵌套。这其实是在模拟真实代码审计的思维方式——真实漏洞很少有孤立的,往往是一个小疏忽加上另一个小疏忽,串联成一条可利用的攻击链。所以刷这一段,不仅要记特性,更要练“串联利用”的思维。
2. 弱类型比较:PHP最“反直觉”的设计
2.1 松散比较的底层逻辑:自动类型转换
PHP的弱类型比较,也就是==,是我在89-115里碰到最多的考点,也是新手最容易翻车的地方。
先说原理。==比较两个值的时候,如果类型不同,PHP会尝试把它们转换成“可比较的状态”。比如比较一个字符串和一个数字,PHP会尝试把字符串转换成数字——转换规则是:如果字符串开头是数字,就取开头的数字部分;如果开头不是数字,整个字符串变成0。
这就衍生出一个经典的坑:
var_dump("abc" == 0); // true,因为"abc"转数字是0 var_dump("1abc" == 1); // true,因为"1abc"转数字是1 var_dump("0e123" == 0); // true,因为"0e123"是科学计数法表示的0 var_dump("0e123" == "0e456"); // true,双方都转成0前两个其实还好理解,后两个才是真正让新手懵的。"0e123"这种字符串,因为后面跟的是数字,PHP会把它当作科学计数法来理解:0乘以10的123次方,结果还是0。所以任何以0e开头、后面全是数字的字符串,在==比较时都等于0,也互相等于对方。
我在实际刷题时还验证过一个细节:这种转换在PHP 7和PHP 8里有些微差别,但0e开头的字符串与0的比较结果在主流版本里都是true。做题时如果你本地跑不出来,先看PHP版本,再看有没有加引号。
2.2 intval与is_numeric:数字函数的“截断”艺术
intval()这个函数在特性题里出现的频率极高,因为它有一个非常“好骗”的特性——遇到非数字字符就截断,只取前面能解析的数字部分。
var_dump(intval("123abc")); // 123 var_dump(intval("abc123")); // 0 var_dump(intval("9999abc")); // 9999一个常见考法是这样:代码要求你传入一个参数,经过intval()之后要大于某个值,但同时又要求这个参数包含某段字符串。比如:
$num = $_GET['num']; if (intval($num) > 1000) { if (strpos($num, "flag") !== false) { echo $flag; } }这时候你传9999flag就能同时满足两个条件。因为intval("9999flag")截断返回9999,大于1000成立;strpos("9999flag", "flag")也找得到。思路很简单,但实际做题时很多人只想到传数字,忘了拼接字符串,就是因为没理解“截断”这个特性可以反过来利用——让同一份数据的“数字部分”和“字符串部分”分别满足不同条件的检查。
is_numeric()则是另一个方向的考点。它判断字符串是不是数字,有个容易被忽略的行为:支持科学计数法。"1e3"会被认为是一个合法的数字,因为它可以解释为1000。某些题的过滤逻辑是“不允许传数字”,但用的是is_numeric()判断,这时候传1e3就可以绕过——因为is_numeric("1e3")是true,但它在某些场景下又能被当作字符串拼接使用,产生意想不到的效果。
2.3 强比较的“死路”与“活路”
===强比较要求类型和值都完全相等,看起来没有绕的空间?其实有。最典型的就是MD5强比较用数组绕过,这个我在下一章详细说。这里先提一句:强比较的“活路”有两个方向,一个是利用函数返回值的异常类型,比如数组传给md5会返回null,null === null 成立;另一个是利用真正的哈希碰撞,让两个不同的值产生完全相同的哈希结果。
在PHP特性89-115这个区间,所有===相关的题目基本都走数组路线,因为简单直接。真正的MD5碰撞题需要生成特定的二进制文件,比赛中很少要求现场构造,更多是考察你知不知道“存在这样两个值”。
3. MD5与哈希绕过:0e碰撞和数组的“魔法”
3.1 0e开头的字符串:哈希的“幸运星”
MD5和sha1的0e碰撞是Web题里最经典的考法之一。原理其实很简单:有些字符串的MD5值恰好是0e开头,后面跟的全是数字。比如经典的QNKCDZO,它的MD5值是0e830400451993494058024219903391,是一个科学计数法表示的0。因此:
$_GET['a'] = "QNKCDZO"; $_GET['b'] = "s878926199a"; // 另一个0e开头的MD5 // 如果代码是 if (md5($a) == md5($b)) // 两边都是0e开头的字符串,比较时都等于0,条件成立这里有一个新手特别容易犯的错:直接在URL里传a=0、b=0,以为0等于0就绕过了。但代码里是md5($a),md5("0")的哈希值根本不是0e开头,所以完全无效。你必须找一个字符串,它的MD5哈希值本身是0e开头才行。
我还碰到过变体:不比较两个MD5,而是比较一个MD5和一个变量。比如md5($a) == $b,这时候只需要让$b等于一个0e开头的字符串,比如0e123,就可以让右边本身转成0,左边也转成0。这种考法更容易忽略,因为你的注意力全放在找0e碰撞上了,忘了可以直接传一个0e形式的字符串。
3.2 数组报错返回null:最无脑的绕过方式
如果说0e碰撞还需要背几个payload,那数组绕过就真的是“零门槛”了。
// 代码:if (md5($_GET['a']) === md5($_GET['b'])) // 传参:a[]=1&b[]=2原理是这样的:md5()的参数要求是字符串,如果你传一个数组进去,PHP会产生一个Warning,但函数仍然会执行,返回结果是null。所以md5(array) === md5(array)就是null === null,成立。
这个技巧在89-115区间里到处都是,因为出题人明明用了===来防0e碰撞,却忘了数组能让所有哈希函数“失灵”。我刷题时甚至总结出一个条件反射——看到md5或sha1比较,先试数组,不行再想别的。
需要注意的是,URL传数组的格式是a[]=1,不是a=1[],这个在手动测试时很容易打错。还有一点,数组里的值是什么其实无所谓,因为比较的是null,两个null必然相等,你甚至可以传a[]=&b[]=这种空值。
3.3 强比较的“神仙打架”:真正的MD5碰撞
有一类题目会刻意限制你只能用字符串,不能用数组,比如加了is_string()检查。这时候===就真的无解了吗?也不是,还存在真正的MD5碰撞:两个不同的字符串,MD5值完全相同。
这个技术需要用到已知的碰撞对。网上有公开的碰撞文件,两个内容不同的二进制块,MD5值一模一样。但实战里这类题通常不是让你现场生成碰撞,而是直接给你两个字符串,让你判断能不能绕过去。所以我的建议是:知道有这个方向就行,刷题时如果碰到“非字符串不可”的强比较,先想一想是不是题目故意设置了密钥或者盐值,用拼接的方式把碰撞路径封死了。
89-115这个区间里,我印象中遇到的大多数MD5题都没有严格到“必须上碰撞”的程度,数组和0e基本够用了。
4. 函数误用与过滤绕过:那些反直觉的返回值
4.1 strpos的陷阱:0和false的“真假美猴王”
strpos($haystack, $needle)这个函数,在找到匹配时返回匹配位置的索引,在找不到时返回false。问题来了:如果匹配位置是字符串的第一个字符,返回的是整数0。
在PHP里,0 == false是成立的(松散比较),所以很多出题人写的“防护代码”其实自带漏洞:
if (strpos($_GET['a'], "flag") == 0) { // 如果a以flag开头,就拒绝 die("no"); }这个判断的本意是“如果参数以flag开头就拦截”,逻辑上没错。但你传入a=xxflag的时候,strpos返回的是2(flag出现的位置),2不等于0,条件不成立,就能正常进入后续逻辑。等等,这不是正常的吗?问题在于这个检查的意图是拦截包含flag的输入,而很多新手以为传了带flag的参数就会被拦截,实际上出题人只拦截了“flag在开头”的情况。
更经典的坑出现在这种写法里:
if (strpos($_GET['a'], "flag")) { die("no"); } // 再把flag拼到后面去处理这段代码的意思是:只要strpos返回的值不为空(不为0、false、null),就die。但strpos返回0时,0在if判断中是false,所以不会die。换句话说:参数以flag开头时,反而能绕过拦截。这就是“0和false混淆”导致的逻辑漏洞。每次看到strpos,第一反应应该是:“它有没有用!==来严格判断?”如果没严格判断,这道题大概率就是让你钻这个空子。
4.2 preg_match的绕过:数组、换行与回溯
preg_match()是特性题里最常见的过滤函数,也是最容易构造绕过的函数。根据我刷89-115的经验,常见的绕过姿势有四种:
第一种是数组绕过。preg_match处理数组时会直接返回false,不会做实际匹配。所以代码如果写成:
if (preg_match("/flag/i", $a)) { die("no"); }你直接传a[]=123,preg_match返回false,if不成立,然后后续代码如果直接把$a当字符串用,又会报Warning或继续执行,很多时候就能绕过去。
第二种是换行绕过。如果正则没有加/s修饰符,点号.不匹配换行符。有些过滤规则写的是"/^flag$/",用^和$锚定整串,但$默认匹配的是字符串末尾(不包括末尾换行之前的位置?这里有个细节,$在PHP正则中默认匹配最后一个换行符之前的位置)。你传flag%0a,就能让匹配目标变成/^flag$/也能过——因为换行符让$匹配的位置提前了。这类绕过看起来很取巧,但在实际开发中确实存在,也是出题人爱考的点。
第三种是PCRE回溯限制绕过。这个属于进阶技巧。PHP的preg_match在执行时有一个回溯次数上限(默认100万次)。当你传入超长字符串,让正则匹配过程触发大量回溯,导致匹配“失败”(返回false)时,过滤就被绕过了。最经典的例子是正则/.*flag.*/+ 超长字符串不包含flag:正则引擎为了找到flag会疯狂回溯,超过限制直接返回false,过滤也就形同虚设。这个技术在89-115中会出现在一些比较综合的题目里,看到超长内容输入的题,可以往这个方向想。
第四种是逻辑关系绕过。有些过滤正则写的过于宽松或过于严格,比如只过滤了cat /flag,但没过滤tac /flag,或者只过滤了小写,没过滤大写。这类“绕过滤”不涉及PHP特性,但因为在特性专题里经常和前面的技术组合出现,所以也一并归入总结。
4.3 str_replace的“只替换一次”和过滤顺序问题
str_replace()在特性题里的经典用法是当关键词过滤器。代码会把$a中的敏感词替换成空字符串,然后再去执行或拼接过后的内容。这个函数有两个可以利用的点:
第一,替换不递归。str_replace("flag", "", $a)只会执行一次替换,不会对替换后的结果再检查。所以要绕过这个过滤,只需要在参数里写成flflagag——中间的flag被删掉后,剩下的正好是flag。
$_GET['a'] = "flflagag"; // 替换后变成 "flag"类似地,如果过滤的是php,可以传pphphp;过滤system,可以传syssystemtem。我记得有一道题过滤了一连串的危险函数,用“双写”就能全部绕过,因为过滤规则是逐个替换的,替换完一轮不会再来一轮。
第二,替换顺序可能被“错位利用”。有的代码先替换flag为fl,再替换fl为空,这就产生了歧义。你传一个flflflagag,两次替换的顺序不同,结果也不同。这种题基本是考你“能不能逆着替换顺序构造输入”,思路和双写本质一样,但要更细心。
5. 变量覆盖与伪协议:从“绕过”到“利用”
5.1 extract与parse_str:外部输入变成内部变量
当代码中出现extract($_GET)或者parse_str($_SERVER['QUERY_STRING'])时,题目就进入了“变量覆盖”的领域。这类题的核心考点是:你能否通过外部参数,覆盖一个本应在代码内部初始化的变量。
举个例子:
$pass = "admin"; extract($_GET); if ($pass === "secret") { echo $flag; }正常情况下,$pass被赋值为"admin",进入不了if。但extract($_GET)会把URL里的参数变成同名变量,如果你传pass=secret,$pass的值就被覆盖了,比较通过。
这个技术的变体很多,比如用$$动态变量覆盖,或者用parse_str解析a=1&b=2这种字符串自动生成变量。遇到这类代码,我的思路是:先列出现有变量名,再看哪些外部输入可以直接影响它们。审计时的注意力不能只放在“显眼”的$_GET['xxx']上,要特别注意那些把整个超全局数组“展开”成变量的函数——它们是最强的变量覆盖工具。
5.2 伪协议:让文件函数为你打工
在PHP特性专题的后半段,伪协议的戏份会明显增加。最常见的是include配合伪协议读取源码或执行代码。
最经典的打法:
php://filter/read=convert.base64-encode/resource=flag.php—— 用include或file_get_contents读取,得到base64编码后的源码,解码就能看到注释里的flag;php://input—— POST一个<?php system("...")?>,配合include会直接当成PHP执行;data://text/plain;base64,PD9waHAgc3lzdGVtKCdscycpOz8+—— 用data协议直接传代码,需要在php.ini里开启allow_url_include,但有些题目环境默认是开着的,可以直接试。
刷这一段时有一个很重要的细节:include和file_get_contents在遇到伪协议时的表现不太一样。include把读到的内容当作PHP代码执行,file_get_contents只是读取原始内容。如果题目用了file_get_contents($_GET['file']),你传php://filter也一样能读源码,但传php://input就只能读到POST原始数据了,失去了代码执行能力。做题前先确认代码用的是什么函数,别一股脑往php://input上冲。
5.3 弱类型+变量覆盖+伪协议的组合拳
前面那些知识单拎出来都不难,但89-115这个区间里最有价值的题目,恰恰是把它们组合起来的那些。我举个例子描述一下这种题目的“嵌套结构”:
题目先要求你传入一个变量,经过某个函数处理后,必须等于某个固定字符串,否则直接退出。第一层,你可能用strpos返回0的陷阱,或者用intval截断,让数据通过校验;第二层,代码里有个extract把另一个参数变成变量,覆盖了某个内部配置,改变了后续的包含路径;第三层,包含路径里用了伪协议读取文件,把flag带出来。
实际做这类题时,我的建议是:先不要跳着试,而是像调试程序一样,把代码从头到尾捋一遍,标出每个“变量从哪来”、“在哪里被使用”。很多时候组合题看起来复杂,但真正需要绕过的地方只有一两处,其余都是障眼法。比如某个参数只在第一层校验里出现,后面根本不用,那你只需要构造它满足第一层校验就行,完全不需要理解整个代码的“业务逻辑”。
这也是为什么我反复强调“遇到PHP特性题,先列参数流向,再想payload”——因为特性题的考点不是单一的一个绕过点,而是多个绕过点之间的逻辑关系,你只有先看清楚这层关系,才能构造出恰好能穿过所有校验的输入。
6. 刷题常见问题与排查技巧实录
6.1 本地环境差异导致的结果不一致
我在刷PHP特性题时,至少三次遇到过“网上说能绕过,我本地测试却不行”的情况。原因无一例外都是PHP版本差异。
比如"0e123" == 0在PHP 5时代是成立的,在PHP 7里大部分时候也成立,但是某些字符串转数字的规则在PHP 8里有调整。数组传给md5()返回null这个行为,不同版本的表现也不同——PHP 8会抛出更明确的TypeError,如果不加@抑制,可能直接中断执行。
所以本地复现时,建议搭一个和靶机版本一致的PHP环境。最简单的方式是用Docker,一次性拉几个不同版本的PHP镜像,比如php:5.6-apache、php:7.4-apache、php:8.1-apache,按需切换。实测下来,刷CTFshow这类平台,PHP 7.x的环境最通用,大多数传统考点都能复现。
6.2 参数格式与URL编码:最容易被忽略的“隐形杀手”
很多payload在本地测试能过,提交到靶机上却失败,问题往往出在URL编码上。
举几个我踩过的坑:
- 传数组时写法错误:
a[]=1&b[]=2是正确格式,但有人会写成a[0]=1&b[0]=2,虽然大部分情况也能触发数组行为,但如果有array_keys或array_values之类的处理,结果可能不一样; - 换行符没有编码:直接传
flag%0a是正确的,如果忘了编码写成flag\n,在URL里就成了字面字符\n; - 特殊字符被浏览器或中间层过滤:
data://里的分号、逗号偶尔会被某些waf处理,这时可以试试二次URL编码,或者用data:text/plain,<?php ...?>这种不带//的变体。
我的习惯是:在规定参数尽量贴近原文的前提下,先在本地用urldecode逻辑还原一遍,确认靶机收到的内容和本地测试的内容一致,再提交。省得因为一个小编码错误浪费半小时。
6.3 做题时的排查思路:从“盲目试payload”到“有方向地推断”
刷题时最常见的挫败感是:连续试了5个payload都不对,心态就崩了。后来我总结了一套排查流程,帮助自己在卡住的时候快速定位问题:
- 确认参数名和变量名:题目代码里的
$_GET['a'],你传的是不是也叫a?这点最基础,但着急时真有人写错。 - 确认过滤点是否真的生效:先传一个普通值,看返回什么;再传一个明显被拦截的值,看有没有变化。通过对比返回信息,你可以判断过滤是在前端、中间层还是PHP代码里。
- 确认PHP版本:如果某个payload理论上成立但实际不生效,查一下这个版本下的函数行为有没有变化。
- 确认注入点是GET、POST还是原始输入:
php://input用的就是原始POST体,如果你同时传了GET参数,可能被忽略。有些题目用$_REQUEST,它的优先级顺序在不同配置下不一样,也会导致传参“失灵”。 - 看回显判断逻辑走向:有的题目会在
die()前输出一句提示,比如“no no no”,这时候你可以用二分法——先让条件成立,再故意让条件不成立,看提示是否变化,从而确定你的输入是否真正触碰到了目标代码段。
这个排查过程有点像调试程序时打日志,虽然慢,但比漫无目的地换payload靠谱得多。
6.4 我的知识库整理法:每道题一句话总结
刷完89-115之后,我做了一个非常简单的复盘动作:把每一道题用一句话总结,记在本地笔记里。格式是“题目编号 + 考点 + 关键payload + 易错点”。
比如:
- 某题:
intval截断 + 字符串拼接,payload是9999flag,易错点是不能只传数字; - 某题:
strpos返回0绕过,payload是flag开头反而能过,易错点是没注意if里不是!==; - 某题:
extract覆盖变量,payload是pass=xxx,易错点是忘了先看变量名。
这样做的好处是,后续遇到类似的题,我能立刻在笔记里找到对应的类,知道优先试什么。刷题不是为了刷数量,而是为了建立一个“条件反射库”——看到什么函数,就知道往哪个方向想。这个库越厚,后面打比赛、做审计就越快。
结尾
最后再分享一个我刷完这个区间最大的感受:PHP特性题其实是非常“讲道理”的题目。它不像某些漏洞利用需要天马行空的想象力,更像是一场“语言特性知识竞赛”——你越了解PHP那些反直觉的设计,解题就越轻松。我经常是刷一道题,就去翻一遍PHP手册对应函数的说明,看看有没有被我漏掉的细节。这个过程很慢,但积累下来,后面遇到没见过的题目时,反而能快速定位到对应的函数特性上。所以我的建议是,别急着把89-115一口气刷完,每道题尽量多思考一步——这个特性在真实代码里会导致什么问题?如果我是开发者,该怎么避免踩这个坑?带着这种视角去刷,收获会比单刷payload大得多。