爬虫JS逆向:RSA登录参数加密定位与Python复现 第一次看到某鸟类观测记录平台的登录请求里蹦出那串 344 个字符的 Base64 密文时我下意识去搜了一下明文里有没有自己的账号结果当然是一个都搜不到——整段密码被 RSA 加密算法换成了一个固定长度的字节块。这类场景在爬虫工作里出现的频率远比想象中高只要站点前端用了常见的 JS 加密库做登录保护你抓到的数据包里就永远只有一堆字母数字。这篇内容想讲清楚的就是三件事RSA 加密在爬虫场景里到底是什么样的存在、怎么从压缩混淆的 JS 里把加密入口揪出来、以及怎么用 Python 把加密过程一比一复现出来。下文会以某鸟类观测记录平台下称模拟平台作为完整案例从抓包判定一直走到可稳定运行的请求封装。适合已经能写基础 requests 请求、但一碰到登录参数被加密就卡住的读者也适合想系统梳理前端加密还原思路的同学。1. 先判定加密类型别一上来就啃 JS1.1 从密文的形状读出三条线索很多人在抓包工具里看到一串乱码参数第一反应是打开 Sources 面板开始翻 JS翻了半小时一无所获。我的习惯是先在请求面板里把密文的形状读一遍这一步能省掉大量无效劳动。密文长度、字符集、是否含/、两次请求结果是否一致这四个观察点基本能把加密算法的范围缩小到两三种。具体到模拟平台登录接口POST /api/v1/user/login的 body 里有个字段叫password值长 344 个字符由大小写字母、数字以及/组成没有中文没有空格。344 这个数字非常关键——它是 Base64 编码 256 字节后的长度ceil(256/3)*4 344而 256 字节正好是 2048 位 RSA 的输出长度。如果长度是 172那对应 128 字节也就是 1024 位的 RSA。如果长度是 24 或 44那更像 AES 或 DES 的输出。观察项观察结果可推断结论密文长度344 字符Base64 后 256 字节疑似 RSA-2048字符集A-Za-z0-9/标准 Base64无 URL 安全变体两次加密同一密码结果不同带随机填充排除纯哈希与 ECB 固定输出明文长度变化密文长度不变输出长度只跟密钥长度有关符合 RSA 特征第三行那个两次结果不同我特别想强调。判断一个参数是不是 RSA最省事的办法就是同一个明文连发两次看密文变不变。如果两次一模一样那它大概率是 MD5/SHA 这类哈希或者 AES-ECB 加固定 IV跟 RSA 没关系。如果两次不一样但长度固定就非常像 RSA 的 PKCS#1 v1.5 填充——这种填充会在每个块里塞随机字节所以同样内容每次加密出来都不同但总长度永远等于密钥模长。1.2 把加密边界圈出来再动手确认了是 RSA 之后下一步不是拆算法而是圈边界到底哪几个字段被加密了、加密顺序是什么、有没有字段参与了加密但不叫password。模拟平台这一版是password和timestamp两个字段都进加密函数前端把password | timestamp拼成一个字符串整体加密服务端解密后按分隔符切开校验时间窗。如果不看 JS 直接猜只加密密码那你复现出来的密文服务端解出来是残缺的永远返回参数错误。圈边界的方法很土但有效在登录页反复提交把每次请求里所有参数列出来对照哪些是用户可控、哪些是自动生成。用户可控的账号、密码、备注通常是加密候选自动生成的时间戳、随机串、设备标识则要考虑它们是否参与了加密或签名。做完这张表你手上就有了一个待验证清单后面断点调试时可以直接对着清单逐个确认而不是漫无目的地读代码。2. RSA 在前端登录里的真实角色2.1 公钥在前端这才是爬虫能还原的根本原因RSA 是非对称加密一把公钥负责加密、一把私钥负责解密。前端做登录保护时的标准做法是服务端生成密钥对私钥留在服务端公钥下发给前端。浏览器用公钥把密码加密成密文提交服务端用私钥解开拿到明文再去做哈希比对。这套设计对浏览器是单向的对爬虫却几乎是透明的——因为公钥就明明白白躺在 JS 文件里谁都能拿到。这一点必须先想明白否则容易把 RSA 当成不可破解的防护而放弃。事实上公钥加密本来就是公开可用的它的安全性建立在私钥不外泄上而不是算法保密。爬虫要做的不是破解 RSA而是扮演浏览器的角色用同一把公钥、同样的填充方式、同样的明文拼装规则去生成密文。难度不来自密码学而来自工程细节你得先找到公钥、找到明文拼装逻辑、找到填充参数。反过来说如果某天你发现站点把加密逻辑搬到了服务端、或者给公钥加上了动态下发机制每次会话换一把公钥并绑定会话那才是真正难缠的情况。模拟平台目前还是静态公钥硬编码在 JS 里属于工作量最小的一类。2.2 填充模式比密钥长度更容易翻车新手最常犯的错误是盯着密钥长度折腾却忽略了填充模式。同样一把公钥、同样的明文用 PKCS#1 v1.5 和 OAEP 加密出来的密文服务端只会认其中一种。用错了你拿到的是一串格式完全合法、长度完全正确的密文服务端返回的却是用户名或密码错误——这个报错极具误导性会让你以为是账号密码问题或者时间戳问题实际上就是填充对不上。前端最常用的加密库默认使用 PKCS#1 v1.5这是历史原因造成的绝大多数站点的登录加密走的就是这条路。OAEP 相对少见一般出现在较新的实现或者对安全性要求更高的系统里。判断方法很简单PKCS#1 v1.5 填充的密文用私钥解密时不需要额外参数而 OAEP 必须指定哈希算法常见是 SHA-1也有用 SHA-256 的。如果你在 JS 里看到RSA_PKCS1_PADDING或者干脆没有任何 padding 相关字样基本就是 v1.5。还有一个绕不开的硬限制PKCS#1 v1.5 下单块能加密的明文最大字节数等于密钥字节数减 11。2048 位密钥是 256 字节减去 11 就是 245 字节明文上限1024 位密钥是 128 字节上限 117 字节。中文按 UTF-8 算一个汉字 3 字节所以 1024 位密钥下超过 39 个汉字就会超限。前端那个常用加密库遇到超长明文时不会自动分块而是直接返回false页面上的表现是点登录没反应控制台里可能连报错都没有。这算是这类库一个相当坑人的设计也是你自己写复现代码时必须提前考虑的边界条件。3. 定位加密入口从压缩代码里把函数揪出来3.1 用公钥前缀做全局搜索比搜 encrypt 有效十倍绝大多数教程会告诉你搜encrypt实测下来这个关键词在混淆代码里命中率高得离谱一搜出来几十个结果全是干扰项。我自己的首选是搜公钥的 Base64 特征前缀。RSA 公钥的 DER 编码转成 Base64 后有几个非常稳定的开头MIGf1024 位、MIIB2048 位短格式、MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A2048 位带 X.509 头部、MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ1024 位带 X.509 头部。这些字符串在代码里是硬编码的搜中就是一击必杀。在开发者工具的全局搜索Windows 下是CtrlShiftF里输入MIIB模拟平台这次定位到两个文件一个是我们自己的业务 JS另一个是被引入的加密库。点进业务 JS公钥就在var PUBLIC_KEY ...这一行往下一屏就是加密调用。整个过程不到两分钟。搜完公钥之后顺手再搜一下10001这是 RSA 公钥指数 e65537 的十六进制写法在手工实现的密钥构造函数里经常出现可以作为交叉验证。3.2 XHR 断点和调用栈回溯如果搜索没命中说明公钥可能是拼出来的或者是动态获取的那就得靠断点。做法是在 Sources 面板里给XHR/fetch Breakpoints加一条 URL 关键词比如login然后正常提交一次表单代码会在请求发出的那一刻停下来。此时右侧调用栈里最上面几层就是发起请求的函数一层层往下点很快能看到一个形如function s(t) { ... n.encrypt(...) }的包装函数。调用栈回溯的关键是看参数从哪来。停在断点时把t的值打印出来确认它是明文密码还是已经被处理过的字符串。如果打印出来的已经是密文说明加密发生在更早的层级得往上继续找。我一般会在调用栈里找第一个出现用户输入的地方那里就是加密的起点。模拟平台的调用栈是这样的submitLogin→buildPayload→encryptFields→ 库内部方法加密发生在encryptFields里参数就是拼好的password|timestamp。混淆代码读起来费劲有个小技巧在格式化按钮点一下把压缩代码展开然后利用编辑器的折叠功能从encrypt附近的函数体开始看。另外本地替换Local Overrides非常好用——你可以在本地把疑似加密函数改成console.log打印参数后再走原逻辑刷新页面就能看到真实输入而且改坏了刷新一下就能恢复比反复断点省事。4. 把公钥搬进 Python 的三种路径4.1 路径一纯 Python 重写最稳也最值得做只要填充模式和明文拼装规则搞清楚了纯 Python 重写是首选方案。它不依赖浏览器环境运行速度快出问题时能直接打断点看中间变量。下面这段是模拟平台的实际复现代码用的是 PyCryptodomeimport base64 from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 PUB_PEM -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...(省略)... -----END PUBLIC KEY----- key RSA.import_key(PUB_PEM) def encrypt_password(password: str, ts: str) - str: raw f{password}|{ts}.encode(utf-8) cipher PKCS1_v1_5.new(key) # 每次新建不要复用 return base64.b64encode(cipher.encrypt(raw)).decode()这段代码有两个容易踩的点我专门说明一下。第一PKCS1_v1_5.new(key)必须每次新建因为它的内部状态会随着加密过程变化复用一个实例连续加密有时会出问题。第二RSA.import_key对 PEM 格式要求严格头尾的五个横杠不能少中间的 Base64 每行一般 64 字符缺了换行某些版本会解析失败。如果偏好cryptography库等价写法是这样的import base64 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding pub serialization.load_pem_public_key(PUB_PEM.encode()) ct pub.encrypt(bpassword|1700000000000, padding.PKCS1v15()) print(base64.b64encode(ct).decode())4.2 公钥的三种形态与它们之间的转换实际遇到的公钥不止 PEM 这一种形态常见的有三类转换方法各不相同。第一类是完整 PEM直接import_key就行。第二类是裸 Base64也就是去掉了-----BEGIN PUBLIC KEY-----头尾的那一长串这种需要自己补头尾和换行。补的时候注意每 64 字符插一个\n末尾也要有换行否则部分解析器会报错。第三类是modulusexponent形式通常以十六进制字符串给出需要按大整数处理from Crypto.PublicKey import RSA n int(b1e5...省略, 16) # 模数 e 65537 # 公钥指数绝大多数情况 key RSA.construct((n, e))判断属于哪一类的办法是看长度和前缀。2048 位完整 PEM 的公钥部分一般是 392 个字符左右裸 Base64 就是 392 减去每 64 字符插入的换行而十六进制的模数是 512 个字符2048 位 ÷ 4一眼就能区分。有些站点的公钥会做简单的字符替换比如把换成-、/换成_这就是 URL 安全变体导入前要换回去。4.3 路径二和路径三直接执行 JS作为兜底手段纯 Python 重写搞不定的时候比如加密逻辑里混了大量自定义位运算重写成本太高可以直接执行页面 JS。做法是把加密相关的代码整段抠出来——包括公钥常量、加密函数、以及它依赖的工具函数——存成一个独立的.js文件然后在命令行里用node跑。调用方式可以是从 Python 侧起子进程读标准输出。这条路的好处是绝不失真坏处同样明显JS 代码往往依赖window、navigator、document这些浏览器对象抠出来经常报 undefined得一层层补环境。另一个问题是维护成本高站点更新一次 JS 你就得重新抠一遍。还有一条中间路线是用cryptography或 PyCryptodome 解释器把 JS 里的关键参数读出来再在 Python 里组装。这条路适合公钥是动态获取但填充是标准 v1.5的场景。三条路的取舍可以概括成下表路径适用场景优点代价纯 Python 重写填充标准、明文拼装简单快、稳、易调试、易维护需要理解 JS 逻辑直接执行 JS逻辑重度混淆、自定义算法与前端完全一致环境难补、易随版本失效混合取参数 Python 组装公钥动态、填充标准兼顾稳定与灵活需要额外解析逻辑5. 模拟平台完整链路走一遍5.1 从会话建立到登录成功的前置动作模拟平台的登录不是一步到位的前置还有两步。第一步是GET /api/v1/common/pubkey返回一个 JSON里面是压成一行的裸 Base64 公钥和一个keyId。第二步是带着keyId去登录服务端靠这个 id 找到对应的私钥。也就是说公钥是动态下发的但填充方式固定是 PKCS#1 v1.5属于上面表格里的第二种场景。我先把整个流程拆成四个动作拿公钥、拼明文、加密、发请求。拼明文这一步要特别注意顺序模拟平台是password|timestamp分隔符是竖线时间戳是毫秒级字符串没有前导零。这三点少一样服务端解出来就切不开。发请求时 Content-Type 是application/json但密文字段本身还要再做一次 URL 编码吗实测不需要JSON 里的 Base64 可以直接放和/在 JSON 字符串里是安全字符。这一点跟表单提交不同——如果是application/x-www-form-urlencodedBase64 里的会被解析成空格必须做 URL 编码。会话保持同样关键。模拟平台在第一次访问时会下发一个匿名会话 Cookie登录接口会校验这个 Cookie 和keyId是否匹配。如果直接用requests.post裸调而不带会话服务端会返回公钥不存在。正确做法是全程用同一个requests.Session()对象让它自动管理 Cookieimport time, base64, requests from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 s requests.Session() s.headers.update({ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..., Accept: application/json, Referer: https://example.invalid/login, }) pk s.get(BASE /api/v1/common/pubkey, timeout10).json() der_b64 pk[publicKey].replace(-, ).replace(_, /) pem (-----BEGIN PUBLIC KEY-----\n \n.join(der_b64[i:i64] for i in range(0, len(der_b64), 64)) \n-----END PUBLIC KEY-----) key RSA.import_key(pem) ts str(int(time.time() * 1000)) raw f{password}|{ts}.encode(utf-8) cipher_text base64.b64encode(PKCS1_v1_5.new(key).encrypt(raw)).decode() payload {account: account, password: cipher_text, timestamp: ts, keyId: pk[keyId]} resp s.post(BASE /api/v1/user/login, jsonpayload, timeout10)5.2 联调阶段的验证手段第一次跑通的那一刻我是有点怀疑的——万一服务端只是没校验密文呢验证的办法是故意把cipher_text改一位字符再发一次如果服务端返回解密失败或者参数错误说明它确实在做解密校验如果照样登录成功那说明这个字段根本没被校验你可以省掉整个加密环节。这个故意改坏的测试我建议每个站点都做一次它能帮你确认自己复现的加密是不是真的起作用。第二步验证是拿浏览器里的密文和 Python 算出来的密文比对。因为 PKCS#1 v1.5 填充带随机性两次加密的密文肯定不同没法直接比字符串。可行的办法是把两份密文分别交给前端解密函数如果页面里有的话或者直接提交给服务端看结果是否一致。更彻底的做法是在断点里把前端加密前的明文打出来跟 Python 拼出来的明文逐字节比对——这一步能抓出分隔符、编码、时间精度之类的隐蔽差异。5.3 长字段与中文的处理边界模拟平台这套流程登录只加密密码所以不会超限。但如果某天站点改成整个表单 JSON 加密就会撞上 245 字节的墙。前端的处理方式通常有两种一是直接分块每块 245 字节2048 位或 117 字节1024 位单独加密后拼接再整体 Base64二是改用RSA 加密一个随机 AES 密钥AES 加密正文的混合方案。这两种在 Python 侧的复现方式完全不同判断依据是密文长度——分块密文的长度是密钥字节数的整数倍而混合方案的密文通常只有一两百字符。分块代码写起来不长但必须和服务端约定完全一致包括块顺序、是否每块独立 Base64def encrypt_chunked(text: str, key) - str: data text.encode(utf-8) chunk key.size_in_bytes() - 11 out b for i in range(0, len(data), chunk): out PKCS1_v1_5.new(key).encrypt(data[i:i chunk]) return base64.b64encode(out).decode()中文的超限问题更隐蔽。一个 60 字的中文密码UTF-8 编码后是 180 字节2048 位密钥能装下1024 位就不行了。而前端库遇到超长时返回false页面表现是点了没反应。如果你在浏览器里能登录成功、Python 侧却一直报错先数一下明文的字节数很可能就是踩到了这条线。6. 密文看着对却不通的几类典型原因6.1 时间戳和随机串加密里混了变量最让人抓狂的情况是加密逻辑完全正确、公钥填充都没问题服务端还是拒绝。这类问题九成出在加密明文里混了动态变量。模拟平台把timestamp拼进明文的同时也在 body 里单独传了一份timestamp服务端解密后要把两份对比差值超过 5 分钟就判定请求过期。如果你拼的明文里用了本地时间而本机时钟快了 10 分钟那就永远过不了校验。还有更隐蔽的参与加密的随机串是页面加载时生成的每次刷新都会变并且和服务端下发的会话绑定。这种只能从公钥接口的返回里一并取出来不能自己造。判断方法是把前端加密前的完整明文抓出来跟自己的拼装结果逐字符比较差异一眼就出来了。6.2 编码链从 UTF-8 到 Base64 再到 URL 编码编码问题在中文场景下特别常见。整个链路是明文字符串→ UTF-8 编码字节串→ RSA 加密字节串→ Base64 编码字符串→ 放进请求体。任何一步处理错最终结果都不同。常见的坑有用str.encode(gbk)而不是utf-8Base64 之后又手工做了一次urllib.parse.quote导致服务端拿到的不是原始 Base64或者反过来在表单提交时没做 URL 编码被吃成空格。我处理这类问题的固定套路是每一步都print出来跟浏览器断点里对应步骤的值对比。编码问题最怕感觉是对的必须实测。6.3 风控层加密过了请求频率过不了加密只是第一道门。模拟平台在登录成功之后查询观测记录列表的接口还有频率限制同一会话 60 秒内超过 20 次请求会返回一个特殊的错误码继续请求会被短时间限制。这种限制跟 RSA 没关系但会让很多人误以为是加密参数有问题因为错误提示往往很模糊。我的应对方式是保守的请求节奏单会话 QPS 控制在 1 以内需要更快就增加间隔而非并发把每次响应里的状态码和错误信息记录下来出现异常立刻降速。另外要注意某些站点会校验请求头顺序、Accept-Language、sec-ch-ua这类浏览器指纹字段用requests默认头很容易被识别。我一般会照抄浏览器抓包里的关键头把明显异常的比如Accept-Encoding里的br而本地库不支持解压去掉。7. 这一类站点的实操取舍7.1 数据获取的边界与自我约束鸟类观测记录这类数据本质上是大量爱好者长期积累的成果。我在做模拟平台的练习时给自己定了三条线只请求公开可见的列表页和详情页不碰任何用户个人信息字段严格遵守站点的使用声明与数据授权说明页面明确不允许的接口一律不调控制单次任务的总请求量把压力分散到较长的时间窗口里不影响正常用户的访问体验。这三条听起来像是给自己找麻烦但实际做下来反而是省事的范围划清楚了就不用在这个字段能不能抓上反复纠结节奏定好了就不会因为频繁被限速而反复改代码。我见过太多人把精力花在绕过限制上最后代码越写越复杂一个版本更新全部推倒重来。控制好节奏、只用公开数据反而是最稳的长期方案。7.2 加密逻辑变更的预警思路静态公钥会变动态公钥的格式也会变填充方式偶尔也会被升级。我给自己的爬虫留了一个自检入口每次运行前先请求一次公钥接口检查返回结构里是否还有预期的字段、公钥长度是否还在正常范围、登录接口的报错文案是否变化。任何一项不符就抛异常停止而不是让整个任务带着错误的加密参数一直跑下去。另外建议把加密实现和业务请求分层。加密部分单独放一个模块对外只暴露encrypt(text) - str这样一个函数业务代码完全不感知内部是 RSA 还是别的什么。这样当站点从 RSA 换成 AES 或者加了一层签名时你只需要改那一个文件。我自己在模拟平台上就吃过没分层的亏第一版把加密代码和登录逻辑混在一起后来公钥改成动态下发改了整整一下午。最后分享一个我经常用的小技巧把浏览器断点里抓到的前端加密前的明文和前端加密后的密文成对存下来作为自己复现代码的回归测试用例。密文因为带随机填充没法直接比对但可以反向验证——用私钥解一下自己算的密文确认解出来的明文和存下来的那条完全一致。有了这组样本以后不管是换库还是改代码跑一遍就知道有没有改坏比任何调试都可靠。