我需要脚本化地调用一个「密码在前端加密后再提交」的接口,而目标环境不许装任何第三方库。于是用标准库把整条链路复刻了一遍 —— 其中 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是非零随机字节,长度至少 8M是明文(这里是密码的 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 —— 零第三方依赖,可移植性极好。
三个能带走的东西:
- 解析二进制结构时,按长度字段跳,不要按猜测跳
- 判断请求成败时,看语义字段(如
Location),不要只看状态码 - 失败原因必须分类 —— 「限流」和「密码错」长得几乎一模一样