从解密验签到任意用户登录漏洞的一次真实案例
C4安全
2026年10月14日 10:32
江苏
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
以下文章来源于我不懂安全
,作者Vlan911
我不懂安全
.
分享挖掘漏洞小技巧,分享安全案例以及一些安全动态,分享实用技术
本文首发自奇安信攻防社区-https://forum.butian.net/share/4994
本文首发自奇安信攻防社区-https://forum.butian.net/share/4994
本文首发自奇安信攻防社区-https://forum.butian.net/share/4994
前言:
系统使用了Sign签名校验来防范重放攻击,sign签名采用多层编码的防护方式导致测试难度增加,通过前端动态调试解密签名的编码方式,而后利用flask server配合burpsuite的python脚本实现自签名实现签名绕过;后续测试发现系统某个接口存在缓存漏洞,通过多次重放某个接口数据包数千次可以返回多个在线用户的session会话信息,利用这些会话信息可以登录对应用户,以此实现任意用户登录的效果
漏洞前提:
至少有一个低权限用户
漏洞发现难度:
中等
签名校验,是防护重放攻击的重要手段;常规签名校验有大致两种表现形式:一种是不能修改请求体参数,但是可以原样重放数据包;第二种是签名使用一次立即失效
本次笔者遇到的场景便是第二种,签名使用一次立即失效;一般这种情况都是因为系统加入了timestamp相关从一次性因子,因此请求在使用一次后立即失效
一般情况下,签名校验大多数都是通过前端生成的,因此可以通过尝试在前端进行js动态调试去观察签名的生成方式;当然了签名的生成方式并非是绝对的,也有一些安全系数更高的系统的签名生成确实是在后端生成的,对于后端生成的那种成本较高,对系统的负载要求也更高,因此不是特别常见,只适用针对某些特定请求的验签使用场景。
当然了,还有一些金融常用的:前端签名+风控上下文,大致效果如下:
签名 = HMAC(参数 + sessionId + deviceId + riskScore)
其中:
sessionId
、deviceId 由后端下发
* riskScore
来自风控系统
* 即使签名算法被还原,换设备 / 换账号也会失效
这在金融 App、大厂开放平台里非常常见。
扯远了,下述为常见的第二种验签的大致流程:
客户端 服务端
│ │
│ 1. 生成 nonce = UUID │
│ 2. 生成 timestamp = now() │
│ 3. 计算 signature │
│ 4. 发送请求 ────────────────▶│
│ │
│ │ 5. 校验 timestamp 在窗口内
│ │ 6. 查 Redis: nonce 是否存在?
│ │ ├─ 存在 → 拒绝(重放攻击)
│ │ └─ 不存在 → 继续
│ │ 7. 重新计算 signature 比对
│ │ 8. 校验通过 → 处理业务
│ │ 9. Redis SET nonce \"1\" EX 300
│ │
│ ◀──────────────── 返回结果 │
本次项目通过堆栈追踪的方式,很容易就找到了验签的点位,首次追踪到的代码如下:
const n = (new Date).valueOf() 时间戳i.default.ls.get(r.k); #r.k=Access-Token 这里获取的是登录后的Authorization: Bearer efe1axxxxxaeb8后续追踪参数如下:return o = m(t + \",\" + n + \",\" + a), # t值为请求接口的uri,n是时间戳,a是efe1aaxxxxx62aeb8进入到o = m(t + \",\" + n + \",\" + a)函数,发现首先进行拼接
再次进入下一层,发现是hmac-sha-SM3,且key是硬编码3c444xxxxxxdf4cc
不知道为啥粘进来没格式了,凑合看吧
function g(e, t) { if (e = \"string\" == typeof e ? p(e) : Array.prototype.slice.call(e), t) { if (\"hmac\" !== (t.mode || \"hmac\")) throw new Error(\"invalid mode\"); let n = t.key; if (!n) throw new Error(\"invalid key\"); return n = \"string\" == typeof n ? p(n) : Array.prototype.slice.call(n), f(function(e, t) { for (t.length > 64 && (t = u(t)); t.length < 64; ) t.push(0); const n = l(t, h) , i = l(t, d) , r = u([...n, ...e]); return u([...i, ...r]) }(e, n)) } return f(u(e)) } const m = e=>g(e, { key: \"3c44xxxx4cc\" });
输出的位置在return f(u(e)),所以先调用u(e)函数,然后调用f()函数,因此需要在 r = u([...n, ...e]);的位置步入看看里面的内容
function u(e) { let t = 8 * e.length , n = t % 512; n = n >= 448 ? 512 - n % 448 - 1 : 448 - n - 1; const i = new Array((n - 7) / 8) , r = new Array(8); for (let a = 0, o = i.length; a < o; a++) i[a] = 0; for (let a = 0, o = r.length; a < o; a++) r[a] = 0; t = t.toString(2); for (let a = 7; a >= 0; a--) if (t.length > 8) { const e = t.length - 8; r[a] = parseInt(t.substr(e), 2), t = t.substr(0, e) } else t.length > 0 && (r[a] = parseInt(t, 2), t = \"\"); const l = new Uint8Array([...e, 128, ...i, ...r]) , u = new DataView(l.buffer,0) , h = l.length / 64 , d = new Uint32Array([1937774191, 1226093241, 388252375, 3666478592, 2842636476, 372324522, 3817729613, 2969243214]); for (let g = 0; g < h; g++) { a.fill(0), o.fill(0); const e = 16 * g; for (let o = 0; o < 16; o++) a[o] = u.getUint32(4 * (e + o), !1); for (let o = 16; o < 68; o++) a[o] = (f = a[o - 16] ^ a[o - 9] ^ s(a[o - 3], 15)) ^ s(f, 15) ^ s(f, 23) ^ s(a[o - 13], 7) ^ a[o - 6]; for (let s = 0; s < 64; s++) o[s] = a[s] ^ a[s + 4]; const t = 2043430169 , n = 2055708042; let i, r, l, h, p, m = d[0], v = d[1], y = d[2], b = d[3], x = d[4], _ = d[5], w = d[6], S = d[7]; for (let u = 0; u < 64; u++) p = u >= 0 && u <= 15 ? t : n, i = s(s(m, 12) + x + s(p, u), 7), r = i ^ s(m, 12), l = (u >= 0 && u <= 15 ? m ^ v ^ y : m & v | m & y | v & y) + b + r + o[u], h = (u >= 0 && u <= 15 ? x ^ _ ^ w : x & _ | ~x & w) + S + i + a[u], b = y, y = s(v, 9), v = m, m = l, S = w, w = s(_, 19), _ = x, x = c(h); d[0] ^= m, d[1] ^= v, d[2] ^= y, d[3] ^= b, d[4] ^= x, d[5] ^= _, d[6] ^= w, d[7] ^= S } var f; const p = []; for (let a = 0, o = d.length; a < o; a++) { const e = d[a]; p.push((4278190080 & e) >>> 24, (16711680 & e) >>> 16, (65280 & e) >>> 8, 255 & e) } return p }d = new Uint32Array([1937774191, 1226093241, 388252375, 3666478592, 2842636476, 372324522, 3817729613,
2969243214]);这个初始化向量属于SM3固定写法
https://www.btool.cn/hmac-generator 使用在线站点验证下
继续往后走
o = o + \",\" + n + \",\" + a + \",\" + C(),
这里没什么可说的,属于hash加盐的步骤,又重新加了一遍,C()是UUID
o = Object(v.b)(o)最后一层加密是SM4加密const i = \"GJwxxxxJW\" , r = new (0, n(\"85d2\").sm4)({ key: i, mode: \"cbc\", iv: i, cipherType: \"base64\" }) , a = e=>r.encrypt(e) , o = e=>r.decrypt(e) },可以看到,这里的key和iv都是写死的,并且是CBC的,因此后边就不用看了,想看跟进r.encrypt(e)也可以由此可以确定解密流程`Step 1: 构造基础串base = path + \",\" + timestamp + \",\" + tokenStep 2: 计算签名sign = HMAC-SM3(base, fixed_key)Step 3: 二次拼接payload = sign + \",\" + timestamp + \",\" + token + \",\" + uuidStep 4: 对称加密cipher = SM4-CBC(payload, dynamic_key, dynamic_iv)Step 5: Base64final = base64(cipher)Burp插件(Jython)↓ HTTP本地 Python Flask 服务(gmssl)↓返回:Sign / Encrypttype`
编写简单的flask server代码后运行如下
burpsuite插件安装如下
此时burpsuite已经可以做到抓包改包重放了
以上仅做到了测试的基础前置条件,接下来开始正常测试,重新进行登录,发现登录请求返回包存在多种不同的uri地址,是的,不是多个,是多种,一种像是后台自定义的短路由,一种像是后台代码里的正常路由
进入到后台是这个样子的
可以看到,其实是没有什么用户管理之类功能的,但是在apirights列表里笔者看到了很多的关于用户的东西,于是大胆的对接口进行了fuzz,fuzz后发现果然是看到了一些奇怪的东西,,其中一个接口泄露了其他用户的用户名和密码,并且密码字段与登录请求密码字段是一致的
尝试使用登录接口对获取的账户密码进行测试,很可惜,存在二次登录认证
而后发现fuzz的接口里有一个退出登录接口,但是退出接口已经执行了,按理说会话也注销了,后面还在fuzz的接口依然没有被影响,说明此处存在会话滞留漏洞,但是目前这个洞并没有什么卵用
于是继续翻看接口,其中有一个接口吸引了我的注意力
这个接口并不是登录接口,但是他却返回了和登录成功请求的数据返回包几乎一样的数据,于是笔者将这个数据包发送至重放界面,本打算像测试一下重放,结果却出人意料,第一次重放依然是test1用户
再次重放,数据内容竟然发生了变化
这就很奇怪,于是反复的将数据包重放,发现重放的次数越多,蹦出来的其他用户的东西记录越大,于是直接扔intruder里进行枚举10000次,果然不出所料,成功的拿到了一个信息管理员的用户模版
拿到这个的比这想的第一个打法,就是重新登录,输入任意用户和密码,然后直接构造返回包,把这个数据包整个贴进去,然后再伪造一条set-cookie:JSESSIONID,这样又能任意用户登录,又能绕过二次认证,还不用知道密码
模拟正常登录,用户名密码乱写
替换掉拿到的高权限用户模版,手动把JSESSIONID构造进去
构造结束后,一个包一个包的过,然后发现进入到了后台,此时数据还没加载出来,因为数据包是一个一个走的
但是数据包放着放着,笔者发现莫名其妙的就退出去了
按理说页面都加载进来了,不应该会退出去,随便找一个401状态码的数据包转发到repeater
这个时候发现,他的header头里面的cookie这么长一串,并且似乎有两个JSESSIONID,怀疑是不是这里冲突了,也可能是那个LSESSIONID有问题,于是把后边的都给他删了,重新发包,发现有数据了
因此结论大概就是请求体冲突,这个问题很好解决,直接利用burpsuite的替换功能,把cookie做一个全局替换
再次模拟登录,这个时候,神奇的一幕发生了
好吧,最后发现其实是系统缓存问题,只需要把浏览器关了重启,然后直接替换请求就能实现任意用户登录的操作;
经过对接口的多次请求,笔者尝试重复请求10000次、20000次发现,返回的用户数据都是单用户的,甚至有的时候会把别的用户的密码也返回出来,并且一个用户数据可能会重复几百次几千次,于是笔者有了大胆的猜测:
- 当前线程残留用户
- 缓存污染
- Session 获取异常
- 网关上下文串号
- 或分布式鉴权状态错乱
结束,下机,漏洞目前已经修复了,但是没问客户是用什么手法修复的,也不知道漏洞成因具体是哪一种,你们遇到过这种缓存漏洞吗?
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论