如果你打开一个网址,习惯性地往 URL 后面多加了几个字符,页面就把 flag 直接吐了出来,这种体验在刚接触 CTF Web 题时非常常见。Bugku 平台上这道 GET 题就是这样:逻辑简单到几乎没有绕弯,只要你猜到参数值,就能直接拿到 flag,全程没有任何权限验证。它适合刚入门的选手熟悉 HTTP 请求结构,也适合从做题转向写代码的人思考一个问题:为什么一个仅仅“猜参数值”的操作,就能突破一道本应有防护的关卡?
这篇文章我会从实际做题过程讲起,还原一次完整的参数猜解流程,再把这层“猜参数值”的技术外衣剥掉,聊聊它背后缺失的权限验证到底是什么。最后我会落到真实开发场景中,给出同类型接口漏洞的几种常见写法、修复方式,以及后续可以继续练习的方向。无论你是 CTF 新手,还是写过几个 Web 项目的开发者,这篇文章应该都能提供一些值得带走的东西。
1. 从一道 Bugku 的 GET 题说起:没有权限验证的入口意味着什么
1.1 第一次打开题目时的直觉判断
这道题在 Bugku 平台上的页面非常简单,没有复杂的交互界面,没有输入框,也没有跳转逻辑。打开之后就是一个平平无奇的页面,甚至乍一看都不知道该做什么。像我这种习惯先看“入口在哪”的人,第一反应就是按 F12,先把浏览器开发者工具开出来,看看请求长什么样。
这里有个做 Web 题的基本功:不要只盯着肉眼看到的页面,要看浏览器到底发出去什么请求、服务器返回了什么响应。 页面长得再简单,请求和响应里往往藏着关键信息。这道题打开开发者工具后能看到,整页只有一个普通的 GET 请求,URL 路径很干净,没有带任何查询参数,响应回来的 HTML 也没有明显异常。那问题就很清楚了:服务器端肯定隐藏着某个逻辑,只有当你往 GET 请求里加入正确参数时,它才会触发。
这类题目在 CTF 里被归为“逻辑漏洞”,说白了很多时候就是后端代码写得太随意——没有身份校验、没有会话管理、没有权限分级,只要请求参数猜对了,它就把 flag 给你。这也是我标题里提到“逻辑简单,猜到参数值就能直接获取 flag”的原因。
1.2 这类题为什么适合入门
简单并不等于没价值。这道题的考点很集中:GET 请求的参数传递机制。你要知道 URL 中问号后面是查询字符串,查询字符串由“参数名=参数值”组成,多个参数之间用 & 连接。如果你的 URL 里没有参数,那就是在告诉服务器“我只请求这个路径本身”。而这道题恰恰需要你去输入参数。
我见过不少初学者卡在第一步,原因不是不懂技术,而是不知道题目在问什么。这类题目的好处就是帮助你建立“做题思路”:服务器代码我看不到,但它一定有一个判断逻辑,比如 if ($_GET['key'] == 'flag')。我要做的就是通过尝试,把这个判断条件“喂饱”。
基础概念理清了,后面实操起来才不会慌。接下来我带你完整走一遍我的猜解过程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 一次完整的解题过程:我是怎么把 flag 问出来的
2.1 第一步:在页面和响应里找线索
按 F12 打开开发者工具,切到 Network 面板,刷新页面,能看到一个文档请求。点开这个请求,你会发现 Headers、Preview、Response 这些标签页。这里我建议先看 Response,也就是服务器返回的原始 HTML。
很多时候,出题人会在 HTML 注释里留线索。比如:
html复制<!-- hint: try to find the flag parameter -->
这就是在明示:参数名可能就叫 flag。还有的情况是网页里有一段 JavaScript,里面定义了一个变量但没有用到,或者某个接口地址写在隐藏的 input 标签里。总之,响应内容不能只看渲染后的页面,要看源码。我在这道题的响应内容里找到的线索是指向“某个参数名”的提示,具体就不再剧透更多了,因为留点悬念给还没做的人更有意思。
如果你发现响应里什么线索都没有,那就进入下一步:猜。猜参数是 Web 题里绕不开的能力,尤其在参数名比较常规的情况下。
2.2 第二步:构造参数名与参数值的猜解清单
猜参数不能瞎猜,需要积累一套常见的“口令表”。拿这道题来说,既然提示是 GET 且与 flag 直接相关,那优先尝试的参数名一般有这些:
| 参数名方向 | 常见参数名示例 | 对应的后端判断逻辑 |
|---|---|---|
| 直接与 flag 相关 | flag, get_flag, admin_flag, is_flag | $_GET['flag'] |
| 与逻辑开关相关 | admin, is_admin, auth, login, pass | $_GET['admin'] == 1 |
| 与身份相关 | user, username, id, uid | $_GET['user'] == 'admin' |
| 与访问控制相关 | key, token, secret, code | $_GET['key'] == 'flag' |
有了这份清单,操作方式就很简单:逐个在 URL 后面加上参数,观察页面响应有没有变化。比较常见的组合是:
text复制/index.php?flag=1
/index.php?flag=flag
/index.php?key=1
/index.php?key=flag
/index.php?admin=1
/index.php?auth=true
这里有一个小技巧:参数值不一定非要用有语义的字符串,很多后端代码只是判断“这个参数是否存在”或者“这个参数是否等于某个非空值”,所以先试 1、true、flag 这三个值,命中率很高。我当时就是用这样的顺序,把前面几个组合试了一遍,页面毫无反应;直到试到某个参数名加某个参数值的组合时,页面直接出现了 flag 字符串。那一刻的感受是很直接的:原来后端真的没设防。
2.3 第三步:用命令行工具复现并确认结果
浏览器地址栏固然方便,但真正到解题后期,尤其是要批量尝试很多参数组合时,在地址栏手动改 URL 效率太低了。建议尽快学会用命令行工具来发送 GET 请求。最基础的是 curl:
bash复制curl "http://目标站点/index.php?flag=1"
如果你在 Windows 上没装 curl,或者想要更灵活地控制请求头、写脚本批量测试,用 Python 的 requests 库也很方便:
python复制import requests
url = "http://目标站点/index.php"
params = {"flag": "1"}
resp = requests.get(url, params=params)
print(resp.text)
注意 requests 的 params 参数会自动把字典拼成 URL 查询字符串,不需要自己手动拼接,还能避免特殊字符转义的问题。拿到 flag 之后,再回头去响应里确认一下返回位置:它可能直接在 HTML 里,也可能藏在响应头的某个自定义字段中。藏在响应头的情况不多,但确实存在,所以养成看完整响应的习惯没坏处。
这道题的完整链路就是:“发现页面没有线索 → 尝试参数组合 → 命中参数 → 拿到 flag”。整个过程不超过十分钟,但这里面的方法论,在后续更复杂的 Web 题里我们还会反复用到。
3. 剥开题目看本质:“猜到参数值就能拿 flag”背后缺了什么
3.1 问题的核心是信任了不该信任的输入
拿到 flag 之后,如果只停留在“我 ac 了”的兴奋里,那这道题就白做了。停下来想一想:为什么一个参数值就能让服务器相信“你是有权限的那个人”?
往深了说,这是信任边界的问题。服务器收到一个 HTTP 请求,它其实只有两个途径了解“你是谁”:一是请求本身所带的身份凭证(比如 Cookie、Authorization 头),二是请求参数中携带的信息。如果一个接口把“是否通过验证”这个决定权完全交给了参数,那就等于把门卫的判断标准写在了门禁卡上,而不是存储在门卫的登记簿里。任何人只要把卡上“允许进入”一栏改成“是”,就能大摇大摆进门。
生活化的类比:一个仓库门口贴着标语“如果你是仓库管理员,请直接领取钥匙”。你只需要在门禁对讲机里说一句“我是管理员”,门就开了。你没有出示工牌,值班人员也没有核对人脸。这就是典型的“只有参数判断,没有身份验证”。CTF 题里的 flag 就是仓库里的货物,而缺掉的“值班人员核对身份”这一环,就是权限验证。
3.2 权限验证到底应该出现在哪个环节
一个正常的安全模型里,权限验证会在多个环节协作完成:
- 身份认证(Authentication):确认“你是谁”。常见实现是登录后由服务端生成会话标识(Session ID),通过 Cookie 带给浏览器,或者返回一个签名的 Token。
- 权限授权(Authorization):确认“你能做什么”。即使知道你是用户 A,也要判断用户 A 是否有权限调用某个接口、操作某个数据。
- 输入校验(Input Validation):确认“你传的参数合法”。参数值不能直接当成逻辑开关使用,更不能用它来替代身份判断。
| 环节 | 解决的问题 | 本题缺失的部分 | 正确做法 |
|---|---|---|---|
| 身份认证 | 你是谁 | 完全没有,谁都能请求 | 登录后发会话凭证 |
| 权限授权 | 你能做什么 | 任何参数都能触发 flag | 先鉴权再判断业务逻辑 |
| 输入校验 | 参数是否合法 | 参数名/值被直接信任 | 后端定义白名单校验 |
回到这道题本身,后端代码大概长这样:
php复制if ($_GET['flag'] == 'flag') {
echo $flag;
}
写这行代码的人不是没有“验证”,而是验证错了对象。他验证了“参数是不是等于 flag”,却完全没有验证“请求者是否有资格读取 flag”。这就是我标题里说的“缺乏权限验证”的真正含义。
3.3 为什么这类逻辑会在真实项目里出现
你可能会想:这种低级错误,真实项目里应该没有吧?恰恰相反,类似问题在真实项目里出现频率相当高,只是表现形态不同,稍后第 4 节我会细讲。这里先说说为什么会发生:开发者在写一个“功能”时,第一反应通常是让功能跑通,而不是给功能做防护。如果这个接口是在内网部署、仅供测试使用,或者当时觉得“这个功能只有内部人知道”,就很容易省略权限验证。时间一长,接口被公网扫描器发现,就变成了突破口。
还有一个常见原因是,开发者把“参数来自前端请求”和“参数是可信的”画了等号。这是 Web 安全里最基本的误解。HTTP 请求本身就是不可信的,请求头可伪造,请求参数更可伪造。只有把“请求内容”和“身份信任”区分开,才能在设计接口时保持清醒。
4. 别以为只有 CTF 题才这样:真实开发里的同款翻车案例
4.1 案例一:只凭接口路径进入的管理后台
有一个非常经典的真实案例:某系统部署了一个管理后台,地址形如 /admin。前端虽然做了登录页,但后端对 /admin 路径下的所有接口没有做任何拦截。如果运维人员忘了在前端路由之外配置后端鉴权,那么攻击者只要直接访问后台页面,或者直接请求后台接口,就能绕过登录。
这和 Bugku 这道 GET 题其实是一个逻辑:入口路径是公开的,参数是可知的,但后端没有在关键位置检查“当前访问者是否登录”。防御方法并不是“把后台路径改得隐蔽”,而是要确保任何管理接口都先走统一的鉴权中间件。
4.2 案例二:接口里传一个 user_id 就能操作别人的数据
这类问题在真实项目里最常见的叫法是“越权漏洞”。一个接口可能是这样设计的:
text复制GET /api/user/update?user_id=1001&nickname=test
请求里传了 user_id,后端就按这个 ID 去更新数据库里对应用户的资料。问题是,后端完全没有校验“当前登录用户”是否就是 1001。于是任何一个登录用户,只要把 user_id 改成别人的 ID,就能修改别人的昵称、邮箱、甚至密码。
这种漏洞比直接给 flag 更隐蔽,因为它功能是“正常”的,接口也确实要接收某个用户 ID,但忽略了最重要的一环:把请求者身份和目标资源归属绑定校验。正确做法通常是:
python复制# 错误:直接信任参数里的 user_id
user_id = request.GET["user_id"]
update_profile(user_id, nickname)
# 正确:从会话中取当前用户身份,而不是信任参数
current_user_id = request.session["user_id"]
update_profile(current_user_id, nickname)
从会话中取身份,而不是让前端传身份,这句话值得刻在工位上。
4.3 案例三:“内网接口默认不加鉴权”的侥幸心理
还有一种常见翻车场景是内网服务直接对外。开发同学说“这个服务只在内网用,外网访问不到”,于是接口不设认证。结果公司某个业务被 SSRF 或者反向代理配置漏洞穿透,内网接口被打通,攻击者直接调用接口拿数据。
这种场景和 CTF 题目的差异只是“环境”,核心问题仍然一样:没有权限验证的接口,就像没有锁的房门,不能指望别人不知道门的位置。 安全领域的默认假设是:攻击者知道所有接口路径,知道所有参数名,唯一的防线就是服务端校验。把这条假设想明白了,很多安全设计就不会走偏。
5. 如果这是真实项目:正确补上权限验证的几种姿势
前面已经意识到了问题,那如果这道题是一个真实项目里的接口,应该怎么修?我从简单到复杂,把常见的权限验证方案梳理一遍。
5.1 方案一:会话鉴权,最基础的 Cookie/Session 校验
如果你用的是传统服务端渲染应用,最直接的做法是引入登录机制,登录成功后,服务端把 Session ID 写入 Cookie。每次请求时,受保护的路由先检查 Session 是否存在、是否过期,再决定要不要继续执行。
以 Python Flask 为例:
python复制from flask import Flask, session, request, abort
app = Flask(__name__)
app.secret_key = "some-secret-key"
@app.route("/flag")
def get_flag():
if "user_id" not in session:
abort(401) # 未登录,直接拒绝
return "flag{only-for-logged-in-users}"
@app.route("/login")
def login():
session["user_id"] = 1 # 模拟登录成功
return "logged in"
这里的关键点不是代码本身,而是它的结构:“获取 flag”的逻辑和“验证登录状态”的逻辑被强制分开了。 /flag 接口的第一行就是鉴权判断,不通过就直接 abort,根本不会走到 flag 输出的代码。这样就不会出现“参数猜对就放行”的问题。
5.2 方案二:Token 鉴权,适合前后端分离的接口
如果你的项目是前后端分离,接口没有 Session 概念,那通常会使用 Token 鉴权。前端在登录后拿到一个 Token,每次请求在 Authorization 请求头带上它,后端验证 Token 的签名和有效期。
一个很常见的实现是 JWT。它的结构是“头.载荷.签名”,签名使用服务端密钥生成,攻击者无法伪造:
text复制Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxfQ.xxxx
需要注意的是,JWT 的载荷只是 Base64 编码而不是加密,用户自己就能解码看到里面的内容。所以千万不要把敏感数据和权限判断依据直接放在 JWT 明文部分,必须依赖服务端验签后的结果来做判断。看到这里你就能理解,为什么像“?flag=1”这种参数设计如此不可靠了——它连最基本的防伪签名都没有。
5.3 方案三:越权检查,验完身份之后还要验权限
会话和 Token 解决了“你是谁”的问题,还解决不了“你能不能做这件事”。比如 4.2 案例里的更新用户资料接口,就算确认了用户已经登录,还得确认他要操作的数据确实属于他自己。这就是所谓的水平越权检查。
正确的流程应该是:
- 从会话/Token 中取出当前用户 ID;
- 从请求参数中取出目标资源 ID;
- 判断当前用户是否有权限操作目标资源;
- 通过校验才执行真正的业务逻辑。
python复制@app.route("/api/user/update", methods=["POST"])
def update_user():
current_user_id = get_current_user_id() # 从会话获取,不是从参数获取
target_user_id = request.form["user_id"]
if current_user_id != int(target_user_id):
abort(403) # 无权限操作目标用户
# 通过校验,继续执行更新逻辑
update_profile(target_user_id, request.form["nickname"])
这里每个环节都不能省。现实项目里经常出现“登录校验做了,但越权没做”的情况,攻击者成功登录一个低权限账号后,利用接口缺陷操作其他用户的数据,产生的危害比题目里的 flag 泄露要大得多。
5.4 方案四:别把业务参数设计成前端可控的开关
还有一层思维要转变:任何时候都不要把“是否放行”这种核心安全判断,设计成由请求参数来决定的开关。 这是这类题目“猜参数值”的直接根源。
反面写法:
php复制// 前端传 is_admin=1 就是管理员?
$isAdmin = $_GET['is_admin'] ?? 0;
if ($isAdmin == 1) {
// 管理员逻辑
}
正确做法是,管理员身份必须来自后端可验证的凭证,比如数据库角色表、Token 里的角色声明,而不是请求里“自报家门”的参数。如果你发现自己写的代码里,某个权限判断依赖的是一个用户可以任意修改的请求字段,那这个设计本身就有问题。权限判断的依据应该只有两个来源:经过服务端验证的会话凭证,或者服务端存储的状态。
6. 刷完这道题之后,下一步该练什么
6.1 同一场景的进阶变体:从 GET 到 POST、Header 与 Cookie
这道题考的是 GET 参数,但“通过请求内容控制服务端逻辑、缺乏权限验证”的套路远不止 GET 一种。后续你可以找几个相似方向的题目继续巩固:
- POST 参数型:参数从 URL 挪到了请求体里,常见格式是
application/x-www-form-urlencoded或 JSON。解题思路一样,只不过要改请求方法并设置请求头。 - Cookie 伪造型:题目把某个身份标识放在 Cookie 里,比如
admin=0。尝试改成admin=1再访问,往往会触发新的逻辑。这其实就是“猜参数值”的一种变体。 - Header 注入型:某些老题目会检查
X-Forwarded-For请求头,当成客户端 IP 来源。如果后端用它做白名单判断,改 Header 就能绕过。这类题能让你意识到:请求头的可信度同样为零。 - Referer 校验型:服务器只允许从某个来源页面跳转过来的请求,如果不带或者带错 Referer 就会拒绝。绕过方法同样是通过工具修改请求头。
这些变体表面上看各不相同,内核一模一样:服务端把请求中部分内容当成了可信任的输入,并由此做出权限判断。 吃透“不可信输入”这个概念,一道 GET 题的解题经验直接迁移到后面所有类似题目中。
6.2 从“找 flag 的人”到“修漏洞的人”
做题做到一定阶段,真正拉开差距的不是刷了多少题,而是能不能从“拿 flag”转向“补漏洞”。比如这道题,解题只需要一分钟,但如果让你以开发者的身份去修复它,你能不能在五分钟内给出一个方案?
我的建议是,每做完一道题,都给自己加一个复盘任务:如果这个接口出现在你的项目里,该加什么防护? 你可以先看它是缺身份认证、缺权限校验,还是缺输入过滤,然后写一段真实代码去修复。这个习惯会慢慢改变你看接口的方式——不再只看功能,而是先问“这个接口能被滥用吗”。
我自己在带新人的时候常说一句话:CTF 题目里的漏洞,现实项目里几乎都有对应的翻版。Bugku 这道 GET 题,本质就是真实世界里“后台接口没有鉴权”的缩影。你从题目里理解到的“别信任参数”,在以后的代码审查、方案设计、安全测试中都会一直用到。
提示:如果你是想系统练习 Web 安全方向,建议把同一类的题目集中刷完,然后自己搭一个简单的用户系统,故意写出几个未鉴权接口,再用 Burp Suite 或者 curl 去测试自己写的接口。只有亲手写出一个有权限验证问题的接口,再亲手把它修好,这个概念才算真正内化。
最后再分享两个实际操作中的体会。第一,做这类题时不要急着刷新页面试下一个值,先在浏览器地址栏保留住当前 URL,把尝试过的参数组合记录下来,避免重复劳动。第二,拿到 flag 之后回看一下响应头和完整 HTML,因为有些题目会故意在另外的位置放隐藏 flag 或提示,多看一眼不亏。安全这条路,“细心”往往比“聪明”更能帮你避开坑。
