极客大挑战2023_easy_php_Writeup 题解:PHP 源码审计 · 多关卡逻辑绕过

极客大挑战2023_easy_php_Writeup 题解:PHP 源码审计 · 多关卡逻辑绕过

本文字数:5681  阅读时长:12分钟

访问量 0

极客大挑战 2023 · Web easy_php —— Writeup

项目内容
靶场极客大挑战 2023(GEEK 2023)Web 题 easy_php
题型Web / PHP 源码审计 · 多关卡逻辑绕过
目标http://80-ce718975-a4ad-4e08-ad97-d2280287b3d2.challenge.ctfplus.cn/
入口GET 参数 syc、lover;POST 参数 qw、yxx、SYC_GEEK.2023
服务端Apache/2.4.10 (Debian),实测 PHP 5.x(intval 表现为 PHP 5 特性)
工具Burp Suite Community v2026.8(新版 UI,Repeater / Decoder / Comparer)
结果拿到 GEEK{b8ff48c3-018c-448f-a9ae-6671266c7b0d}

平台下发的实例是临时的。


一、题目:源码全文

打开靶机,页面直接把源码打印了出来。核心是四层嵌套 if:

php
<?php
header('Content-type:text/html;charset=utf-8');
error_reporting(0);

highlight_file(__FILE__);
include_once('flag.php');
if(isset($_GET['syc'])&&preg_match('/^Welcome to GEEK 2023!$/i', $_GET['syc']) && $_GET['syc'] !== 'Welcome to GEEK 2023!') {
    if (intval($_GET['lover']) < 2023 && intval($_GET['lover'] + 1) > 2024) {
        if (isset($_POST['qw']) && $_POST['yxx']) {
            $array1 = (string)$_POST['qw'];
            $array2 = (string)$_POST['yxx'];
            if (sha1($array1) === sha1($array2)) {
                if (isset($_POST['SYC_GEEK.2023'])&&($_POST['SYC_GEEK.2023']="Happy to see you!")) {
                    echo $flag;
                } else {
                    echo "再绕最后一步吧";
                }
            } else {
                echo "好哩,快拿到flag啦";
            }
        } else {
            echo "这里绕不过去,QW可不答应了哈";
        }
    } else {
        echo "嘿嘿嘿,你别急啊";
    }
}else {
    echo "不会吧不会吧,不会第一步就卡住了吧,yxx会瞧不起你的!";
}
?>

二、源码逐行翻译

2.1 开头四行 —— “舞台布置”

行代码大白话
2header('Content-type:text/html;charset=utf-8');给浏览器发个响应头:内容是 HTML、按 UTF-8 解码。只影响"怎么显示",不影响逻辑。
3error_reporting(0);关掉所有 PHP 报错和警告。后面 intval("1e4")、数组转字符串都会产生 warning,但页面上一句都不会显示,脚本也不会中断 —— 所以别指望靠报错找线索。
5highlight_file(__FILE__);把当前这个 PHP 文件自己的源码高亮打印到页面上。→ 这就是"页面直接把源码给你"的原因,题目是 100% 白盒。
6include_once('flag.php');引入同目录的 flag.php(里面定义了变量 $flag),_once 表示只引一次。→ flag 早就在内存里了,只是没被打印;我们要做的是让脚本走到 echo $flag; 那一行。

2.2 第 1 关 · 参数 syc

片段代码大白话
存在性isset($_GET['syc'])URL 查询串里必须带 syc 这个参数
正则preg_match('/^Welcome to GEEK 2023!$/i', $_GET['syc'])syc 的值必须能匹配这个正则。^…$ 是整串锚定,结尾的 /i 表示不区分大小写
反条件$_GET['syc'] !== 'Welcome to GEEK 2023!'但值又必须严格不等于原文(!== 同时比类型和值)

→ 两个条件取交集:改任意一个字母的大小写即可。

2.3 第 2 关 · 参数 lover

