从解密验签到任意用户登录漏洞的一次真实案例"

admin 2026-10-16 13:40:53 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

从解密验签到任意用户登录漏洞的一次真实案例

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 获取异常
  • 网关上下文串号
  • 或分布式鉴权状态错乱

结束,下机,漏洞目前已经修复了,但是没问客户是用什么手法修复的,也不知道漏洞成因具体是哪一种,你们遇到过这种缓存漏洞吗?

从解密验签到任意用户登录漏洞的一次真实案例" 网络安全文章

从解密验签到任意用户登录漏洞的一次真实案例"

本文分享一个从解密验签到任意用户登录的完整漏洞挖掘案例。系统使用HMAC-SM3签名及SM4-CBC多层编码防护,作者通过前端JS动态调试还原签名算法,利用Flask服务配合BurpSuite实现签名绕过。后续发现接口存在缓存漏洞,通过多次重放数据包获取在线用户session信息,结合会话滞留问题实现任意用户登录。文章提供了详细的技术分析过程和可操作的漏洞利用思路。
首批入选!新华三AI安全网关通过中国信通院权威评估认证" 网络安全文章

首批入选!新华三AI安全网关通过中国信通院权威评估认证"

新华三AI安全网关通过中国信通院智能体安全网关技术能力评估,成为业内首批获认证产品。评估围绕身份可信、授权可控、交互可溯等维度,涵盖两大能力域和十大能力模块。该产品提供统一接入、智能调度、身份权限管控、全链路防护及全景运营看板五大核心能力,旨在解决AI服务接入分散、成本不可控、权限边界模糊等安全问题,为企业AI应用落地提供安全基座。
Zigbee近场打进网关:拆PhilipsHueBridge的OTA堆溢出(CVE-2026-3555)" 网络安全文章

Zigbee近场打进网关:拆PhilipsHueBridge的OTA堆溢出(CVE-2026-3555)"

本文详述Pwn2OwnCork赛事中Synacktiv团队通过Zigbee近场攻击PhilipsHueBridge的完整过程,利用CVE-2026-3555堆溢出漏洞实现远程代码执行。攻击链包括短接开机脚开SSH、逆向ipbridge二进制、用nRF52840假冒飞利浦灯泡触发DownloadBlob状态机、利用musldlmalloc的Vudo任意写技术劫持函数指针获得reverseshell。文章强调RF面安全评估的重要性,并提出分片上界校验、封死调试口、启用installcode等缓解措施。
评论:0   参与:  0