
1. 项目概述从“能用”到“懂行”的国密算法爬虫实战在数据采集这个行当里遇到加密参数是家常便饭。几年前AES、DES、RSA这些国际通用算法是主流我们只需要备好相应的解密库对着接口文档或者逆向出来的JS代码“照方抓药”就行。但最近两年情况变了。尤其是在处理一些特定领域比如金融、政务、公共服务等平台的公开数据时SM系列国密算法的出现频率越来越高。很多开发者第一次碰到SM2、SM3、SM4这些名词时往往会有点懵——这玩意儿怎么解密Python的cryptography库里好像没有网上的代码片段跑不通怎么办这正是我写这个系列的原因。前两篇我们聊了SM4对称加密和SM3哈希算法在爬虫中的识别与应对算是解决了“有没有”和“怎么用”的问题。但爬虫对抗的本质是理解而不仅仅是调用。今天这篇我们深入到SM2非对称加密算法。它不像SM4那样直接给你密文让你解密也不像SM3那样只是做个摘要验证。SM2在爬虫场景中更多地扮演着“签名验签”和“密钥协商”的角色是构建安全通信通道的基石。你可能会在登录的sign参数、请求体的完整性校验甚至是WebSocket握手阶段遇到它。如果你只会用现成的库一旦对方的实现有“个性”比如用了特定的曲线参数、或者签名格式非标你就会卡住。所以这篇文章的目标是带你穿透库的封装理解SM2的核心原理和代码实现。我会从最基础的椭圆曲线数学讲起别怕用爬虫工程师能懂的方式然后手把手带你用Python从零实现关键步骤最后再回到爬虫实战分析几个真实案例中SM2的踪迹和破解思路。当你读完面对一个使用了国密SM2的接口你将不再只是搜索“Python SM2 decrypt”而是能冷静地分析它的作用模式并找到正确的切入点。2. SM2算法核心原理椭圆曲线上的“捉迷藏”要搞懂SM2必须先过椭圆曲线这一关。很多文章一上来就扔公式y² x³ ax b (mod p)确实劝退。我们换个角度理解你可以把椭圆曲线想象成一个在有限范围内由质数p定义的、布满离散点的神奇“台球桌”。2.1 椭圆曲线上的点运算规则在这个台球桌上只有两种基本操作“点加”和“点倍乘”它们构成了所有密码学操作的基础。点加 (Point Addition)假设桌上有两个球点P和点Q用一根特殊的杆子直线穿过它们这条线会在桌上找到第三个交点R‘。然后我们把R‘垂直“反射”到桌子的另一边得到最终的点R。我们就说P Q R。如果P和Q是同一个点呢那就用这个点的“切线”作为那根特殊的杆子。点倍乘 (Point Scalar Multiplication)这其实就是自己加自己多次。k * P表示把点P自己加自己加k次。比如3 * P P P P。这是SM2算法中最核心、计算量最大的操作。为什么这个“台球游戏”安全因为给定结果点R和其中一个起点P想反推出另一个起点Q或者倍数k是极其困难的。这就是“椭圆曲线离散对数问题”ECDLP是SM2安全性的数学根基。2.2 SM2的专属“台球桌”国际通用的椭圆曲线算法如ECDSA有很多张不同的“台球桌”曲线参数。SM2使用的是国家密码管理局定义的一张特定的“桌子”称为sm2p256v1。它的参数是公开的质数 p:FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFF(一个很大的质数)方程参数 a:FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF 00000000 FFFFFFFF FFFFFFFC方程参数 b:28E9FA9E 9D9F5E34 4D5A9E4B CF6509A7 F39789F5 15AB8F92 DDBCBD41 4D940E93基点 G: 这是一个固定的起始点坐标(Gx, Gy)也是长串的十六进制数。基点阶数 n:FFFFFFFE FFFFFFFF FFFFFFFF FFFFFFFF 7203DF6B 21C6052B 53BBF409 39D54123。这个n很重要它意味着n * G O无穷远点相当于出界了保证了运算在循环群内。注意在爬虫逆向中如果对方完全使用标准SM2这些参数通常是内置的。但如果遇到私有化部署或定制安全方案这些曲线参数可能会被修改这时你就不能直接用现成的sm2库了需要根据获取的新参数重新初始化曲线。这是第一个可能遇到的坑。2.3 SM2的三重身份加密、签名与密钥交换SM2是一套算法集包含了三个功能在爬虫里遇到的后两种居多数字签名算法 (SM2-2)这是最常见的。服务器用私钥对一段数据比如时间戳请求参数生成签名sign客户端或我们的爬虫用公钥验证这个签名确保数据未被篡改且来源可信。爬虫逆向时我们的目标往往是伪造签名这就需要拿到私钥或者破解签名过程。密钥交换协议 (SM2-3)用于双方协商出一个共同的会话密钥通常用于后续的SM4加密通信。在WebSocket或一些长连接通道建立时可能会用到。爬虫需要模拟客户端完成整个协商流程才能得到正确的加密密钥。公钥加密算法 (SM2-1)用公钥加密数据私钥解密。在爬虫中相对少见因为非对称加密速度慢通常只用于加密关键信息如加密SM4的密钥。理解这些原理我们才能明白在逆向JS时看到的那些sign、encode、exchangeKey函数到底在做什么而不是盲目地寻找解密函数。3. 从零实现SM2关键步骤理解胜过调包虽然生产环境强烈推荐使用成熟的gmssl或cryptography需支持国密库但自己动手实现核心步骤是加深理解、应对变种的不二法门。这里我们用Python标准库和ecdsa库仅借用其椭圆曲线基础框架来演示。3.1 搭建椭圆曲线基础框架首先我们需要定义SM2的曲线参数并实现点的基本运算。import hashlib import random from typing import Tuple, Optional # 定义 SM2 椭圆曲线参数 (sm2p256v1) P 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFF A 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFC B 0x28E9FA9E9D9F5E344D5A9E4BCF6509A7F39789F515AB8F92DDBCBD414D940E93 Gx 0x32C4AE2C1F1981195F9904466A39C9948FE30BBFF2660BE1715A4589334C74C7 Gy 0xBC3736A2F4F6779C59BDCEE36B692153D0A9877CC62A474002DF32E52139F0A0 N 0xFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFF7203DF6B21C6052B53BBF40939D54123 class ECPoint: 椭圆曲线点类 def __init__(self, x: int, y: int, is_infinity: bool False): self.x x self.y y self.is_infinity is_infinity # 是否为无穷远点 def __eq__(self, other): if self.is_infinity and other.is_infinity: return True return not (self.is_infinity or other.is_infinity) and self.x other.x and self.y other.y def __repr__(self): return fECPoint({hex(self.x)}, {hex(self.y)}) if not self.is_infinity else ECPoint(Infinity) # 定义基点 G G ECPoint(Gx, Gy) def point_add(P: ECPoint, Q: ECPoint) - ECPoint: 椭圆曲线点加运算 if P.is_infinity: return Q if Q.is_infinity: return P if P Q: return point_double(P) if P.x Q.x and P.y ! Q.y: # 互为逆元和为无穷远点 return ECPoint(0, 0, is_infinityTrue) # 计算斜率 lambda (Q.y - P.y) * inv(Q.x - P.x, P) mod P delta_x (Q.x - P.x) % P inv_delta_x pow(delta_x, -1, P) # Python 3.8 支持模逆运算 lam ((Q.y - P.y) * inv_delta_x) % P # 计算结果点 R 的坐标 x_r (lam * lam - P.x - Q.x) % P y_r (lam * (P.x - x_r) - P.y) % P return ECPoint(x_r, y_r) def point_double(P: ECPoint) - ECPoint: 椭圆曲线点倍运算 (P P) if P.is_infinity: return P # 斜率 lambda (3 * P.x² A) * inv(2 * P.y) mod P numerator (3 * P.x * P.x A) % P denominator (2 * P.y) % P inv_denominator pow(denominator, -1, P) lam (numerator * inv_denominator) % P x_r (lam * lam - 2 * P.x) % P y_r (lam * (P.x - x_r) - P.y) % P return ECPoint(x_r, y_r) def scalar_mult(k: int, point: ECPoint) - ECPoint: 标量乘法计算 k * point使用倍加算法 result ECPoint(0, 0, is_infinityTrue) # 初始化为无穷远点 addend point while k 0: if k 1: # 如果当前二进制位为1 result point_add(result, addend) addend point_double(addend) # 无论当前位如何加数都需要翻倍 k 1 # 右移一位 return result实操心得自己实现点运算主要是为了教学理解。在实际爬虫工程中千万不要在关键路径上使用自己写的这段代码因为它的性能和安全性与优化过的C语言库如gmssl底层调用的相差巨大。但这段代码能帮你调试当你怀疑某个JS实现的SM2用了非标参数时可以用这个框架快速验证点的运算结果是否正确。3.2 实现SM2签名与验签签名和验签是爬虫中最常需要模拟的操作。SM2的签名过程需要用到SM3哈希算法。def sm3_hash(msg: bytes) - bytes: 简易SM3哈希实现生产环境请使用gmssl库的sm3 # 此处为演示简化实现。实际国密SM3算法较复杂。 # 强烈建议在爬虫项目中直接使用 gmssl.sm3 或逆向目标JS中的SM3函数。 # 这里用SHA-256模拟一个哈希输出仅为了流程演示。 return hashlib.sha256(msg).digest() def sm2_sign(private_key: int, message: bytes, k: Optional[int] None) - Tuple[int, int]: SM2 签名生成 :param private_key: 私钥 (一个大整数) :param message: 待签名的消息 :param k: 随机数k如果为None则内部生成。**安全警告在实际中k必须密码学安全随机**。 :return: 签名 (r, s) # 1. 计算 e H(Z || M) Z为用户标识与公钥的哈希此处简化假设Z为空或固定值。 # 在实际标准中Z的计算很关键。爬虫逆向时必须完全照搬目标代码的Z计算方式。 e_bytes sm3_hash(message) e int.from_bytes(e_bytes, big) # 2. 生成随机数 k if k is None: k random.randrange(1, N) # 3. 计算椭圆曲线点 (x1, y1) k * G point_kG scalar_mult(k, G) r (e point_kG.x) % N if r 0 or r k N: raise ValueError(Invalid r, need to regenerate k) # 4. 计算 s ((1 d_A)⁻¹ * (k - r * d_A)) mod N d_A private_key s (pow(1 d_A, -1, N) * (k - r * d_A)) % N if s 0: raise ValueError(Invalid s, need to regenerate k) return r, s def sm2_verify(public_key_point: ECPoint, message: bytes, signature: Tuple[int, int]) - bool: SM2 签名验证 :param public_key_point: 公钥点 :param message: 原始消息 :param signature: 签名 (r, s) :return: 验证是否通过 r, s signature if not (1 r N and 1 s N): return False # 1. 计算 e H(Z || M) 与签名时一致 e_bytes sm3_hash(message) e int.from_bytes(e_bytes, big) # 2. 计算 t (r s) mod N t (r s) % N if t 0: return False # 3. 计算椭圆曲线点 (x1, y1) s * G t * P_A point_sG scalar_mult(s, G) point_tP scalar_mult(t, public_key_point) point_sum point_add(point_sG, point_tP) # 4. 验证 R (e x1) mod N R (e point_sum.x) % N return R r3.3 关键细节与爬虫逆向的关联用户标识Z在标准SM2签名中哈希e的计算是e H(Z || M)其中Z是用户标识符和公钥的SM3哈希值。很多简化实现或某些平台的JS代码可能会省略Z或者使用固定值。爬虫逆向时必须通过调试确认对方计算e时是否包含了Z以及Z的具体计算方式。这一步不一致会导致你本地生成的签名永远无法通过验证。随机数k签名中的k必须是密码学安全的随机数。但在爬虫逆向中如果发现对方的JS代码里k是固定的或者可预测的那这就是一个巨大的安全漏洞也是我们的突破口。你可以直接复用这个k来伪造签名。签名格式生成的(r, s)是两个大整数。在网络传输时通常会被转换为固定长度的十六进制字符串有时是r||s拼接有时是ASN.1 DER编码。你需要通过抓包分析签名值的格式并在你的Python代码中以同样格式输出。注意事项自己实现SM2签名仅用于学习和调试目的。在真正的爬虫项目中一旦逆向出对方的签名逻辑最佳实践是用Python调用其原始的JavaScript代码通过execjs、PyMiniRacer等库来生成签名这是最稳妥、兼容性最好的方式。只有在JS代码混淆极深或性能要求极高时才考虑用Python复现。4. 爬虫实战逆向分析与案例拆解理论说再多不如看实战。我们假设一个常见的爬虫场景某数据平台其查询API的请求头中需要一个X-Signature字段算法是SM2。4.1 第一步抓包与初步分析使用Fiddler/Charles/Burp Suite抓包发现一个典型的请求POST /api/v1/data/query HTTP/1.1 Host: target-site.com X-Timestamp: 1646389275123 X-Signature: 2b9e...一长串16进制字符 Content-Type: application/json {page: 1, size: 20, keyword: test}初步判断签名X-Signature很可能由X-Timestamp和请求体或其中部分字段共同生成。4.2 第二步前端代码定位在浏览器开发者工具的Sources或Network面板中搜索关键词sm2、sign、X-Signature、国密等。很可能找到一个被Webpack打包的chunk-vendors.js或类似文件里面包含加密库。找到类似如下结构的代码已做简化反混淆// 1. 引入SM2库可能是自定义的也可能是来自某个npm包如 sm-crypto const sm2 require(sm-crypto).sm2; // 2. 定义公钥通常硬编码在代码里或从接口获取 const publicKey 04...; // 04开头表示非压缩公钥 let privateKey null; // 私钥一般不会出现在前端这里应该只是签名私钥在后端 // 3. 签名函数 function generateSignature(timestamp, requestBody) { // 3.1 组装待签名字符串注意顺序和分隔符这是关键。 const messageToSign timestamp${timestamp}body${JSON.stringify(requestBody)}; // 3.2 计算 Z 观察这里是否有多一步对公钥和ID的哈希计算 // const userId 1234567890; // const z sm3( userId publicKey ... ); // 如果看到这步Z的计算必须还原 // const e sm3(z messageToSign); // 3.3 实际签名调用 // 使用 sm-crypto 的签名示例 const sig sm2.doSignature(messageToSign, privateKey, {hash: true}); // 注意这里私钥是空的可能只是函数占位 // 或者可能是另一种调用方式 // const sig sm2.sign(messageToSign, privateKey, {mode: sm2, hash: sm3}); return sig; }排查技巧这里有一个极其重要的发现代码中sm2.doSignature的privateKey参数看起来是null或空值。这不符合常理。有两种可能1. 私钥通过其他方式注入如全局变量2.这根本不是标准的私钥签名而是使用了一种叫做“SM2withSM3”的、并且可能使用了“用户ID”和“公钥”推导出的“签名”实际上是一种验证结构。这时你需要仔细看sm2.doSignature的具体实现或者搜索doSignature的调用上下文看privateKey是否被赋值。4.3 第三步深入调试与逻辑还原在生成签名的代码行打上断点重新触发请求。观察输入generateSignature函数的timestamp和requestBody参数具体是什么值字符串拼接的格式是否和你想的一致输出sig变量的值是什么是65字节的16进制字符串04开头吗还是48字节r和s拼接关键单步进入sm2.doSignature函数如果可能。或者在Console中尝试直接调用sm2.doSignature传入你构造的参数看能否复现签名。一个常见陷阱你可能会发现前端根本没有私钥签名是由后端下发的或者使用的是SM2的“无私钥签名”变种实际上是一种基于预共享秘密的MAC。这时你的策略就需要从“复现签名”转变为“窃取或重用签名”。策略A重用签名如果签名只和timestamp相关且timestamp在短时间内有效你可以快速重放同一个签名。策略B破解签名生成逻辑如果发现签名算法其实不涉及非对称加密而是用了一个固定的密钥进行SM3-HMAC那你只需要逆向出这个密钥即可。4.4 第四步Python侧复现假设我们通过调试确认了对方使用的是标准SM2签名且私钥以某种形式存在于前端代码中虽然不安全但确实有公司这么做。我们成功提取到了私钥private_key_hex和完整的消息组装方式。import json from gmssl import sm2, sm3, func # 生产环境使用 gmssl def generate_signature_for_crawler(timestamp: int, request_body: dict) - str: 模拟前端生成SM2签名 # 1. 还原消息组装逻辑 (必须与JS端完全一致) # 注意JSON序列化的空格、键排序都可能影响最终字符串 # 使用 separators 参数确保紧凑格式避免JS的 JSON.stringify 可能产生的差异 body_str json.dumps(request_body, separators(,, :), ensure_asciiFalse) message_to_sign ftimestamp{timestamp}body{body_str} # 2. 加载私钥 (从逆向中获取) private_key_hex 你的私钥Hex字符串不带04等前缀 sm2_crypt sm2.CryptSM2(private_keyprivate_key_hex, public_key) # 签名时公钥可空 # 3. 生成签名 # gmssl 的 sign 方法默认使用 SM3 哈希并返回 bytes random_hex_str func.random_hex(sm2_crypt.para_len) # 生成随机数k signature_bytes sm2_crypt.sign(message_to_sign.encode(utf-8), random_hex_str) # 4. 转换为前端使用的格式 (可能是16进制字符串) signature_hex signature_bytes.hex() # 或者可能是 Base64 # import base64 # signature_b64 base64.b64encode(signature_bytes).decode() return signature_hex # 使用示例 timestamp 1646389275123 request_body {page: 1, size: 20, keyword: test} sig generate_signature_for_crawler(timestamp, request_body) print(f生成的签名: {sig}) # 将 sig 填入 X-Signature 请求头实操心得消息序列化是签名失败的重灾区。JavaScript的JSON.stringify和Python的json.dumps默认输出可能不同如空格、中文编码。务必使用json.dumps(..., separators(,, :))来生成最紧凑格式并与JS调试器中看到的实际待签名字符串进行逐字节比较可以使用repr()函数查看原始字符串。5. 常见问题排查与进阶技巧即使按照步骤操作你可能还是会遇到签名无效的问题。以下是排查清单5.1 签名验证失败排查表问题现象可能原因排查方法服务器返回“签名无效”1. 待签消息拼接错误2. 哈希算法不一致未用SM33. 用户标识Z计算错误或遗漏4. 随机数k生成逻辑不同1. 在JS调试器中将待签消息字符串打印出来与Python生成的进行严格比对。2. 确认JS中调用的是sm3哈希。3. 检查JS代码中是否有计算Z的步骤并完整复现。4. 尝试在Python中使用固定k从JS调试中捕获进行签名。服务器返回“签名格式错误”1. 签名(r, s)编码格式不符2. 输出编码Hex/B64错误1. 分析抓包得到的签名值长度和格式。是r本地签名每次不同但服务器都接受使用了错误的公钥进行“验证”可能服务器只是解析了签名中的R值用于其他目的如密钥协商。重新审视该接口的协议文档或JS代码确认这个X-Signature字段的真实用途。它可能根本不是用于验证的签名而是密钥交换的一部分。无法在前端找到私钥1. 私钥被混淆或加密存储。2. 根本不存在私钥使用的是其他机制。1. 搜索所有网络请求看是否有接口返回加密的私钥或密钥材料。2. 关注WebSocket握手、登录token生成等初始化流程私钥可能在这些环节动态生成或交换。5.2 进阶技巧处理代码混淆与动态加载Hook关键函数如果代码混淆严重无法直接阅读。可以使用浏览器的Console对关键对象进行Hook。例如在页面加载前注入代码// 保存原函数 const originalDoSignature window.sm2.doSignature; // 替换为新函数用于打印参数 window.sm2.doSignature function(message, privateKey, options) { console.log([SM2 Hook] Message:, message); console.log([SM2 Hook] PrivateKey (first 10 chars):, privateKey ? privateKey.substring(0,10) ... : null); console.trace(); // 打印调用栈 // 调用原函数并返回结果 return originalDoSignature.call(this, message, privateKey, options); };追查动态加载的密钥私钥可能通过Axios拦截器、Webpack模块的__webpack_require__动态注入。在Network面板中搜索包含key、private、cipher等关键词的响应体。或者在localStorage、sessionStorage、IndexedDB中查找。降级攻击如果目标网站同时提供了非国密的API如旧的RSA签名接口可以尝试寻找并调用这些旧接口可能绕过复杂的SM2逆向。5.3 当一切都不奏效时模拟执行与环境复制终极方案是放弃纯Python复现采用“以彼之矛攻彼之盾”的策略使用execjs将关键的、混淆过的JavaScript签名函数代码片段提取出来用execjs在Python中创建一个JavaScript环境来执行。import execjs with open(signature.js, r, encodingutf-8) as f: js_code f.read() ctx execjs.compile(js_code) signature ctx.call(generateSignature, timestamp, request_body)使用PyMiniRacer这是一个基于V8引擎的Python库执行效率和兼容性比execjs更好适合更复杂的JS环境。无头浏览器自动化如果签名过程依赖完整的浏览器环境、DOM状态或其他难以剥离的库可以考虑使用Playwright或Selenium无头浏览器直接让浏览器执行整个页面逻辑然后从内存中截取请求。这种方法最笨重但兼容性最强。国密SM2在爬虫中的应用标志着数据接口安全防护水平的提升。它不再是一个简单的“加密-解密”黑盒而是一个需要你理解其协议流程、数学原理的完整安全体系。面对它最好的武器不是万能的解密脚本而是扎实的密码学基础、耐心的逆向调试能力和灵活多变的工程化思路。从理解椭圆曲线开始到亲手实现点运算再到逆向分析前端代码最后选择合适的方案进行复现或模拟——这条路径能帮你攻克绝大多数基于SM2的签名验证关卡。记住爬虫与反爬的对抗本质上是知识深度的对抗。当你对SM2的了解接近甚至超过它的设计者时那些加密的参数在你眼中便不再是屏障而是一行行清晰的逻辑代码。