爬虫进阶实战:AES+RSA混合加密接口的JS逆向解析与Python复刻 有人跟我说最近的爬虫实战越来越像“考古”——挖接口、挖参数、挖加密逻辑挖着挖着就挖到一堆 JS 混淆。这次开源的“py每日spider案例之某website之解析接口AESRSA难度中等”就是一个比较典型的组合加密场景标题里把技术点都写明白了AES 对称加密 RSA 非对称加密混合使用难度中等。这种加密套路在前端反爬里非常常见尤其是涉及登录、下单、数据上报这些敏感接口几乎成了标配。如果你正在学爬虫进阶或者刚接触 JS 逆向这个案例可以作为一份带注释的实战练习——它不是那种给你一整页混淆代码让你硬啃的噩梦而是通过抓包、定位、还原加密逻辑一步步把接口“翻译”成 Python 能直接调的请求。这篇文章就把整个分析过程和实现代码完整拆开讲讲我是怎么从零开始把这个接口拿下的包括中间踩过的坑和一些可复用的经验。1. 案例定位与分析思路拆解1.1 为什么选这个站和这个接口先说结论选这个案例是因为它的加密方案在真实业务里非常典型但实现又不算极端。某website 的某个核心数据接口在请求时前端并不是直接明文提交参数的而是先把业务参数做一个 AES 加密然后再把 AES 的密钥用 RSA 公钥加密最后把这两段密文拼在一起发给服务端。服务端收到后用 RSA 私钥解密出 AES 密钥再用 AES 密钥解开业务参数。整个过程看下来就是教科书级别的“混合加密”非常标准。但让我觉得它适合做案例的还有几个原因加密逻辑在前端代码里有明确的入口函数和关键词没有套一层恐怖的混淆工厂。密钥的生成、IV 的固定方式都比较直白没有做动态下发或者绑定环境指纹。接口没有复杂的风控校验签名和时间戳的校验比较宽松适合新手完整跑通。当然难点也确实存在因为请求参数是动态加密的你不能像普通接口那样直接复制一个 URL 就能 post你必须在本地用代码复刻出和前端一致的 AES 密钥、RSA 填充方式、字符集编码等细节有一个环节搞错服务端就解密失败。所以这个“难度中等”并不是嘴上说说考核的正是你对加密算法本身的理解程度。1.2 加密方案选型背后的逻辑你可能好奇为什么前端要用 AES RSA 这么麻烦的混合加密而不是只用一种这里其实有一个非常经典的理论RSA 虽然安全性高但它的加解密速度慢而且密文长度会随着密钥长度显著增加不适合加密大量数据。AES 则相反加密速度快适合处理长文本但它的密钥如果直接放在前端只要对方稍微扒一下 JS 就能拿到明文密钥等于白加密。所以混合加密的思路就是“取长补短”用 AES 加密真正的大段业务数据用 RSA 加密那个随机的 AES 密钥。这样即使 RSA 公钥和加密逻辑完全暴露在前端也没关系因为服务端手里的 RSA 私钥才是密钥安全的核心。数据量大用 AES 扛密钥传输用 RSA 护这套路在金融级应用和大型网站里都很常见。理解了这一点你再看这个案例的时候就不会把注意力浪费在“为什么搞两套加密”上而是把关注点放在“如何还原这两套加密的参数和流程”。1.3 整体方案落地的拆解步骤我在拿到这个任务后没有直接打开 DevTools 硬看 JS而是先列了一个大致的执行清单抓包确认哪些接口有加密参数哪些参数是动态生成的。在 Sources 面板里搜索加密相关关键词快速定位加密函数。还原整个 AES RSA 加密流程搞清楚 key、iv、modulus、exponent 从哪里来、怎么变。用 Python 实现同样的加密逻辑构造合法的请求头与请求体。联调验证跑通接口并解析返回数据。这个过程本质上就一句话把浏览器的黑盒打开一条缝看清楚它提交给服务器的到底是什么然后让 Python 冒充这个浏览器。下面我会把每一步的关键细节都展开讲尤其是那些容易翻车的点。2. 核心加密原理与JS还原2.1 你必须先弄懂RSA和AES的“分工”讲实操之前先花点篇幅把两个算法的基础讲透因为后面所有代码都是围绕这两个算法的参数在转。RSA 是非对称加密密钥分成公钥和私钥。公钥负责加密私钥负责解密。前端代码里写死的那个 RSA 公钥你可以看作一把“锁”任何人都可以用这把锁把东西锁起来但只有持有私钥的服务端才能打开。所以即使公钥暴露数据照样安全。这个案例里 RSA 主要用于加密 AES 密钥密钥长度一般是 1024 位或 2048 位。区别在于如果站点用的是 1024 位密钥那么它最多只能加密 117 字节的数据按 PKCS#1 v1.5 填充算而 AES 密钥通常是 16 字节所以加密一点问题都没有。但如果你用这种方法去加密大段参数就会直接报错这也是很多新手第一次调用 RSA 加密大 JSON 时报“data too long”的根本原因——你把 RSA 当全能选手用了。AES 是对称加密加密解密用同一个密钥速度极快。实际业务加密大段的 JSON 字符串都是它负责。AES 有几个需要关注的参数密钥长度128/192/256、加密模式CBC/ECB/GCM 等、填充方式PKCS7/PKCS5/ZeroPadding以及 CBC 模式下必须有的 IV 初始向量。这些参数有一个对不上服务端就解密不出一模一样的明文。在 JS 逆向中你经常会在代码里看到 CryptoJS.AES.encrypt(...) 这样的调用这个案例同样是在浏览器内置的 crypto-js 环境中完成的。这个案例的 AES 采用 CBC 模式 PKCS7 填充密钥 16 字节128 位IV 固定为 16 字节的字符串。RSA 采用 PKCS#1 v1.5 填充公钥明文写死在前端 JS 文件里。这些都是我在还原过程中逐步确认的没有谁一开始就告诉你全靠代码里读。2.2 在JS代码里逆向加密逻辑的关键命门定位加密函数我最常用的一招就是在开发者工具里直接搜索关键词。这个案例里我在某个 JS 文件里搜 “AES” 出现了多处但搜 “encrypt” 的时候看到了冒烟点var key CryptoJS.enc.Utf8.parse(0123456789abcdef); var iv CryptoJS.enc.Utf8.parse(abcdef9876543210); var encrypted CryptoJS.AES.encrypt(JSON.stringify(params), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString();AES 部分就有这些行key 和 iv 都直接写死在 JS 里用Utf8.parse把字符串转为 WordArray。这一下就把 AES 的四个核心要素全拍在我脸上了密钥是0123456789abcdefIV 是abcdef9876543210模式 CBC填充 PKCS7。紧接着我在同一个函数里看到了 RSA 的调用var encryptor new JSEncrypt(); encryptor.setPublicKey(publicKey); var rsaEncryptedKey encryptor.encrypt(aesKey);这就是典型的前端 RSA 加密流程构造 JSEncrypt 对象加载公钥然后对 AES 密钥进行加密。而aesKey往上追溯通常是随机生成的 16 位字符串每次请求都会重新生成一次。看到这里前面那套加密链路就完全清楚了前端随机生成一个 16 位的 AES 密钥aesKey。用aesKey和写死的 IVAES-CBC 加密JSON.stringify(params)得到encryptedData。用写死的 RSA 公钥加密aesKey得到encryptedAesKey。请求体里其实只提交了encryptedData和encryptedAesKey两个字段服务端自己解密。这一步还原的关键就是在混乱的 JS 文件里精准找到“生成密钥”和“加密参数”的那几行而不是从头到尾把整份 JS 读完。大多数前端加密代码再复杂最终都会暴露这几个动作取密钥、取偏移量、加密、拼装。找到入口其他都是顺藤摸瓜。2.3 一个容易忽略的字符集细节这里要额外强调一个我踩过的坑字符集。JS 里的0123456789abcdef转成 WordArray 的时候是按 UTF-8 的字节来解析的这与你 Python 里直接b0123456789abcdef是一模一样的没什么问题。但如果你在前端看到的是CryptoJS.enc.Hex.parse(0123456789abcdef)那意思就完全不同了——它是把十六进制字符串直接按字节解析等价于 Python 里的bytes.fromhex(0123456789abcdef)。一字之差密钥从 16 字节变成 8 字节解密必败。所以以后凡是看到密钥字符串第一反应是确认它的编码类型是Utf8还是Hex。这是决定 AES 解密能否成功的关键前置条件。3. 实操过程与Python实现3.1 抓包确认请求与响应的构成按照我自己的习惯逆向第一步不是打开编辑器而是先开抓包工具。这个案例的请求用常规方式就能看清全貌我直接用了浏览器开发者工具里的 Network 面板刷新页面并触发目标接口调用后很快看到一个 POST 请求。请求体是 JSON 格式但里面不是明文参数而是两个看起来完全随机的字段。我贴一下当时观察到的数据结构字段名我略做了脱敏处理{ bizCode: NTEwMjMwMTExMTUyNDM0, data: u7lO1VjXWKLhB7fGhWfLmV7ePZ5cJ9Vh8N2KLkO2r3E, encryptedAesKey: l8J3vU9O5bD6aR2mQ1kZ... }我把这个请求复制成 cURL再用 Python 的 requests 库发了一遍服务端直接返回了一个业务错误码。这完全符合预期因为服务端压根解不开我传的假参数。但是这也恰恰证明了一点这个接口确实依赖动态加密参数不是那种只要带上 Cookie 就能直接获取数据的“伪加密”接口。抓包阶段还有一个重要任务就是把请求头里的Content-Type、User-Agent、Referer、自定义 header 等全部记录下来。后续构造请求时这些 Header 一个都不能少尤其是服务端会校验Content-Type和Origin如果你漏了某些自定义 header比如签名用的nonce、timestamp请求直接会被拒掉。这个案例的 header 不算复杂但我在后面代码里还是完整地带上了一份。3.2 定位JS中生成密钥的源头刚才已经拿到了 AES 的 key 和 IV但 AES 密钥不应该是写死的某个固定值而是每次请求随机生成的那个写死的0123456789abcdef是我在调试时看到的固定初版密钥。实际生产逻辑会生成一个随机字符串作为密钥。在完整还原时我需要继续往这个函数的调用处追溯。我用 Chrome 的 Call Stack 往回翻了几个函数很快发现密钥是在某个initRequest方法里先生成的function generateRandomKey(length) { var chars ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789; var key ; for (var i 0; i length; i) { key chars.charAt(Math.floor(Math.random() * chars.length)); } return key; } var aesKey generateRandomKey(16);这就是标准的随机字符串生成函数每次请求都会生成一个新的 16 位字符串作为 AES 密钥。这也解释了为什么同一请求发两遍data字段长得完全不一样——因为 AES 密钥每次都变加密出来的密文当然完全不同。这个机制对逆向来说是好事因为我不需要在代码里写死某个密钥只需要复刻出“每次随机生成一个 16 位字符串”的逻辑就可以。Python 里对应这个逻辑的一行代码是import random, string aes_key .join(random.choices(string.ascii_letters string.digits, k16))这里有一个更值得注意的点这个随机生成函数用的是Math.random()它不具备加密安全性但对于还原请求而言我们不需要预测它的输出只需要让服务端能解出来就行。只要你是随机生成的自己知道密钥是多少服务端也能用 RSA 解出密钥它根本不在乎你是用什么随机函数生成的。所以本地 Python 就用标准的 random 模块就够了不需要跟 JS 的随机算法保持一致。3.3 用Python完整复刻AESRSA加密链路在我把所有参数和生成逻辑都搞清楚之后剩下的事情就变得非常机械了。我用 Python 的Crypto库对应 PyCryptodome 库和rsa库来实现前端两个加密动作。先看 AES 部分。前端用的是 CryptoJS 的 AES 加密默认toString()输出的密文是 Base64 字符串。Python 里对应的写法是from Crypto.Cipher import AES from Crypto.Util.Padding import pad import base64 # 固定参数来自JS逆向 AES_KEY b0123456789abcdef AES_IV babcdef9876543210 def aes_encrypt(plaintext: str) - str: plaintext: 需要加密的 JSON 字符串 返回 Base64 编码的密文 key_bytes AES_KEY iv_bytes AES_IV # 用字节形式创建 AES-CBC 实例 cipher AES.new(key_bytes, AES.MODE_CBC, iv_bytes) # PKCS7 填充等价于前端 Pkcs7 padded_data pad(plaintext.encode(utf-8), AES.block_size) encrypted cipher.encrypt(padded_data) return base64.b64encode(encrypted).decode(utf-8)这里有一个新手最容易卡住的地方AES.new()的key_bytes和iv_bytes必须是bytes类型不能是字符串。如果你直接在 Python 里传0123456789abcdef这种字符串PyCryptodome 会直接报错所以在代码里先用b...转成字节。再看 RSA 加密 AES 密钥。我使用的是 Python 的rsa库因为rsa.encrypt可以直接处理公钥字符串不需要像Crypto那样先构造 RSA 对象。但要注意公钥的格式这个案例的公钥是 PEM 格式的字符串以-----BEGIN PUBLIC KEY-----开头。如果你的环境拿到的公钥是一串 Base64 的 modulus/exponent 对你还需要用rsa.PublicKey(n, e)手动构造。两种方式我都写下import rsa # 情况1直接是 PEM 公钥字符串 PUBLIC_KEY_PEM -----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC... -----END PUBLIC KEY----- def rsa_encrypt_pem(data: bytes) - str: pub_key rsa.PublicKey.load_pkcs1_openssl_pem(PUBLIC_KEY_PEM.encode(utf-8)) encrypted rsa.encrypt(data, pub_key) return base64.b64encode(encrypted).decode(utf-8) # 情况2如果给你的是 modulus 和 exponent def rsa_encrypt_modulus(data: bytes, modulus_hex: str, exponent: int 65537) - str: n int(modulus_hex, 16) pub_key rsa.PublicKey(n, exponent) encrypted rsa.encrypt(data, pub_key) return base64.b64encode(encrypted).decode(utf-8)注意一点RSA 加密的明文长度有严格限制我这里只加密 16 字符的 AES 密钥完全在可加密范围内。rsa.encrypt默认使用 PKCS#1 v1.5 填充所以与前端JSEncrypt的默认行为是一致的。如果你在别处看到前端用RSA_PKCS1_OAEP_PADDING那才是需要换一种填充方式的情况。到这里整个加密链路的 Python 版就闭环了。我整理一下最终构造请求的完整函数import json import time import requests import random import string import base64 import rsa from Crypto.Cipher import AES from Crypto.Util.Padding import pad def build_request_payload(real_params: dict) - dict: # 1. 随机生成 AES 密钥 aes_key .join(random.choices(string.ascii_letters string.digits, k16)) # 2. AES 加密业务参数 plaintext json.dumps(real_params, separators(,, :)) encrypted_data aes_encrypt(plaintext, aes_key.encode()) # 3. RSA 加密 AES 密钥 encrypted_aes_key rsa_encrypt_pem(aes_key.encode()) return { bizCode: encrypted_aes_key, data: encrypted_data, timestamp: str(int(time.time() * 1000)) } # 然后直接发起请求 resp requests.post( https://某website.example/api/data, jsonbuild_request_payload({...}), headers{...} )当然上面代码里的aes_encrypt和rsa_encrypt_pem是基于固定 key/iv 和 PEM 公钥的封装实际环境中可能需要做参数调整但整体流程就是这个形状。3.4 注意请求体字段名的坑有一个细节我觉得值得单独拿出来讲很多新手拿到请求体后看见data字段就以为直接放密文就行压根不管其他字段。但这个案例的请求体里还有bizCode和timestamp字段服务端会校验它们的格式和有效期。如果bizCode不是 RSA 加密后的 AES 密钥或者timestamp与服务器时间相差过大接口会直接返回“参数校验失败”之类的错误。这种错误跟 AES 解密失败的错误还不一样它会让你误以为自己的加密逻辑完全错了浪费很多排查时间。所以在还原一个接口时一定要把请求体的每个字段都弄明白它的含义和生成方式而不是只盯着最显眼的那个加密切片。我的习惯是列一张字段映射表哪个字段来自什么加密逻辑、什么时间戳策略、什么签名算法全部写到注释里方便后续排查。尤其是遇到“参数校验失败”“签名错误”这类模糊报错有这张表排查效率会高很多。3.5 完整请求头与请求体组装参考我把自己在跑通时的完整请求头贴出来供大家对照检查。注意这里的 Cookie、User-Agent 都需要改成你自己实际抓包拿到的值不要直接照抄headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Content-Type: application/json;charsetUTF-8, Accept: application/json, text/plain, */*, Referer: https://某website.example/, Origin: https://某website.example, Cookie: 你的会话Cookie字符串 } payload { bizCode: encrypted_aes_key, data: encrypted_data, timestamp: str(int(time.time() * 1000)) } resp requests.post(https://某website.example/api/xxx, jsonpayload, headersheaders, timeout10) print(resp.status_code, resp.text)如果返回的 JSON 里出现了解密后的业务数据那就说明整条链路已经走通。这里的bizCode字段名是脱敏后的展示真实环境中叫什么都有可能是key、enKey、token之类都不奇怪。4. 常见问题与排查技巧实录4.1 明明复刻了加密逻辑为什么服务端还是拒绝这是刚接触混合加密的人问得最多的问题。我复盘了几个高频原因字符集问题前面提到过JS 里CryptoJS.enc.Utf8.parse对应 Python 的.encode(utf-8)如果你误用了Hex.parse对应的bytes.fromhex数据就错位了。JSON序列化格式不一致前端加密的原文是JSON.stringify(params)默认没有空格输出形如{key:value}。Python 的json.dumps如果用了indent4或者默认的ensure_asciiTrue输出格式会变密文自然不同。这里强烈建议使用json.dumps(params, separators(,, :), ensure_asciiFalse)来对齐。RSA填充方式不一致前端可能用的不是默认的 PKCS#1 v1.5而是 OAEP。你需要在 JS 里看清encrypt的底层实现或直接看依赖库版本。大多数 JSEncrypt 默认是 PKCS#1 v1.5但不能想当然。公钥格式不对有些站点的公钥需要从接口动态获取每次请求都要先请求一次公钥接口然后拼接出完整的 PEM 字符串。如果你直接把写死的公钥字符串拿去用而站点已经更换过密钥解密必败。时间戳与随机字符串对不上有些接口会校验timestamp和随机字符串的位数比如前端生成 32 位随机字符串你只生成 16 位服务端一查格式不对直接拒绝。这些问题我在实现的过程中都踩过其中 JSON 序列化格式不统一是最隐蔽的坑。因为 AES 解不出来时没有报错提示只会给你一段乱码或者空数据你很难往“原文格式不对”这个方向想。我自己的排查思路是先在浏览器 Network 面板里找到一条真实请求拿到它的data和enKey然后写一个本地脚本尝试用自己的密钥解密这条真实请求的密文如果能解出来但字段对不上再去看 JSON 格式的差异。4.2 明确报错的判断方向当你真实模拟时不同错误码对应的方向可以用一张表快速定位现象大概率原因排查方向返回“参数不合法”JSON 序列化格式不一致或缺少字段对比真实请求体字段是否齐全返回“解密失败”或明文乱码AES 的 key/iv/模式/填充不正确检查字符集、key 长度、填充方式返回“签名错误”请求体里还有额外的签名参数优先查找签名生成函数如sign字段返回“token过期”Cookie 或 token 未正确携带检查请求头/请求体里 token 字段返回“请联系管理员”可能触发了频率限制降低请求频率加延时换出口IP这里额外提一句我在标题里写的“某website”本身就是为了保护目标站点合规起见大家在做练习时最好选择完全公开的演示页面、自己的测试前端或者有明确授权的研究目标不要拿真实生产业务数据反复测试。爬虫技术本身没有错但使用边界得自己把握好。4.3 验证前端“动态密钥”的调试技巧如果你也想在浏览器里实时验证 AES 密钥的生成过程可以下断点调试打开 Sources 面板找到生成 aesKey 的那一行。在那一行打上断点刷新页面。当代码停在断点时在 Console 里输入aesKey就能看到当前请求使用的 AES 密钥明文。再往下走几步进入CryptoJS.AES.encrypt调用处可以把key和iv打印出来确认和我在代码里贴的常量一致。这个操作看似简单但对理解“密钥是怎么生成的”非常有帮助。你亲眼看到它每次请求都不一样就会明白本地代码为什么也要动态生成密钥而不是复制一次请求就用固定值去构造。很多新手就是因为用固定 value 去加密结果同一明文每次密文都一样虽然服务端能解密但一旦你换了明文字段就彻底懵了不知道该怎么处理。按我自己的经验整个案例从抓包到完全跑通大概花了一个晚上的时间。最耗时的环节不是写 AES/RSA 代码而是定位前端那几行关键函数以及把 JSON 序列化格式对齐。一旦把“密钥从哪里来、参数怎么拼、填充怎么选”这三个问题弄明白后面写 Python 代码其实就是翻译的工作甚至不需要用到 Selenium 这类重型工具纯 requests 就能实现。最后再分享一个小技巧如果你在分析过程中发现某个加密参数同时出现在多个接口里而且不同接口的参数名还不一样大概率是同一个加密函数在复用。你可以把加密还原逻辑单独抽成一个公共模块后续遇到同站的其他接口直接调用同一个函数构造请求体就行。我在这个案例里就是保持了这种“一次还原处处调用”的思路后面再处理这个站的其他接口时省了不少事。逆向这条路最重要的就是积累可复用的套路和心法遇到一个加密场景就吃透一个后面越做越快。