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 签名>" }

逆向的思路是:定位签名函数 → 看它读什么输入 → 复现。看前端调用栈,最终会落到一个只做字符串运算的小函数。它的算法不复杂,但有三个"机关":

  1. 密钥来自一张自定义字符表。不是固定字符串,而是按路径字符逐个查表拼出来的——所以照抄一个常量 key 会失败,必须把那套 ord(c) % N 查表逻辑原样搬过来。
  2. 做了两次 HMAC:第一次对"路径"求 HMAC,取其中一段当 nonce;第二次对"路径+路径+body"求 HMAC,当签名。
  3. 全部先转小写。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 等。

复现它有三个魔鬼细节:

  1. null 与 undefined 的区别。JS 里 null → "null"、缺失字段 → "undefined";Python 里没有 undefined 概念,必须手动把缺失值写成字符串 "undefined",否则头和签名对不上。
  2. 序列号每请求都要新生成,且是版本 4 的 UUID(第 7 字节 &0x0F|0x40、第 9 字节 &0x3F|0x80)。
  3. 登录后要缓存用户信息(操作员 ID/token/机构),后续请求用它刷新用户头;每次请求交易时间都要刷新。

C 的成功判定也自成一套:响应体里有 responseType 字段,等于 "SUCCESS" 才算成功——同样是"不能只看 200"。而且不同接口的场景码不同(取客户列表和取评级用一套,其余接口用另一套),抄错场景码会被拒。

三者的共性,才是真正可迁移的东西

把三个客户端放一起看,登录逆向的通用套路就清楚了:

  1. 先把流程画出来。A 一步、B 五步、C 两步——步骤决定了你要维护多少状态。
  2. 别信 200。A 用 token 判断、B 用页面特征、C 用 responseType。"什么字段代表成功"本身就是要逆向出来的信息。
  3. 会话要正确携带。A 走 Authorization、B/C 走 Cookie + 自定义头;登录成功只是开始,后续每个请求的"身份"都要带对。
  4. 加密/签名的输入输出要字节级对齐。小写、编码、padding、null/undefined、hex 大小写——全是坑。
  5. 老系统靠抄,新系统靠读。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,三个系统已完全抽象,未包含任何真实密钥、字符表或接口。