国密SM2-SM4-SM3混合加密与滑块行为指纹实战解析
1. 这不是“教你怎么登录”,而是拆解一套真实跑在生产环境里的国密防护逻辑
你有没有遇到过这样的情况:打开一个政府类或金融类平台,输入账号密码后,页面不直接提交,而是弹出一个滑块验证框;拖动完成后,网络请求里却看不到明文密码,甚至整个登录参数都像被“加密压缩”过一样,全是Base64编码的长字符串?我第一次调试某省级电子税务局的登录接口时,就卡在这一步——F12抓包看到的login请求体是类似{"data":"gZv...","sign":"qXm..."}的结构,点开data解码后仍是乱码,sign字段长度固定64位,明显是哈希值。这不是前端简单加个MD5,也不是后端随便套个AES;它背后是一整套符合《GM/T 0002-2012 SM2椭圆曲线公钥密码算法》《GM/T 0004-2012 SM4分组密码算法》《GM/T 0001-2012 SM3密码杂凑算法》三重规范的混合加密链路,外加一个与时间戳、随机数、用户行为深度耦合的滑块验证签名机制。
这个标题里的“逆向解析”,不是黑产意义上的暴力破解,而是作为一名合规安全工程师/系统集成开发者,在获得授权前提下,对已上线政务系统中公开暴露的前端加密逻辑进行白盒级还原。它解决的核心问题是:当你要为该平台开发配套的自动化申报工具、第三方单点登录网关、或做等保测评中的密码应用安全性评估时,如何在不依赖原厂SDK、不调用其私有JS文件的前提下,仅凭浏览器中可获取的静态资源(JS代码、HTML结构、网络请求特征),100%复现其加密流程与验证规则。关键词“国密算法”“SM2-SM4-HMacSHA256”“滑块验证”不是并列关系,而是一个严密的嵌套结构:SM2用于密钥协商与签名验签,SM4用于会话密钥加密后的业务数据加解密,HMacSHA256则作为滑块行为指纹的完整性校验核心。全文不涉及任何服务端源码、不触碰数据库、不绕过身份核验,所有操作均在前端可控范围内完成。适合两类人细读:一是正在对接政务/税务/金融类国密改造项目的开发同学,二是需要出具商用密码应用安全性评估报告的安全工程师。接下来,我会带你从Chrome控制台的一行debugger开始,逐层剥开这三层加密壳。
2. 滑块验证不是“拖过去就行”,它的指纹生成逻辑才是整个加密链路的起点
2.1 滑块行为数据的采集维度远超你的想象
很多人以为滑块验证只是记录“是否拖到终点”,但实际生产系统中,它采集的是一个高维行为向量。我在该税务平台的滑块JS文件(slider.min.js)里定位到核心采集函数collectTrackData(),它并非只取起点和终点坐标,而是以10ms粒度持续监听鼠标移动事件,构建了一条包含27个字段的轨迹快照。关键字段包括:
| 字段名 | 类型 | 含义 | 实测典型值 |
|---|---|---|---|
t0 | number | 滑块初始化时间戳(毫秒) | 1715238942156 |
t1 | number | 首次mousedown时间戳 | 1715238942201 |
t2 | number | mouseup时间戳 | 1715238942387 |
dx | number | X轴总位移(像素) | 302 |
dy | number | Y轴总位移(像素) | 12 |
points | array | 坐标点序列([x,y,ts]三元组) | [ [10,20,201], [15,21,211], ... ] |
speeds | array | 相邻点间瞬时速度(px/ms) | [0.5, 0.8, 1.2, ...] |
accelerations | array | 瞬时加速度(px/ms²) | [0.03, 0.04, -0.01, ...] |
提示:
points数组长度不是固定的,它取决于用户拖动速度——慢速拖动会采集更多点,快速拖动则点更稀疏。这直接导致后续哈希结果的不可预测性,是防机器模拟的关键设计。
最反直觉的是speeds和accelerations的计算方式。它不是简单用(x2-x1)/(t2-t1),而是先对原始坐标点做三次样条插值(spline interpolation),再求导得到平滑的速度曲线。我在本地用Python复现时发现,如果跳过插值直接线性计算,生成的签名永远无法通过服务端校验。这说明服务端必然运行着同构的插值算法,否则无法保证两端行为指纹一致。
2.2 HMacSHA256签名的密钥来源:不是硬编码,而是动态派生
滑块数据采集完毕后,下一步是生成签名。平台没有使用固定密钥,而是通过一个叫deriveSliderKey()的函数动态生成。该函数接收两个参数:userToken(前端存储的短期token,形如eyJhb...)和sliderSalt(从后端API/api/v1/slider/salt获取的64位十六进制字符串)。其核心逻辑是:
function deriveSliderKey(userToken, sliderSalt) { // 步骤1:对userToken做SM3哈希(注意!不是SHA256) const sm3Hash = sm3(userToken); // 输出64字符hex字符串 // 步骤2:将sm3Hash与sliderSalt拼接后,再做一次SM3 const keySeed = sm3(sm3Hash + sliderSalt); // 步骤3:取keySeed前32字节作为HMac密钥 return keySeed.substring(0, 64); // 32字节=64hex字符 }这里有两个关键细节必须注意:
- SM3不可替换为SHA256:我最初用Node.js的
crypto.createHmac('sha256', key)测试,始终失败。直到对比国密标准文档才发现,SM3的初始向量(IV)、消息填充规则、压缩函数都与SHA256不同。必须使用国密专用库,如gm-crypt(Node)或sm-crypto(浏览器)。 sliderSalt是有时效性的:该salt有效期仅90秒,且每个请求返回的salt都不同。这意味着你不能缓存salt,必须在触发滑块前实时调用/api/v1/slider/salt接口获取最新值。实测中若复用5分钟前的salt,服务端会直接返回ERR_SLIDER_SALT_EXPIRED。
2.3 滑块签名的最终组装:JSON序列化规则决定成败
生成HMac密钥后,对滑块数据对象进行签名。但这里有个致命陷阱:JSON序列化的顺序必须与服务端完全一致。平台使用的不是标准JSON.stringify(),而是自定义的stableStringify()函数,它强制按字段名ASCII升序排列。例如,原始对象:
{ "t0": 1715238942156, "dx": 302, "points": [[10,20,201]], "t1": 1715238942201 }标准JSON.stringify()可能输出{"t0":...,"dx":...,"points":...,"t1":...},但stableStringify()会重排为{"dx":302,"points":[[10,20,201]],"t0":1715238942156,"t1":1715238942201}。我曾因忽略此点,导致签名值相差0.3%,反复调试3小时才发现问题根源。服务端校验时,正是用同样的排序规则重建JSON字符串后再计算HMac,顺序错一位,整个哈希值就全错。
最终签名流程如下:
- 调用
/api/v1/slider/salt获取sliderSalt - 执行
deriveSliderKey(userToken, sliderSalt)得到密钥 - 构建滑块数据对象(含插值计算的
speeds/accelerations) - 用
stableStringify()序列化为字符串 - 用
hmacSha256(key, serializedString)计算64位hex签名 - 将签名填入登录请求的
sliderSign字段
注意:
userToken并非登录态token,而是滑块组件初始化时从<input id="user-token">中读取的隐藏域值,它在页面加载时由后端注入,有效期2小时。若页面停留过久,需刷新页面重新获取。
3. SM2密钥协商不是“前端生成公钥发给后端”,而是双端预置证书体系下的会话密钥派生
3.1 你以为的SM2流程 vs 实际运行的SM2流程
绝大多数教程讲SM2,都是“前端生成密钥对→公钥发后端→后端用公钥加密→前端用私钥解密”。但在该税务平台中,前端根本没有生成SM2密钥对的动作。我在所有JS文件中搜索generateKeyPair、createKey等关键词,结果为空。取而代之的是一个叫getSm2PublicKey()的函数,它直接返回一个硬编码的64字节十六进制字符串:
function getSm2PublicKey() { return "04a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0"; }这个64字节字符串,正是SM2标准中定义的未压缩格式公钥(04开头,后接64字节X坐标+64字节Y坐标)。它对应的服务端私钥,由平台CA中心统一管理。这意味着:整个系统的SM2加密,并非为每次登录动态协商新密钥,而是所有客户端共用同一对SM2密钥,用于加密一个临时生成的SM4会话密钥。
3.2 SM4会话密钥的生成与SM2加密:两步不可逆的密钥封装
登录时真正被加密的,不是密码明文,而是一个32字节的随机SM4密钥。该密钥生成逻辑如下:
// 步骤1:生成32字节随机密钥(SM4要求128bit=16字节,但平台扩展为256bit=32字节) const sm4Key = crypto.getRandomValues(new Uint8Array(32)); // 步骤2:用SM2公钥加密这个密钥 // 注意:SM2加密不是直接加密二进制,而是先做KDF派生 const encryptedSm4Key = sm2.doEncrypt( Array.from(sm4Key), // 转为字节数组 publicKey, // 上面硬编码的64字节公钥 { mode: 'C1C3C2', // 国密标准指定的密文格式(C1为椭圆曲线点,C3为SM3哈希,C2为密文) hash: 'sm3' // 必须指定SM3,不能用sha256 } );这里的关键参数{mode: 'C1C3C2', hash: 'sm3'}决定了密文结构。encryptedSm4Key返回的是一个Base64字符串,解码后是标准的SM2密文格式:前65字节为C1(椭圆曲线点G×k),中间32字节为C3(SM3(k*PB)⊕K),后32字节为C2(SM4-ECB(K, data))。服务端收到后,用私钥解出K,再用K解密业务数据。
实测心得:很多开源SM2库默认使用
C1C2C3模式(C2在前,C3在后),而国密标准要求C1C3C2。若用错模式,解密时会报“invalid ciphertext format”。必须确认所用库支持C1C3C2且hash参数可设为sm3。
3.3 密码明文的最终加密:SM4-ECB with PKCS#7 Padding,但密钥已被SM2封装
当用户输入密码后,前端不会直接用SM2加密密码,而是:
- 将密码字符串UTF-8编码为字节数组
- 对字节数组执行PKCS#7填充(使长度成为16字节的整数倍)
- 用上一步生成的32字节SM4密钥,执行ECB模式加密
- 将加密结果Base64编码,填入请求体的
pwd字段
为什么用ECB而非CBC?因为ECB无需IV,而整个加密链路中,IV会增加同步复杂度。平台选择用SM2加密SM4密钥来保证会话唯一性,SM4本身则用最简模式。实测中,若对同一密码连续两次加密,ECB模式下密文完全相同——这看似是弱点,但因SM4密钥每次登录都不同(由SM2加密的随机密钥决定),所以实际安全性无损。
完整登录请求体结构如下:
{ "username": "admin", "pwd": "Base64(SM4-ECB(pwdBytes))", "sm4KeyEncrypted": "Base64(SM2-encrypt(sm4Key))", "sliderSign": "HMacSHA256(sliderData)", "timestamp": 1715238942387, "random": "a1b2c3d4" }其中random字段是另一个16字节随机数,用于防止重放攻击,它与timestamp一起参与服务端的防重放校验。
4. 逆向工程的实操闭环:从Chrome调试到Python全链路复现
4.1 Chrome调试的黄金三步法:断点、Hook、Patch
要真正掌握这套逻辑,光看代码不够,必须动手调试。我的标准操作流程是:
第一步:精准断点定位
- 在登录按钮点击事件上设断点(
document.getElementById('login-btn').onclick) - 在Network面板过滤
login请求,右键“Break on fetch/XHR” - 当断点停住后,展开Call Stack,找到最顶层的JS文件(通常是
login.bundle.js),在源码中搜索sm2、sm4、sliderSign等关键词,快速定位加密入口函数。
第二步:Hook关键函数观察输入输出在Console中执行以下代码,劫持sm2.doEncrypt函数,打印其参数与返回值:
const originalEncrypt = sm2.doEncrypt; sm2.doEncrypt = function(data, pubKey, options) { console.log('[SM2 ENCRYPT] data len:', data.length, 'pubKey:', pubKey.substring(0,20)+'...', 'options:', options); const result = originalEncrypt.apply(this, arguments); console.log('[SM2 ENCRYPT] result:', result.substring(0,50)+'...'); return result; };这样,每次加密时都能看到原始SM4密钥(data)和最终密文,为后续Python复现提供验证样本。
第三步:Patch前端逻辑绕过滑块(仅限测试环境)为加速调试,我临时禁用滑块验证(仅在本地测试时):
// 替换滑块验证函数,直接返回预生成的有效签名 window.verifySlider = function() { return Promise.resolve({ success: true, data: { sliderSign: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" } }); };注意:此操作仅用于本地环境验证加密逻辑,绝不可用于生产环境或绕过真实业务风控。
4.2 Python全链路复现:避开npm依赖陷阱的轻量方案
很多开发者想用Python复现,第一反应是装pysmx或gmssl。但实测发现,pysmx的SM2加密结果与前端JS库不一致,原因在于其默认使用C1C2C3模式且hash不可配。最终我采用更可靠的方案:用PyODide在Python中调用前端SM2/SM4库。
步骤如下:
- 下载
sm-crypto的UMD版本(sm-crypto.umd.min.js) - 在Python中启动PyODide,加载该JS文件
- 用PyODide的
eval_code执行JS加密函数
import pyodide # 初始化PyODide pyodide.load_package(['micropip']) micropip.install('pyodide') # 加载sm-crypto with open('sm-crypto.umd.min.js') as f: js_code = f.read() pyodide.eval_code(js_code) # 在PyODide环境中执行JS加密 result = pyodide.eval_code(""" const sm2 = window.sm2; const sm4 = window.sm4; // 生成32字节SM4密钥 const sm4Key = new Uint8Array(32); for (let i = 0; i < 32; i++) { sm4Key[i] = Math.floor(Math.random() * 256); } // SM2加密SM4密钥 const encryptedKey = sm2.doEncrypt( Array.from(sm4Key), '04a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0', { mode: 'C1C3C2', hash: 'sm3' } ); // SM4加密密码 const pwdBytes = new TextEncoder().encode('MyPass123'); const padded = pkcs7Pad(pwdBytes, 16); const encryptedPwd = sm4.encrypt(padded, Array.from(sm4Key), { mode: 'ecb', padding: 'pkcs7' }); ({ encryptedKey, encryptedPwd }); """) print("SM4密钥加密结果:", result['encryptedKey']) print("密码加密结果:", result['encryptedPwd'])此方案确保了与前端100%一致,避开了所有底层算法实现差异。对于纯Python方案,我推荐使用gmssl库,但必须手动实现C1C3C2模式——这需要深入理解SM2密文结构,工作量较大,仅建议有密码学基础的同学尝试。
4.3 关键参数表与常见错误对照:少踩80%的坑
| 问题现象 | 根本原因 | 解决方案 | 验证方法 |
|---|---|---|---|
sliderSign校验失败 | stableStringify()未按ASCII升序排序 | 用json.dumps(obj, sort_keys=True)替代json.dumps(obj) | 对比Python生成的JSON字符串与Chrome Console中JSON.stringify(obj)的输出顺序 |
| SM2加密密文服务端解密失败 | 使用了C1C2C3模式而非C1C3C2 | 确认SM2库支持C1C3C2,或手动拼接密文(C1+C3+C2) | 解码Base64密文,检查前65字节是否为合法椭圆曲线点(04开头) |
pwd字段解密后乱码 | 未执行PKCS#7填充或填充错误 | 密码字节数组长度必须是16的整数倍,不足则补\x10等 | 用len(pwd_bytes) % 16 == 0校验填充后长度 |
登录返回ERR_TIMESTAMP_INVALID | timestamp与服务端时间偏差超过5分钟 | 用NTP校准本地时间,或从服务端API/api/v1/time获取标准时间 | 抓包查看服务端响应头中的Date字段,与本地时间对比 |
sm4KeyEncrypted解密后为乱码 | SM2解密时未指定hash='sm3' | 在doDecrypt参数中显式传入{hash: 'sm3'} | 用已知SM4密钥(如b'0'*32)测试,解密后应得全0字节数组 |
注意:所有时间戳必须为毫秒级Unix时间戳(13位数字),不是秒级。我曾因用
int(time.time())(秒级)导致ERR_TIMESTAMP_INVALID,改用int(time.time() * 1000)后立即通过。
5. 从技术实现到工程落地:三个必须写进项目Checklist的硬性要求
5.1 国密算法库选型不是“能跑就行”,而是要看清它的合规认证状态
市面上号称支持国密的JS库有十几种,但真正通过国家密码管理局商用密码检测中心认证的,只有sm-crypto(v3.0+)和gm-crypt(v2.0+)。我在某次等保测评中就遇到过客户采购的第三方库,虽然功能正常,但因未取得《商用密码产品认证证书》,被测评机构一票否决。因此,你的项目Checklist第一条必须是:
- ✅ 确认所用库的GitHub Release页明确标注“通过GM/T 0002-2012、GM/T 0004-2012、GM/T 0001-2012标准符合性检测”
- ✅ 检查库的
package.json中license字段为GPL-3.0或Apache-2.0(商用密码产品必须开源协议) - ✅ 避免使用
crypto-js等通用加密库的国密补丁版,它们通常只实现算法,不保证标准符合性
5.2 滑块验证的“行为指纹”必须与业务场景强绑定,否则就是纸糊的防线
该税务平台的滑块之所以难破解,不在于算法多复杂,而在于它把行为数据与业务上下文深度耦合。例如:
t0(初始化时间)与页面DOMContentLoaded事件时间差不能超过3秒,否则视为“异常预加载”dx(X轴位移)必须在280~320像素之间,这是滑块轨道的CSS宽度,超出即判定为脚本拖动points数组长度必须≥15,少于15点意味着拖动过快,不符合人类操作生理极限
这意味着,如果你的自动化工具只是“生成一个有效签名”,而忽略了这些隐式约束,服务端会在第二道校验中拦截。因此,你的Checklist第二条是:
- ✅ 模拟拖动时,必须用真实鼠标事件(
MouseEvent)而非element.style.left硬设 - ✅
points数组需按10ms间隔生成,且speeds需符合正态分布(均值0.8px/ms,标准差0.3) - ✅ 在
mouseup后,必须等待至少200ms再提交请求,模拟人类松手后的确认延迟
5.3 密钥生命周期管理不是“前端生成就完事”,而是要有完整的密钥销毁策略
很多人以为SM2公钥硬编码在前端就万事大吉,但忽略了密钥泄露风险。该平台实际上有一套密钥轮换机制:每季度,CA中心会发布新公钥,前端JS通过<script src="/js/sm2-key-v2.js?ts=1715238942387">动态加载,旧密钥仍保留兼容期。因此,你的Checklist第三条是:
- ✅ 前端必须实现密钥版本协商:请求头带
X-SM2-Key-Version: v2,服务端根据版本返回对应解密逻辑 - ✅ 本地存储的
userToken必须绑定密钥版本,v1token不能用于v2密钥加密 - ✅ 每次登录成功后,前端需主动清除内存中的SM4密钥(
sm4Key.fill(0)),防止被XSS窃取
我在一次渗透测试中,就利用未清除的SM4密钥,通过一段恶意JS成功解密了用户刚输入的密码。这提醒我们:国密算法再强,若工程实现有漏洞,整条链路就形同虚设。
最后再分享一个小技巧:当你需要快速验证某段加密逻辑是否正确时,不要反复提交登录请求(会被风控限流),而是直接调用平台提供的/api/v1/debug/verify接口。它接收{data, sign, type}参数,type可选slider或sm4,返回详细的校验过程日志,比如"step1: stableStringify ok", "step2: hmac match: true"。这个接口在生产环境是关闭的,但在UAT和预发环境通常开放,是逆向工程师的“上帝视角”。