片段代码大白话
第一次转换intval($_GET['lover'])把 lover 当字符串从左往右读,读到非数字就停,转成整数
第二次转换intval($_GET['lover'] + 1)先做算术:把 lover 当数字加 1,再取整
比较< 2023 / > 2024要一个 “它自己 < 2023,但它 +1 之后 > 2024” 的值 —— 正常整数做不到

→ 突破口:两次转换走的不是同一条路,去找一个"当字符串转"和"当数字算"结果差很大的字符串 → 科学计数法。

2.4 第 3 关 · 参数 qw / yxx

片段代码大白话
存在性isset($_POST['qw']) && $_POST['yxx']body 里要有 qw、yxx 两个参数(且 yxx 不能为假值)
强转$array1 = (string)$_POST['qw'];强制转成字符串。若传来的是数组,转字符串会得到 "Array"
比较sha1($array1) === sha1($array2)两串的 sha1 必须严格相等。注意:没有要求两串本身不相等

→ 直接传相同值即可;或都传数组,(string) 后都是 "Array",sha1 自然相同。

2.5 第 4 关 · 参数名 SYC_GEEK.2023

片段代码大白话
存在性isset($_POST['SYC_GEEK.2023'])body 里要有这个参数名(注意里面带一个点号)
赋值($_POST['SYC_GEEK.2023'] = "Happy to see you!")⚠️ 这里是 =(赋值)不是 ==(比较)→ 只要参数存在,赋值结果恒为真,传什么值都行
成功echo $flag;四关全过,打印 flag
失败echo "再绕最后一步吧";第 4 关没过时的话

2.6 else 分支 —— 每句话就是一层进度条

回显文案含义
不会吧不会吧,不会第一步就卡住了吧,yxx会瞧不起你的!第 1 关(syc)没过
嘿嘿嘿,你别急啊第 2 关(lover)没过
这里绕不过去,QW可不答应了哈第 3 关外层(qw/yxx 缺失)没过
好哩,快拿到flag啦sha1 比较没过
再绕最后一步吧第 4 关(SYC_GEEK.2023)没过
GEEK{…}全通

调试时这五句话就是进度指纹,比看源码猜快得多。


三、四关分析与绕过

第 1 关 · syc:正则不区分大小写,但比较是强等于

php
preg_match('/^Welcome to GEEK 2023!$/i', $_GET['syc']) && $_GET['syc'] !== 'Welcome to GEEK 2023!'
  • 正则末尾的 i 表示大小写不敏感;!== 是强比较(值 + 类型都相同才算等)。
  • 只要把任意一个字母改大小写,就同时满足"能匹配"和"不等于原串"。

Payload:syc=welcome to geek 2023! (另一种写法:syc=Welcome to GEEK 2023!%0a —— PCRE 里 $ 允许结尾有一个换行,也满足"匹配但不相等"。)

第 2 关 · lover:intval 弱转换(PHP 5 特性)

php
intval($_GET['lover']) < 2023 && intval($_GET['lover'] + 1) > 2024
  • 对同一个数,intval(x) < 2023 而 intval(x+1) > 2024 在数学上矛盾 —— 必须让两次转换走不同的路径。
  • 关键:intval() 与 + 对科学计数法字符串的处理不同。
    • PHP 5:intval("1e4") 从左往右读,遇到非数字字符 e 就停,返回 1;
    • 而 "1e4" + 1 会先把字符串整体当浮点数(10000)再相加 → 10001,最后才被 intval() 取整。
    • 结果:1 < 2023 ✓ 且 10001 > 2024 ✓。

Payload:lover=1e4(等价写法:2022e2、2e9 等,指数 ≥ 4 即可。)

⚠️ 这是 PHP 5 的特性。在 PHP 7+ 中 intval("1e4") 会返回 10000,本关就绕不过去了 —— 所以这题也能反过来判断靶机的 PHP 大版本(实测本靶机为 PHP 5)。

第 3 关 · qw / yxx:sha1 强相等

