Python 爬虫(七):登录逆向——三个客户端脚本里的验证码、加密与签名
Posted on Mon 19 October 2026 in 技术 • Sucre Liu
前提与边界:以下都是自有授权系统、用我本人合法账号做自动化时做的登录逆向——目的是让程序像本人一样正常登录,不是攻击或越权。三个系统一律称 A / B / C,所有真实域名、账号、密钥、字符表、接口路径与业务值均已移除。滑块、行为风控这类人机校验本文不涉及、不提供绕过。请仅在你拥有授权的范围内使用。
三个客户端,三种防护形态
同一个项目里要登录三个系统,结果发现它们的防护思路完全不同——这几乎是内部系统的常态。先把差异摊开:
| 客户端 A | 客户端 B | 客户端 C | |
|---|---|---|---|
| 登录形态 | 一次 POST | 多步表单 + 远程调用 | 打开登录页 + 一次 POST |
| 图形校验 | 无 | 有(字符图片) | 无 |
| 密码处理 | 明文(走 HTTPS/内网) | 明文 + 自定义编码 | MD5/AES → SM2 |
| 请求防护 | 双层 HMAC 签名头 | 老式会话/角色多步 | 自定义 Base64 请求头 |
| 成功判定 | 响应带 token | 页面特征(非 200) | responseType 字段 |
| 会话载体 | Authorization 头 |
Cookie | Cookie + 用户信息头 |
把这三个摸透,等于把"内部系统登录"的三大常见形态各打一遍。下面逐个拆。
客户端 A:双层 HMAC 签名
客户端 A 的接口是干净的 JSON POST,但每个请求都带一个动态签名头,形状是"随机数当键、签名当值":
{ "<20 位 nonce>": "<128 位 hex 签名>" }
逆向的思路是:定位签名函数 → 看它读什么输入 → 复现。看前端调用栈,最终会落到一个只做字符串运算的小函数。它的算法不复杂,但有三个"机关":
- 密钥来自一张自定义字符表。不是固定字符串,而是按路径字符逐个查表拼出来的——所以照抄一个常量 key 会失败,必须把那套
ord(c) % N查表逻辑原样搬过来。 - 做了两次 HMAC:第一次对"路径"求 HMAC,取其中一段当 nonce;第二次对"路径+路径+body"求 HMAC,当签名。
- 全部先转小写。path 和 body 都要
lower()后再参与运算——这也是为什么它的大小写不敏感。
复现出来大致是这样的骨架(key 用占位符,真实字符表不外泄):
import hashlib, hmac
def sign(path: str, body: str, codes: list[str]) -> dict:
p = path.lower()
# 1) 用字符表按路径算出密钥,而不是写死一个常量
secret = "".join(codes[ord(c) % len(codes)] for c in p + p)
# 2) 第一次 HMAC 取中间段当 nonce
nonce = hmac.new(secret.encode(), p.encode(), hashlib.sha512).hexdigest()[10:30]
# 3) 第二次 HMAC 用 "路径+路径+body" 当签名
sig = hmac.new(secret.encode(), (p + p + body.lower()).encode(),
hashlib.sha512).hexdigest()
return {nonce: sig}
逆向 A 的关键教训:签名算法是可以被"读出来"的,只要你能定位到那个函数。定位不到时的线索是:密文/签名的长度是固定的(20 + 128),说明它就是哈希类;再顺着调用栈往回找输入即可。
客户端 B:多步表单 + 图形校验
客户端 B 是最"老派"的一种:登录不是一次请求,而是一条链。
1. GET 打开登录页 → 建立会话(Cookie)
2. GET 下载图形校验 → 拿到一张图片(字节)
3. POST 请求角色列表 → 老式远程调用(带会话/批次参数)
4. POST 选定角色
5. POST 提交账号/密码/校验 → 完成后才算登录
三个必须处理的细节:
其一,图形校验(字符图片)。 这类图片是服务端发给你自己账号的挑战,用通用 OCR 识别是授权自动化里的常见做法。但工程上重点是"怎么应对识别失败",而不是"用哪个库":
def fetch_captcha(session, url) -> str | None:
r = session.get(url, timeout=10)
if r.status_code != 200:
return None
try:
return ocr_engine.classification(r.content) or None # 直接传 bytes
except Exception:
return None
识别有失败率,所以策略是:识别一次 → 提交 → 若失败则整段重登(从第 1 步重来),并设小上限(如 3 次)。图形校验与会话绑定,只重跑最后一步通常没用。
其二,编码与转义。 老系统的坑集中在编码:页面是 GBK,所以响应要 r.encoding = "gbk" 才能正确解出中文;某些表单字段的值还要按特定方式转义后再提交。任何一处编码错了,服务器都当你是错的——而报错往往只是"验证码错误",让人误判方向。
其三,老式远程调用。 第 3、4 步不是 REST,而是一串带着会话 ID、批次号和编号参数的远程调用。这种没有通用技巧,只能把每个参数对照 DevTools 抄下来,并分清哪些是固定的、哪些必须来自上一步响应。抄错一个,链路就断在中间。
最后是成功判定。B 最典型的坑:验证码错了它也返回 200。所以绝不能看状态码,而要看页面特征——登录页有"系统标题 + 当前用户:用户"这种未登录占位,登录成功后这些特征消失、出现真实用户名:
def is_login_page(html: str) -> bool:
text = BeautifulSoup(html, "html.parser").get_text("", strip=True)
return (LOGIN_TITLE in text) and (LOGIN_USER_PLACEHOLDER in text)
def login_ok(html: str, status: int) -> bool:
return status == 200 and not is_login_page(html)
选特征的原则:未登录一定有、登录一定没有,并且用两个特征同时命中降低误判。
客户端 C:密码加密链 + 自定义请求头编码
客户端 C 是三者里加密最重的一个。它的密码不是明文,而是走了一条两级加密链,且带 flag 选择算法:
明文密码
└─ flag=1: MD5(明文) → hex
└─ flag=0: AES-CBC(明文) → Base64
↓
SM2 公钥加密(上一级结果)
↓
十六进制密文(发给服务端)
逆向它的过程,就是把这三级照搬:
def pre_encrypt(plaintext, flag, key, iv):
if flag == "0": # AES-CBC + PKCS7 + Base64
ct = AES.new(key, AES.MODE_CBC, iv).encrypt(pad(plaintext.encode(), 16))
return base64.b64encode(ct).decode()
return hashlib.md5(plaintext.encode()).hexdigest() # flag=1
def encrypt_password(password, flag, pubkey):
inner = pre_encrypt(password, flag, key, iv)
return CryptSM2(private_key="", public_key=pubkey, mode=1).encrypt(inner.encode()).hex()
几个"差一个字节就登录失败"的点,全在这里:
- SM2 的输出格式:是
C1||C3||C2还是C1||C2||C3、是 hex 还是 base64、mode取 1 还是 0——必须和前端一致。 - AES 的 padding 与编码:PKCS7、
utf-8、Base64 是否 URL-safe。 flag的语义:哪个值走 MD5、哪个走 AES,写反了密文结构就不对。
C 的第二处难点是自定义请求头。它每个请求都带两个"看起来是随机串"的头,实际是查询字符串先 UTF8 再 Base64:
Encode-...-Request:装请求元数据——序列号、幂等序列号(都是 UUIDv4)、场景码、交易时间、租户 ID 等;Encode-...-User-Request:装用户信息——操作员 ID、token、机构 ID 等。
复现它有三个魔鬼细节:
null与undefined的区别。JS 里null → "null"、缺失字段 →"undefined";Python 里没有undefined概念,必须手动把缺失值写成字符串"undefined",否则头和签名对不上。- 序列号每请求都要新生成,且是版本 4 的 UUID(第 7 字节
&0x0F|0x40、第 9 字节&0x3F|0x80)。 - 登录后要缓存用户信息(操作员 ID/token/机构),后续请求用它刷新用户头;每次请求交易时间都要刷新。
C 的成功判定也自成一套:响应体里有 responseType 字段,等于 "SUCCESS" 才算成功——同样是"不能只看 200"。而且不同接口的场景码不同(取客户列表和取评级用一套,其余接口用另一套),抄错场景码会被拒。
三者的共性,才是真正可迁移的东西
把三个客户端放一起看,登录逆向的通用套路就清楚了:
- 先把流程画出来。A 一步、B 五步、C 两步——步骤决定了你要维护多少状态。
- 别信 200。A 用 token 判断、B 用页面特征、C 用
responseType。"什么字段代表成功"本身就是要逆向出来的信息。 - 会话要正确携带。A 走
Authorization、B/C 走 Cookie + 自定义头;登录成功只是开始,后续每个请求的"身份"都要带对。 - 加密/签名的输入输出要字节级对齐。小写、编码、padding、
null/undefined、hex 大小写——全是坑。 - 老系统靠抄,新系统靠读。B 那种远程调用只能逐个参数对照;A/C 那种签名/加密,读懂函数就能复现。
逆向方法论:对拍 + 黄金样本
登录逆向没有黑魔法,只有对拍和固化:
- 对拍:
Copy as cURL先确认照抄能成功;再用回显服务比对 header/body;加密/签名不一致时,拿前端的输入输出当黄金样本,逐字节比对 Python 的结果。 - 固化:一旦复现正确,就把它写成断言固定值的测试。这是最容易被忽视、却最值钱的一步。签名和加密都是确定性纯函数,用一组已知输入锁住输出,重构时才不会悄悄改坏:
def test_sign_known_value():
# 固定输入 → 固定签名,锁定契约
assert sign("/api/example", '{"a":"b"}', CODES) == {"<nonce>": "<sig>"}
def test_aes_known_value():
assert aes_encrypt("test123456") == "<base64 密文>"
只要这些测试在,后面无论怎么重构,登录链路都不会因为"手滑改错一个字符"而失效。
边界
这些技术只该用在你被授权访问的系统上:
- 复现前端加密与签名,是让程序像你本人一样登录;它不授权你去访问别人的数据。
- 图形校验(字符图片)在授权场景下可自动化;滑块、行为风控不做。
- 任何密钥、字符表、接口、账号,都不写进公开代码或文章。
小结
- 三个客户端代表三种形态:A 双层 HMAC 签名、B 多步表单 + 图形校验、C 密码两级加密 + 自定义请求头。
- 签名/加密是读得出来的纯函数:定位入口 → 识别算法 → 字节级复现。
- 判断"登录成功"依系统而异:token / 页面特征 / 响应字段,都不是状态码。
- 老系统靠抄参数,新系统靠读函数;共同的武器是对拍与黄金样本测试。
- 多步登录的状态、编码与转义,是失败率最高的地方。
本文基于 requests 2.33 / BeautifulSoup 4.14 / pycryptodome / gmssl / Python 3.12,三个系统已完全抽象,未包含任何真实密钥、字符表或接口。