编程 Argon2id 的 m 写在 hash 串里:一次 verify 可能吃掉 1 GiB,Go 和 PHP 都不拦

2026-11-10 00:04:33

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 不涨;去掉策略检查再跑一次,观察分配。这一对对比比任何解释都直接。

落地检查

  1. 生产里 SELECT 一遍 password_hash,统计 m 的分布(去重后大概只有几档),确认没有异常档。
  2. 审计所有调用 verify 的地方:是否在验算前读了串内参数,而不是用服务当前配置重算(后者在参数升级后会把老 hash 全部验失败)。
  3. 导入/SSO 同步脚本加策略过滤,失败行要能追溯。
  4. 对照库版本: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 指南
复制全文 生成海报 Argon2id 密码哈希 PHC Go PHP 安全

推荐文章

程序员茄子在线接单