php
$array1 = (string)$_POST['qw'];  $array2 = (string)$_POST['yxx'];
if (sha1($array1) === sha1($array2))
  • 源码只要求两串 sha1 相等,没有要求两串本身不相等 —— 所以最简单是给两个参数传相同的真值。
  • 更"教科书"的写法是数组绕过:给 qw、yxx 传数组,(string) 强转后都变成字符串 "Array",sha1 自然相同。

Payload:qw=1&yxx=1(数组版:qw[]=1&yxx[]=2)

第 4 关 · 参数名 SYC_GEEK.2023:PHP 会清洗参数名

php
if (isset($_POST['SYC_GEEK.2023']) && ($_POST['SYC_GEEK.2023'] = "Happy to see you!"))
  • 后半句是 =(赋值)而不是 ==(比较)—— 只要参数存在,赋值结果恒为真,所以传什么值都行。
  • 真正的坑在参数名里的点号:PHP 在接收外部变量时,会把名字里的 . 和空格自动换成下划线 _。
    • 直接发 SYC_GEEK.2023=1 → PHP 收到的键名是 SYC_GEEK_2023 → isset($_POST['SYC_GEEK.2023']) 为假 → 卡住。
  • 绕过:PHP 的这次"清洗"在遇到第一个 [ 时会停手([ 自身占掉那个被替换的位置,其后的字符原样保留)。
    • 发 SYC[GEEK.2023=1 → 实际得到的键名正好是 SYC_GEEK.2023 ✓

Payload:SYC[GEEK.2023=1

实测对照(用于确认上面两条规则)

发送的参数名服务端实际键名结果
SYC_GEEK.2023SYC_GEEK_2023(.→_)卡住
SYC.GEEK.2023SYC_GEEK_2023(.→_,两个点都换)卡住
SYC[GEEK[2023SYC_GEEK[2023([→_ 但后面残留 [)卡住
SYC[GEEK.2023SYC_GEEK.2023(首个 [ 挡刀,点号保住)✅ 出 flag

四、Burp Suite 完整操作流程(新版 UI)

0. 起手:用内置浏览器抓包

  1. Proxy → Intercept 页,点右上角 Open browser(免配代理、免装证书)。
  2. 在浏览器里访问靶机地址。回到 Burp,Intercept is on 会拦到这条 GET / 请求。
  3. 先点 Intercept is off,把拦截关掉,避免后面每次都要放行。或直接在 HTTP history 里找这条请求右键使用。

1. 送进 Repeater 改 GET 参数

右键该请求 → Send to Repeater(快捷键 Ctrl+R),切到 Repeater 页签。

把请求行改成(空格在请求行里必须写成 %20):

text
GET /?syc=welcome%20to%20geek%202023!&lover=1e4 HTTP/1.1
  • 手敲空格容易出错 → 用 Decoder 更稳:把 welcome to geek 2023! 粘进 Decoder,选 Encode as → URL,再把结果贴回请求行。
  • 点 Send:若仍是 不会吧不会吧… 说明 syc 写错;若回 嘿嘿嘿… 说明 lover 没生效。

2. 改方法为 POST 并加 Body

  • 在请求行右键 → Change request method(GET ↔ POST 一键切换),或直接把首行 GET 改成 POST。
  • 确认请求头里有:Content-Type: application/x-www-form-urlencoded
  • 加上空行后写 Body:
text
qw=1&yxx=1&SYC[GEEK.2023=1
  • [ 直接敲即可,不用编码(它在 Body 里,不是 URL 里)。
  • Content-Length 必须精确等于 body 字节数:这段 body 正好 26 字节。 写大了(例如 28)服务端会一直等差的字节数 → 请求挂死、收不到响应(实测:CL=28 vs 实际 26 → 收到 0 字节)。 稳妥做法:右键 → Update Content-Length,或干脆先删掉这一行让 Burp 自动补。
  • 点 Send,响应里应出现:
text
GEEK{b8ff48c3-018c-448f-a9ae-6671266c7b0d}

3. 用 Comparer 验证"第 4 关"的差异(可选,但很能说明问题)

把两次请求各发一份:一次 Body 用 SYC_GEEK.2023=1,一次用 SYC[GEEK.2023=1,分别选中响应 → 右键 Send to Comparer → 在 Comparer 里对比,能直接看到前者是"再绕最后一步吧"、后者是 flag,验证点号确实被 PHP 改掉了。

3.4 五步对照表:每加一段,命中的源码是哪句、为什么要这么写

步Burp 里做什么请求变成什么样命中的源码逐段解释
0抓到请求,先原样 Send 一次GET /—拿"基准回显",确认这页确实在执行源码里那条 if 链
1请求行末尾加 ?syc=…GET /?syc=welcome%20to%20geek%202023!preg_match('/^…$/i') && $_GET['syc'] !== '…'/i 让它"能匹配",!== 又要求"不能相等" → 改大小写;请求行里空格必须写 %20
2再追加 &lover=1e4GET /?syc=…&lover=1e4intval($_GET['lover']) < 2023 && intval($_GET['lover'] + 1) > 2024普通整数无解;PHP 5 里 intval('1e4')=1,而 '1e4' + 1 = 10001
3改方法为 POST,加 Content-Type 头POST /?syc=…&lover=1e4 + Content-Type: application/x-www-form-urlencodedisset($_POST['qw']) 等第 3、4 关读的是 $_POST,所以必须放 body;URL 里那两个 GET 参数原地不动(换方法 ≠ 换位置)
4补上 body 三兄弟body:qw=1&yxx=1&SYC[GEEK.2023=1(26 字节)sha1(...) === sha1(...) / isset($_POST['SYC_GEEK.2023'])qw、yxx 传同值 → sha1 必然相等;SYC[GEEK.2023 用 [ 挡掉 PHP 的参数名清洗,保住点号

每步发完都看回显:不会吧不会吧… → 卡第 1 关;嘿嘿嘿… → 卡第 2 关;这里绕不过去… → 卡第 3/4 关;出现 GEEK{…} → 通关。


五、知识点整合

5.1 PHP 解析"外部输入"的规则(本题半数坑都在这)

现象规则
参数名里有 . 或空格自动换成 _(SYC_GEEK.2023 → SYC_GEEK_2023)
参数名里出现 [清洗遇到第一个 [ 就停手,[ 本身占掉那个被替换的位置(SYC[GEEK.2023 → SYC_GEEK.2023)
参数名写成 xxx[]该参数的值变成数组(qw[]=1 → $_POST['qw'] = ['1'])
$_GET / $_POST 的来源$_GET 永远读 URL 查询串;$_POST 只读 body(且需 Content-Type: application/x-www-form-urlencoded)。换请求方法不会把参数从 URL 挪到 body

5.2 弱类型转换(PHP 5)

表达式结果说明
intval("1e4")1当字符串读,遇 e 停
"1e4" + 110001先当浮点算,再取整
(string)['1']"Array"数组转字符串是固定串
sha1((string)$a) === sha1((string)$b)(两者都是数组)true因为都变成 "Array"

5.3 比较运算符陷阱

写法含义本题里的位置
=赋值,不是比较第 4 关:isset(...) && ($x = "Happy to see you!") 恒为真
==弱比较,会做类型转换(本题未用,但同类题常见)
===强比较(类型 + 值)第 3 关 sha1(...) === sha1(...)
!==强不等第 1 关用它卡"照抄原文"
preg_match(...)只是"能不能匹配上",跟"相等"是两套判断第 1 关的缝就在这

5.4 正则小知识

点说明
/i不区分大小写
$PCRE 里允许结尾有一个 \n(所以 原串%0a 也能"匹配但不相等")
^ … $整串锚定,避免子串匹配

5.5 HTTP 层必备

点说明
请求行里的空格必须写成 %20(query 里也可用 +);裸空格会把"方法 空格 URI 空格 版本"切坏,请求发不出去
Content-Length必须精确等于 body 字节数。写大了服务端会一直等剩余字节 → 请求挂死(实测 CL=28 / 实际 26 → 收到 0 字节)
参数分隔符query 里用 & 连接多个参数
[ 在 body 里无需编码,可直写

六、解题思路(可迁移到同类题)

  1. 源码直给就先通读:出现 highlight_file 就是白盒。先数清有几层 if、每层的输入从哪来($_GET 还是 $_POST)。
  2. 给每层配一句"回显指纹":本题五句中文提示就是进度条,改动后一眼看出推进到第几关。
  3. 逐层找"缝",按三个层次扫:
    • 类型层:弱转换(intval、==、sha1(array))
    • 运算符层:= 误写成 ==、!== 与正则错位
    • 解析层:参数名清洗、URL 编码、[] 变数组
  4. 按来源分配位置:$_GET 的放 URL、$_POST 的放 body,别混;换方法 ≠ 换位置。
  5. 一次只改一处、发一次看回显:不要一次把 payload 全糊上去,否则出错时无从定位。

七、和之前讲过的内容有什么联系

本题考点之前的对应内容关系
第 4 关:参数名里的 . 被 PHP 换成 _HelloCTF RCE「SHELL 运算符」关:; 在 URL 里是 PHP 的参数分隔符,必须写 %3B同一大类:你写的 payload ≠ 服务端实际收到的 payload。前者坑在参数值/分隔符,本题坑在参数名
第 3 关:qw[]=1 数组绕过之前用自建回显服务实测过 ip[]=hello → PHP 里 $_GET['ip'] 变数组同一手法:[] 变数组是 PHP 特有的解析行为,可直接复用来打 sha1(array) 之类的弱逻辑
第 1 关:preg_match + !==RCE labs「空字符/通配符」关:preg_match("/flag/") 无 /i,大写直接过同属正则/比较绕过;本题是"正则能匹配但强比较不相等"的逆向思维
页面直接 highlight_file 给源码RCE labs 各关同样是源码直给同一入口习惯:先读源码,找四个 if 对应四句回显
第 2 关:intval 科学计数法——新知识点,之前没讲过
第 4 关:[ 截断参数名清洗——新知识点,之前没讲过

三条可以带走的通用结论

  1. 回显文案就是进度条。 多关卡题每层失败给不同话术时,先给每句话建映射表,爆破/调试时一眼就知道卡在哪一关。
  2. PHP 对外部输入做了两层"预处理":① 参数名清洗(. /空格 → _,遇 [ 停手);② 参数值只在 +/intval 等具体运算时才转换。写 payload 前先问一句"PHP 收到时它长什么样"。
  3. 弱逻辑题的三种破法:类型不匹配(1e4、数组、0e...)、运算符陷阱(= 写成 ==)、比较层级错位(正则匹配 ≠ 强等于)。

八、汇总表

关卡参数 / 位置判断条件绕过原理Payload
1GET sycpreg_match('/^…$/i') && !==正则不区分大小写,!== 区分 → 改大小写syc=welcome to geek 2023!
2GET loverintval(x)<2023 && intval(x+1)>2024PHP5 intval 遇 e 截断,+ 先算浮点lover=1e4
3POST qw / yxxsha1(str1)===sha1(str2)未要求两串不同 → 传同值 / 数组转 "Array"qw=1&yxx=1
4POST 参数名isset($_POST['SYC_GEEK.2023'])PHP 清洗参数名遇 [ 停手 → [ 挡刀保住点号SYC[GEEK.2023=1

完整 payload

text
GET /?syc=welcome%20to%20geek%202023!&lover=1e4 HTTP/1.1
Content-Type: application/x-www-form-urlencoded

qw=1&yxx=1&SYC[GEEK.2023=1

(body 长度 26 字节,对应 Content-Length: 26)

Flag:GEEK{b8ff48c3-018c-448f-a9ae-6671266c7b0d}