1. GET不是摆设——这道题从名字就开始告诉你考点
PolarCTF2026春季赛Web方向有一道题叫“GET”,题目给了一个URL,页面近乎空白。当时群里不少人在吐槽“这题是不是忘出题了”,但我第一反应是:出题人把HTTP方法当成题面,考点几乎必然在GET请求的细节上。CTF的Web题分两类,一类题面复杂、入口众多,另一类就是这种极简题,看似什么都没有,其实所有线索都藏在每一个请求的边角里。这道“GET”题,属于后者。
当我打开靶机,页面只有一行字、一个链接,甚至连图片样式都没有时,我没有急着去点那个链接,而是按习惯打开浏览器的开发者工具,先看Network面板的请求列表,再看响应内容。这里可能是很多新手和老手的一道分水岭:新手习惯看到什么就点什么,老手习惯先看数据包再决定点什么。原因很简单,浏览器渲染之后的页面是“加工过”的,而HTTP请求和响应是原生的、未经修饰的,服务端到底返回了什么、设置了什么Cookie、写入了什么响应头,只有看数据包才能看见。
这道题能够成立,和GET方法被普遍认为“安全、只读、不改变服务端状态”的刻板印象有很大关系。很多开发者在写GET接口时,默认它就是一次简单的查询,不做权限校验,不做参数过滤,甚至会在GET接口后面跟着敏感操作。这道题就是利用了这个认知差,把源码泄露、弱类型比较、文件读取三个动作全部挂在了同一个GET请求下。换句话说,GET在这里既是攻击入口,也是出题人留给选手的信息通道。
这篇文章的阅读对象,既包括第一次接触CTF Web题的新人,也包括已经会做简单题但总被GET类题目卡住的人。我会把每一步怎么想、怎么试、怎么判断,按我当时的实际顺序还原出来,而不是只贴一个payload。看完之后,你至少能做到三件事:拿到一个白屏Web页面知道该从哪下手;用最直接的方式探测GET参数;理解弱类型比较为什么能成为漏洞,以及为什么只背payload不够。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 信息收集:先看一切,再动手
2.1 页面就那么点东西,那就把响应从头看到尾
题目首页是一段非常简单的HTML。没有表单、没有按钮、没有外部JS,甚至连CSS都是内联的。第一眼看上去,它就像一张白纸,没有任何可以交互的部分。但CTF里“白纸”往往是假象。
我的习惯是:打开浏览器开发者工具,切到Network面板,勾选Preserve log,然后刷新页面。这样一来,浏览器从发起请求到加载完资源的所有记录都会留在面板里,包括每个请求的URL、状态码、请求头、响应头、响应体、Cookie。很多时候,题目的Hint就藏在其中一个响应头里,页面本身反而不重要。
刷新之后,我逐一查看请求列表里的每一项。这个页面只有一个主文档请求,没有额外的JS/CSS请求,也没有图片请求。响应体里是一段纯HTML,中间有一行注释,注释下面还有一段被注释掉的JS。注释内容写的是“flag不在注释里”,后面那行被注释的JS指向了一个看起来不太对劲的接口路径。这个接口路径的URL上带着参数名,但我在浏览器里直接访问却返回了404。也就是说,真正的逻辑可能在另一个带相同参数的接口上,也可能是服务端对这个参数名做了处理,参数名本身才是线索。
这里就涉及一个很重要的Web题思路:页面给你看的永远是表面,真正的信息在注释、响应头、Cookie和脚本资源里。不要因为页面简单就放弃,第一轮信息收集的关键是把所有能看的东西都看一遍,哪怕看起来毫无意义。
2.2 GET和POST的区别,这道题为什么用GET
很多卡在这道题的选手,问题出在方法选择上。他们习惯用POST工具去提交数据,认为Web题的漏洞都藏在POST请求体里。但题目叫“GET”,出题人已经把方法写进标题里了,战场就在GET请求的URL和参数上。
我简单把GET和POST的差异列一下,方便对照理解:
| 对比项 | GET | POST |
|---|---|---|
| 参数位置 | URL的query string | 请求体(body) |
| 可见性 | 会在浏览器历史、代理日志、服务器日志中留下记录 | 请求体不会直接出现在URL上 |
| 长度限制 | 受URL长度限制(浏览器和服务端各有上限) | 请求体可以承载更大的数据 |
| 语义 | 获取资源,通常被认为只读 | 提交数据,可能改变服务端状态 |
| 常见漏洞入口 | 参数注入、信息泄露、缓存投毒、CSRF等 | 参数注入、文件上传、命令执行等 |
对攻击者来说,两者最大的差别在于可控位置和可见性。GET参数就在URL上,改起来非常方便,也容易被观察和调试。这道题让选手从GET入口进去,本质上是让选手把注意力放在“URL上的参数怎么被服务端解析和使用”这件事上。
在拿到题目时,我也怀疑过是不是要先用HEAD或者OPTIONS方法探测一下服务端对请求方法的处理。后来发现,直接用GET就行。原因很简单,页面本身就是通过GET访问的,服务端处理GET请求的主逻辑里已经包含了所有考点。如果一上来就切换到POST,反而会错过题目预设的路径。
2.3 用curl还原第一个请求
浏览器里的Network面板可以看,但调试和重放时命令行更干脆。我切到终端,用curl把最原始的GET请求完整还原出来,这一步的作用是用最低成本的工具拿到最原始的响应。命令很简单:
bash复制curl -i http://target.example.com/
-i参数让curl把响应头也打印出来。没有-i的话,curl默认只打印响应体,那样就看不到响应头里的关键信息了。加上-i之后,输出里既有响应头又有响应体,一眼就能看全。
实测返回的结果大致是这样的(域名和flag做了脱敏处理):
code复制HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Server: nginx/1.18.0
X-Hint: 参数不只是参数
Set-Cookie: session=guest; Path=/
... HTML 内容 ...
在这一堆响应头里,X-Hint的值是“参数不只是参数”。这个提示几乎是在直接告诉我:去看GET参数,参数肯定不只是表面上那一个。同时,Set-Cookie把session设置成了guest,这同样值得留意。在CTF题里,Cookie值经常用来表示身份等级,guest很可能对应的是低权限角色,后续可能需要想办法把它变成admin或者别的值。
2.4 从响应头和Cookie里读出Hint
X-Hint这个响应头,可能是出题人自定义的,也可能是框架自带的调试头。不管哪种,遇到不常见的响应头,第一反应应该是去搜索它的含义,或者观察它的值在哪些请求下会变化。我后来试过,只有当请求带特定参数时,X-Hint的值才会变,这一点进一步说明参数是这道题的核心开关。
Cookie值的变化也是一个观察点。初始请求拿到session=guest,那么下一步就可以试试:在带参数请求时,服务端会不会更新Cookie?如果我把session改成admin再发一次请求,是否会触发不同的逻辑?这类操作在别的题目里经常成为权限绕过的关键,虽然这道题最后没有用到Cookie篡改,但我仍然建议所有选手在拿到一个Web目标时,把Cookie的变化纳入信息收集清单。
关于提示信息的放置位置,不同出题人的习惯很不一样:有人放在页面注释里,有人放在robots.txt里,有人放在响应头里,还有人放在目录扫描才会发现的隐藏文件里。这道题把提示放在响应头,属于比较友好的做法,至少方向明确,不需要爆破。如果你做题时遇到了类似的自定义响应头,一定不要放过,它几乎就是出题人给你留的路标。
3. 参数枚举与源码泄露——把“看不见的逻辑”拉出来
3.1 先用手工,再用脚本:参数探测的两条腿
拿到Hint之后,第一步当然是猜参数名。Web题目里最常见的就是id、cmd、file、page、url、user、role、debug、source、flag这些。为什么猜这些?因为它们直接反映了服务端可能存在的功能逻辑。id往往对应数据库查询,cmd往往对应命令执行,file往往对应文件读取,debug往往对应调试信息泄露,source往往对应源码展示。
我一开始先手工试了几个最常见的参数。每试一个都观察响应长度和内容的变化。比如访问:
bash复制curl -i "http://target.example.com/?source=1"
如果响应里多出了PHP代码或者报错信息,说明这个参数触发了服务端的特定逻辑。这种“响应差异”就是参数探测的核心判断依据。手工试的好处是直观,但效率太低,所以批量的工作要交给工具。
3.2 Burp Intruder批量探测的实操细节
批量探测参数名,我最常用的是Burp Suite的Intruder模块。操作流程并不复杂:随便抓一个原始的GET请求,发送到Intruder,在URL的query string位置标记参数名作为payload位置,然后加载一个常见的参数名字典。这里我提一个比较实用的细节:跑完结果按Response Length排序,而不是按状态码排序。因为很多参数名的响应状态码都是200,但长度会有明显差异,长度异常的往往就是入口。
实测下来,?source=1这个参数在几百个候选里显得非常突出,响应长度比默认页面多了一截。单独访问:
bash复制curl -i "http://target.example.com/?source=1"
返回内容不再是一开始的HTML页面,而是一段PHP源码。到这一步,题目的第一层就交代清楚了:服务端开启了一个源码泄露的参数入口,通过source参数读取了主文件源码,源码里一定有下一步的逻辑。
3.3 源码泄露的常见入口:source、debug、bak、~文件
在Web源码泄露这个考点上,除了GET参数里藏着source开关之外,还有一堆常见的路径和文件名值得留意:/index.php.bak、/index.php~、/.git/、/www.zip、/backup.sql等等。这些都属于服务器上不该暴露但常常存在的敏感资源。在这道题里,如果source参数不存在,我大概率会去尝试备份文件和目录扫描。好在出题人手下留情,直接给了参数入口,省去了爆破的时间。
这里我也想多说一句:源码泄露在CTF里是最高频的信息收集场景之一。很多选手拿到题目后直接开始跑SQL注入,但真正的第一步是先把你面前的目标看穿。能直接读源码的题目,就没必要暴力试探;能通过提示定位接口的题目,就没必要目录爆破。信息收集做得越充分,后续的利用阶段就越轻松。
3.4 从源码差异推测服务端语言与框架
当?source=1返回PHP源码时,服务端语言基本确认了。再结合Set-Cookie的值格式和响应头里的字段,可以初步判断这是一个PHP环境。此时的分析重点就从“找到入口”转移到“读懂入口背后的逻辑”。
读源码时,不要只盯着某个函数,要看整个分支结构:入口参数在哪里被接收、做了哪些处理、在什么条件下才会输出敏感信息。这道题的源码很短,我却花了比前面所有探测都更长的时间去确认其中一个运算符的语义。因为这个运算符决定了payload的构造方式,也是整道题的核心考点。
4. 弱类型比较的底层原理与两种绕过方式
4.1 源码里的关键一行:== 和 === 差在哪
我拿到的源码,核心逻辑简化之后是这样:
php复制<?php
$data = $_GET['data'] ?? '';
$key = $_GET['key'] ?? '';
if (md5($data) == $key) {
echo file_get_contents('/flag');
} else {
echo "Try again.";
}
?>
这段代码的逻辑非常直白:读取GET参数data和key,计算data的MD5值,然后用==和key比较。比较成立就读取/flag,否则返回“Try again.”。考点藏在运算符上:==是松散比较,不是===严格比较。
松散比较在比较两个字符串时,如果两个字符串都能被解析为数字,PHP会先把它们转换成数字,然后比较数字是否相等。这里有个反直觉的点:两个完全不同的字符串,只要在数值上是相等的,==就会返回true。这就是“0e绕过”的根基。
4.2 MD5魔法哈希的原理与构造
MD5 Magic Hash之所以成立,是因为一个MD5值只要以“0e”开头、后面全是数字,它就会被PHP当作科学计数法表示的0来解析。0乘任何10的多少次方都是0,所以任意两个“0e数字串”在数值上相等。
常见的现成载荷:
| 字符串 | MD5值 |
|---|---|
| QNKCDZO | 0e830400451993494058024219903391 |
| s878926199a | 0e545993274517709034328855841020 |
| s155964671a | 0e342768416822451524974117254469 |
| s214587387a | 0e848240448830537924465865611904 |
只要题目代码用的是松散比较,这些载荷基本都能用上。以这个题来说,我可以用data=QNKCDZO,然后把key设成上面任何一个MD5串。因为md5("Q
