第一次看到某鸟类观测记录平台的登录请求里蹦出那串 344 个字符的 Base64 密文时,我下意识去搜了一下明文里有没有自己的账号,结果当然是一个都搜不到——整段密码被 RSA 加密算法换成了一个固定长度的字节块。这类场景在爬虫工作里出现的频率远比想象中高:只要站点前端用了常见的 JS 加密库做登录保护,你抓到的数据包里就永远只有一堆字母数字。这篇内容想讲清楚的就是三件事:RSA 加密在爬虫场景里到底是什么样的存在、怎么从压缩混淆的 JS 里把加密入口揪出来、以及怎么用 Python 把加密过程一比一复现出来。下文会以某鸟类观测记录平台(下称"模拟平台")作为完整案例,从抓包判定一直走到可稳定运行的请求封装。适合已经能写基础 requests 请求、但一碰到登录参数被加密就卡住的读者,也适合想系统梳理前端加密还原思路的同学。
1. 先判定加密类型:别一上来就啃 JS
1.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 后有几个非常稳定的开头:MIGf(1024 位)、MIIB(2048 位短格式)、MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A(2048 位带 X.509 头部)、MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ(1024 位带 X.509 头部)。这些字符串在代码里是硬编码的,搜中就是一击必杀。
在开发者工具的全局搜索(Windows 下是Ctrl+Shift+F)里输入MIIB,模拟平台这次定位到两个文件:一个是我们自己的业务 JS,另一个是被引入的加密库。点进业务 JS,公钥就在var PUBLIC_KEY = "..."这一行,往下一屏就是加密调用。整个过程不到两分钟。搜完公钥之后顺手再搜一下10001,这是 RSA 公钥指数 e=65537 的十六进制写法,在手工实现的密钥构造函数里经常出现,可以作为交叉验证。
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 重写是首选方案。它不依赖浏览器环境,运行速度快,出问题时能直接打断点看中间变量。下面这段是模拟平台的实际复现代码,用的是 PyCryptodome:
import 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(b"password|1700000000000", padding.PKCS1v15()) print(base64.b64encode(ct).decode())4.2 公钥的三种形态与它们之间的转换
实际遇到的公钥不止 PEM 这一种形态,常见的有三类,转换方法各不相同。第一类是完整 PEM,直接import_key就行。第二类是"裸 Base64",也就是去掉了-----BEGIN PUBLIC KEY-----头尾的那一长串,这种需要自己补头尾和换行。补的时候注意每 64 字符插一个\n,末尾也要有换行,否则部分解析器会报错。第三类是modulus+exponent形式,通常以十六进制字符串给出,需要按大整数处理:
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-urlencoded,Base64 里的+会被解析成空格,必须做 URL 编码。
会话保持同样关键。模拟平台在第一次访问时会下发一个匿名会话 Cookie,登录接口会校验这个 Cookie 和keyId是否匹配。如果直接用requests.post裸调而不带会话,服务端会返回"公钥不存在"。正确做法是全程用同一个requests.Session()对象,让它自动管理 Cookie:
import 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", timeout=10).json() der_b64 = pk["publicKey"].replace("-", "+").replace("_", "/") pem = ("-----BEGIN PUBLIC KEY-----\n" + "\n".join(der_b64[i:i+64] 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", json=payload, timeout=10)5.2 联调阶段的验证手段
第一次跑通的那一刻我是有点怀疑的——万一服务端只是没校验密文呢?验证的办法是故意把cipher_text改一位字符再发一次,如果服务端返回解密失败或者参数错误,说明它确实在做解密校验;如果照样登录成功,那说明这个字段根本没被校验,你可以省掉整个加密环节。这个"故意改坏"的测试我建议每个站点都做一次,它能帮你确认自己复现的加密是不是真的起作用。
第二步验证是拿浏览器里的密文和 Python 算出来的密文比对。因为 PKCS#1 v1.5 填充带随机性,两次加密的密文肯定不同,没法直接比字符串。可行的办法是把两份密文分别交给前端解密函数(如果页面里有的话)或者直接提交给服务端,看结果是否一致。更彻底的做法是在断点里把前端加密前的明文打出来,跟 Python 拼出来的明文逐字节比对——这一步能抓出分隔符、编码、时间精度之类的隐蔽差异。
5.3 长字段与中文的处理边界
模拟平台这套流程,登录只加密密码所以不会超限。但如果某天站点改成"整个表单 JSON 加密",就会撞上 245 字节的墙。前端的处理方式通常有两种:一是直接分块,每块 245 字节(2048 位)或 117 字节(1024 位)单独加密后拼接再整体 Base64;二是改用"RSA 加密一个随机 AES 密钥,AES 加密正文"的混合方案。这两种在 Python 侧的复现方式完全不同,判断依据是密文长度——分块密文的长度是密钥字节数的整数倍,而混合方案的密文通常只有一两百字符。
分块代码写起来不长,但必须和服务端约定完全一致,包括块顺序、是否每块独立 Base64:
def 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-8;Base64 之后又手工做了一次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 或者加了一层签名时,你只需要改那一个文件。我自己在模拟平台上就吃过没分层的亏,第一版把加密代码和登录逻辑混在一起,后来公钥改成动态下发,改了整整一下午。
最后分享一个我经常用的小技巧:把浏览器断点里抓到的"前端加密前的明文"和"前端加密后的密文"成对存下来,作为自己复现代码的回归测试用例。密文因为带随机填充没法直接比对,但可以反向验证——用私钥解一下自己算的密文,确认解出来的明文和存下来的那条完全一致。有了这组样本,以后不管是换库还是改代码,跑一遍就知道有没有改坏,比任何调试都可靠。