无过
发布于 2026-10-01 / 0 阅读
0
0

用纯 Python 标准库实现 RSA 加密:一次 DER 解析翻车记

我需要脚本化地调用一个「密码在前端加密后再提交」的接口,而目标环境不许装任何第三方库。于是用标准库把整条链路复刻了一遍 —— 其中 DER 解析那个坑,让我盯着 pow() 3rd argument cannot be 0 看了很久。

背景

有些 Web 接口出于「不让明文密码出现在日志和代理里」的考虑,会把密码在浏览器端用 RSA 加密,再把密文提交给服务端。

这意味着:你不能简单地 POST 明文密码。想做成自动化(接口测试、批量操作),就得把前端的加密逻辑复刻一遍。

约束条件:干净环境的 Python 3,不装 pycryptodome、不装 requests。只能用 urllib、base64、re、os。

第一步:把公钥拿到手

公钥通常内联在页面里,形式是一段 base64 编码的 DER:

html = urlopen(base + '/login').read().decode()
pk   = re.search(r'const publicKey = "([^"]+)"', html).group(1)
csrf = re.search(r'name="_csrf" value="([^"]+)"', html).group(1)

两个小细节:

  • base64 里可能被转义成 \/(字符串里的斜杠转义),先替换回来
  • 长度不是 4 的倍数时要补 =,否则 b64decode 会直接报错
pk = pk.replace('\\/', '/')
pk += '=' * ((-len(pk)) % 4)

第二步:解析 DER,取出模数 n(坑在这里)

公钥的 DER 是个嵌套的 TLV 结构:

SubjectPublicKeyInfo ::= SEQUENCE {
    algorithm         AlgorithmIdentifier,   ← 必须整段跳过
    subjectPublicKey  BIT STRING {
        RSAPublicKey ::= SEQUENCE { modulus INTEGER, exponent INTEGER }
    }
}

坑:如果解析 AlgorithmIdentifier 时只读了它的 tag 就继续往下走,就会把里面的 OID 长度当成 BIT STRING 的长度,后面全部错位,最终 n 被解析成 0。

症状是加密时报出那个著名的错:

ValueError: pow() 3rd argument cannot be 0

这个报错一个字都没提 DER,所以很容易往「加密算法写错了」的方向查 —— 而真正的错误在解析阶段。

正确做法:把 AlgorithmIdentifier 的总长度读出来,整段跳过:

def read_len(buf, i):
    """读 DER 长度字段;返回 (长度, 新偏移)"""
    l = buf[i]; i += 1
    if l & 0x80:                        # 长形式:后续 n 个字节表示长度
        n = l & 0x7f
        l = int.from_bytes(buf[i:i + n], 'big')
        i += n
    return l, i


def parse_rsa_n(der):
    _, i = read_len(der, 1)                  # 外层 SEQUENCE
    aln, j = read_len(der, i + 1)            # AlgorithmIdentifier 的长度
    i = j + aln                              # ★ 整段跳过,不要只跳 tag
    blen, j = read_len(der, i + 1)           # BIT STRING 长度
    bitstr = der[j:j + blen]
    _, k2 = read_len(bitstr, 2)              # 跳过 unused-bits 字节
    nl, k3 = read_len(bitstr, k2 + 1)        # INTEGER:modulus
    return int.from_bytes(bitstr[k3:k3 + nl], 'big')

两个偏移量要解释一下:

  • read_len(der, 1):从第 1 个字节开始读,因为第 0 字节是 SEQUENCE 的 tag(0x30)
  • read_len(bitstr, 2):BIT STRING 的内容以 03 <len> 00 开头,那个 00 表示「unused bits = 0」,要跳过

可迁移的教训:解析 TLV / 二进制结构时,长度字段告诉你「后面还有多少字节」,那就按长度跳 —— 不要按「我猜这里面有几个元素」跳。

第三步:PKCS#1 v1.5 填充

RSA 不是「把密码当数字做一次幂运算」。标准要求先把消息填充成固定长度的加密块 EM,长度等于模长:

EM = 0x00 || 0x02 || PS || 0x00 || M
  • PS 是非零随机字节,长度至少 8
  • M 是明文(这里是密码的 UTF-8 字节)
def encrypt(msg: bytes, n: int) -> bytes:
    emlen = (n.bit_length() + 7) // 8            # 4096 位 → 512 字节
    if emlen < len(msg) + 11:
        raise ValueError('模长不足以容纳消息')
    ps = bytearray()
    need = emlen - len(msg) - 3
    while len(ps) < need:
        b = os.urandom(1)
        if b[0]:                                 # 必须非零
            ps += b
    em = b'\x00\x02' + bytes(ps) + b'\x00' + msg
    c = pow(int.from_bytes(em, 'big'), 65537, n)     # 65537 是标准公钥指数
    return c.to_bytes(emlen, 'big')

为什么 PS 必须非零?因为接收端是按 0x00 || 0x02 之后的第一个 0x00 来切分「填充」和「明文」的。如果 PS 里混进零字节,切分位置就错了。

第四步:提交表单、维护会话

密码字段提交的是加密后的字节再做一次 base64:

body = urlencode({
    '_csrf':    csrf,
    'username': user,
    'password': base64.b64encode(encrypt(passwd, n)).decode(),
}).encode()

会话管理用标准库的 http.cookiejar 最省事:

cj = http.cookiejar.CookieJar()
opener = build_opener(HTTPCookieProcessor(cj))

成功标志不要看状态码,要看 Location —— 很多框架在失败时同样返回 302(跳回登录页并带上错误参数)。判断条件是「跳转目标是登录后的页面」。

第五步:后续请求要带 CSRF 头

拿到会话之后调接口,框架通常要求带一个 CSRF 头,值就是 cookie 里那个令牌 URL 解码后的结果:

xsrf = unquote(cookie_value('XSRF-TOKEN'))
headers['X-XSRF-TOKEN'] = xsrf

漏了这个头,读操作正常、写操作返回 403。而 403 很容易被误读成「权限不够」,其实是缺了一个请求头。

最后:一个把人误导成「密码错」的限流

登录成功若干次之后,接口开始返回:

302 → /login?error=rate-limit-exceeded&method=local

注意 error=rate-limit-exceeded。

如果你的脚本只看「有没有拿到会话 cookie」来判断成败,就会把这个当成「密码错了」,然后开始怀疑人生、反复重试 —— 而每次重试都在把这个限流窗口往后推。

正确做法:解析 Location,把失败原因分类:

Location 特征含义应对
指向登录后的页面成功继续
含 rate-limit-exceeded被限流等待,不要重试
其它含 error=凭据错误检查账号密码

可迁移的教训:「失败」必须分类,不要只有「成功 / 失败」两态。 一个把所有失败都归因到同一个原因的脚本,会把你带进完全错误的排查方向。

小结

整个过程只用了 urllib、http.cookiejar、base64、re、os —— 零第三方依赖,可移植性极好。

三个能带走的东西:

  1. 解析二进制结构时,按长度字段跳,不要按猜测跳
  2. 判断请求成败时,看语义字段(如 Location),不要只看状态码
  3. 失败原因必须分类 —— 「限流」和「密码错」长得几乎一模一样

评论