Argon2id 的 m 写在 hash 串里:一次 verify 可能吃掉 1 GiB,Go 和 PHP 都不拦
本篇切面
PHC 串是自描述的:$argon2id$v=19$m=...,t=...,p=...$salt$hash。任何一次 verify 都必须按串里的参数重算,否则老 hash 一律验不过。于是「一次验证分配多少内存」不是你的配置项,而是这行数据的属性。
站内 #8240 讲的是自己选 m/t/p 之后乘 pm.max_children 算内存账。这篇讲另一个方向:参数来自数据本身——外部系统导入的行、外部 IdP 下发的行、被人写过一手的行。
现象
登录接口偶发 502,容器 OOMKilled,CPU 不高、QPS 正常。排查发现 users.password_hash 里有一批 m=1048576(1 GiB)的串,来自一次外部系统用户表导入。一次 verify 就 malloc 1 GiB,几个并发叠加到 limit 就挂。
另一种更难看的形态:有人能写 hash 列(注入、批量导入模板、SSO 同步),塞进 m=8,t=1,p=1 加已知明文的串,等于给自己开了后门。同一个字段,上界放大资源、下界放大风险。
库层到底校验了什么
Go x/crypto/argon2 源码():
deriveKey只在time):默认只接受 argon2id,max m=1048576 KiB(1 GiB)、t=64、p=16,min 默认 0,可用with_min_cost` 拒绝被塞进来的弱 hash。其文档原话是 PHC 的成本参数来自字符串本身,恶意或损坏的 hash 可能要求 GB 级内存、数分钟 CPU,或切到更弱的变体。- .NET 的 Bodu 指南里
MemoryKiB必须 ≥8*Parallelism且 ≤2^21(),注释说明这个库级上限是为了限制不可信 PHC 串能让一次验证分配多少。
这两个例子说明「在库里钳制」是可行工程,但很多语言绑定没有做:Go 的 x/crypto/argon2 没有 parse+policy 层,PHP 的 password_verify 没有 policy 参数(签名就是 password_verify(string $password, string $hash): bool),只能在写入侧和读侧自己加。
判据:验算前先钳参数
四个字段都要卡:算法必须是 argon2id(串里 $argon2i$/$argon2d$ 也在 libargon2 的 verify 入口里,argon2i 第一趟 data-independent 有 low-storage 攻击,argon2d 内存访问依赖密码、有 cache timing)、m 在业务允许区间、t 有上限、p 必须等于 1(服务端 p>1 不会让攻击者变慢,只会让自己多占核)、salt 16 字节、tag 32 字节。
Go 代码(手写 PHC 解析 + 钳制 + IDKey 重算 + constant time 比较):
package pw
import (
"crypto/subtle"
"encoding/base64"
"errors"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
)
type policy struct {
Alg string // argon2id
MinM, MaxM uint32 // KiB
MaxT uint32
P uint8
SaltLen int
HashLen int
}
var defaultPolicy = policy{
Alg: "argon2id", MinM: 19456, MaxM: 65536, MaxT: 6, P: 1,
SaltLen: 16, HashLen: 32,
}
// 解析 $argon2id$v=19$m=19456,t=2,p=1$$
func splitPHC(s string) (alg string, m, t uint32, p uint8, salt, tag []byte, err error) {
parts := strings.Split(s, "$")
if len(parts) != 6 || parts[0] != "" {
return "", 0, 0, 0, nil, nil, errors.New("phc: bad field count")
}
alg = parts[1]
if parts[2] != "v=19" { // 0x13
return "", 0, 0, 0, nil, nil, errors.New("phc: unsupported version")
}
for _, kv := range strings.Split(parts[3], ",") {
k, v, ok := strings.Cut(kv, "=")
if !ok {
return "", 0, 0, 0, nil, nil, errors.New("phc: bad param")
}
n, convErr := strconv.ParseUint(v, 10, 32)
if convErr != nil {
return "", 0, 0, 0, nil, nil, errors.New("phc: bad param value")
}
switch k {
case "m":
m = uint32(n)
case "t":
t = uint32(n)
case "p":
if n == 0 || n > 255 {
return "", 0, 0, 0, nil, nil, errors.New("phc: bad p")
}
p = uint8(n)
default:
return "", 0, 0, 0, nil, nil, errors.New("phc: unknown param")
}
}
if salt, err = base64.RawStdEncoding.DecodeString(parts[4]); err != nil {
return "", 0, 0, 0, nil, nil, errors.New("phc: bad salt")
}
if tag, err = base64.RawStdEncoding.DecodeString(parts[5]); err != nil {
return "", 0, 0, 0, nil, nil, errors.New("phc: bad tag")
}
return alg, m, t, p, salt, tag, nil
}
func Verify(pw, phc string, pol policy) (bool, error) {
alg, m, t, p, salt, tag, err := splitPHC(phc)
if err != nil {
return false, err
}
if alg != pol.Alg || p != pol.P ||
m pol.MaxM || t == 0 || t > pol.MaxT ||
len(salt) != pol.SaltLen || len(tag) != pol.HashLen {
return false, errors.New("phc: params outside policy")
}
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(tag)))
return subtle.ConstantTimeCompare(got, tag) == 1, nil
}
要点在顺序:策略检查必须在 IDKey 之前,任何一步失败都不能进入分配。MinM 不是为了「更安全」,是为了挡住被塞进来的弱 hash——上界和下界是两个不同攻击面。
PHP 侧没有等价钩子,password_verify 只能吃串;能做的是入库前校验(正则抽 m/t/p,数字区间判断,且只允许 $argon2id$v=19$),以及导入脚本按同一套策略过滤并记录被拒行。password_get_info 只回 algo/algoName/options,不是策略工具。别把 password_needs_rehash 当安全边界,它只是拿当前 options 比一遍。
存储与并发
- 列类型给足但从紧:
VARCHAR(255)足够装 32 字节 tag 的串,超长直接拒。 - 写入路径唯一:hash 只由
password_hash/IDKey产出,任何 API 参数都不接受 hash 字段。 - 导入/同步必须过策略,输出「被拒行数 + 原因」,不要静默入库。
- 并发闸门只是兜底:一个实例级 channel 信号量,满了立刻返回 503/429,不要排队(排队只会把内存峰值摊平成持续占用)。注意 Go 的
IDKey不接受context,开始计算就中断不了。
最小复现
Go:benchmark 里对 memory=1048576 调一次 IDKey,用 b.ReportAllocs 看每次分配约 1 GiB;或 /usr/bin/time -v 观察 Maximum resident set size。在 memory limit 512 MiB 的容器里跑,进程会被 OOMKill。
验证器:把一条正常串的 m 改成 1048576 后丢给前面 Verify,应当返回 params outside policy,且 RSS 不涨;去掉策略检查再跑一次,观察分配。这一对对比比任何解释都直接。
落地检查
- 生产里
SELECT一遍password_hash,统计 m 的分布(去重后大概只有几档),确认没有异常档。 - 审计所有调用 verify 的地方:是否在验算前读了串内参数,而不是用服务当前配置重算(后者在参数升级后会把老 hash 全部验失败)。
- 导入/SSO 同步脚本加策略过滤,失败行要能追溯。
- 对照库版本:Rust 侧用带 policy 的封装,Go 侧自己写,PHP 侧靠写入校验 + 串格式检查。
参考链接:
- RFC 9106
- OWASP Password Storage Cheat Sheet
- Go x/crypto/argon2
- P-H-C/phc-winner-argon2
- crypt-io policy 实现
- Bodu Argon2 指南