验证码安全漏洞——代码审查不会告诉你的那些事

random-pic-api

验证码是用来干什么的

验证码的核心作用就一个:提高自动化滥用的成本。 让机器不能以极低成本去暴力枚举。说白了就是让非法的请求尽早被拦截,不要让它打到数据库。

这个定义反过来也成立:如果一个验证码实现,不能在前置阶段拦截非法请求,而是让请求先查一次数据库再校验验证码,那它就没起到验证码的作用。

不幸的是,我们的验证码实现就是这样。

先查数据库,再校验验证码

验证码校验逻辑在网关的一个 Filter 中实现。简化的流程是这样的:

1
2
3
收到登录请求 → 判断用户名是不是手机号 → 
是:跳过用户查询,直接用手机号校验验证码
否:先通过用户名查询用户信息(DB 查询!) → 再校验验证码

关键代码(脱敏后):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (!Validator.isMobile(username)) {
// 通过帐号查询手机号(登陆名不一定是手机号)
CompletableFuture<Response<LoginUser<?>>> completableFuture =
CompletableFuture.supplyAsync(() -> userApiService.info(username));
Response<LoginUser<?>> loginUserResponse = completableFuture.get();
if (!loginUserResponse.isSuccess()) {
throw new ValidateCodeException(loginUserResponse.getMeta().getMessage());
}
LoginUser<?> loginUser = loginUserResponse.getData();
// 集成类帐号不用校验验证码
if (loginUser.getUserType().equals(UserType.INTEGRATION_USER.getCode())) {
log.info("集成帐号:{}登陆操作", username);
return;
}
mobile = loginUser.getTelephone();
}
// ... 验证码校验(在数据库查询之后)

发现了吗?当用户不是手机号登录时,系统先做了一次数据库查询去查用户信息,拿到手机号,然后才去校验验证码。如果用户不存在,抛出的异常还是”用户名或密码错误”——也就是说这个异常是在数据库查询阶段抛出的,验证码还没被校验。

这意味着攻击者可以在不知道验证码的情况下,把一个请求打到数据库查询层。

三个攻击场景

基于这个逻辑缺陷,三个攻击场景可以轻松构造。

重放攻击

假设你截获了一个合法登录请求。这个请求里的加密数据(queryChain)和请求头签名(Message-Sign)都是有效的。你把请求原封不动地重放:

1
2
3
4
5
6
ab -n 10000 -c 100 \
-T application/x-www-form-urlencoded \
-H "Authorization: Bearer c2N0ZWxjcDE6..." \
-H "Message-sign: 049513a2c604f98fb87b..." \
-p post_data.txt \
http://127.0.0.1:5173/api/user/auth/oauth2/token

100 个并发,10000 个请求,全部打到了数据库。后端日志显示:

1
2
c.s.u.api.dao.UserDao.getLoginUserInfo   : ==>  Preparing: select a.*, b.tenant_id, b.org_name from p_user a left join p_org b on a.org_code = b.org_code where user_name = ?
c.s.u.api.dao.UserDao.getLoginUserInfo : ==> Parameters: dongshijie(String)

每一次请求都执行了数据库查询。验证码完全没有起到拦截作用——因为验证码的校验检查在数据库查询之后

DoS 攻击

即使攻击者不知道合法用户名,也可以发起 DoS 攻击。

只需要调大请求量和并发数:

1
2
3
4
5
6
ab -n 100000 -c 1000 \
-T application/x-www-form-urlencoded \
-H "Authorization: Bearer c2N0ZWxjcDE6..." \
-H "Message-sign: 049513a2c604f98fb87b..." \
-p post_data.txt \
http://127.0.0.1:5173/api/user/auth/oauth2/token

每个请求都会触发一次数据库查询。不需要暴力破解成功,单纯的请求量就能把数据库连接池打满。验证码功能在这种场景下不仅没有提供保护,反而增加了一次 CPU 开销(验证码缓存查询 + 校验逻辑)。

用户名枚举

这是最严重的一个。因为系统对”用户存在”和”用户不存在”返回了不同的业务错误信息:

  • 用户名不存在 → 返回”用户名或密码错误”
  • 用户名存在但验证码错误 → 返回”验证码错误”

攻击者可以构造两组请求:

1
2
3
4
5
# 第一组
username=aaaaaa&password=111&code=111111&grant_type=password&...

# 第二组
username=bbbbbb&password=111&code=111111&grant_type=password&...

如果第一组返回”验证码错误”,第二组返回”用户名或密码错误”,攻击者就知道 aaaaaa 是一个真实用户。这就是典型的用户名枚举漏洞(Username Enumeration),OWASP Top 10 里有这条。

正确的做法是:不管用户名存不存在,都返回同样的错误信息。甚至在用户名不存在时也执行一次假的 bcrypt 比对,让响应时间也一致——防止基于时间的推断攻击。

为什么请求可以随意构造

有人可能会问:请求不是加密的吗?攻击者怎么构造参数?

答案是:加密机制的设计有缺陷。

前端加密的逻辑是这样的:

  1. 随机生成一个对称密钥(SM4 key),16 字节
  2. 用后端公钥(SM2)加密这个对称密钥,拼接成 04 + 密文,得到 Message-Sign
  3. 用对称密钥(SM4)加密请求参数,得到 queryChain

三个步骤看下来,问题在哪?对称密钥是前端生成的,后端直接信任并使用这个密钥解密数据。 这意味着攻击者可以自己生成对称密钥、自己决定要发送什么参数。

更糟糕的是,公钥在前端代码中是硬编码的,前端源码里可以直接找到。虽然公钥本身不是秘密——它本来就是公开的——但问题在于,攻击者不仅知道公钥,还知道完整的加密逻辑。前端编译后的 JS 代码可读性虽然差,但加密的核心流程是清晰的:

1
2
3
4
5
6
7
// 前端加密逻辑(从编译后代码还原)
const pk = randomSM4Key(); // 随机对称密钥
const encryptedKey = sm2Encrypt(pk, PUBLIC_KEY); // 公钥加密对称密钥
headers["Message-Sign"] = "04" + encryptedKey;
params = {
queryChain: sm4Encrypt(formData, pk) // 对称密钥加密参数
};

用 Java 还原同样的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. 生成随机对称密钥(和前端一样)
String sm4Key = generateRandomHexKey(16);

// 2. 用公钥 SM2 加密对称密钥
String messageSign = "04" + sm2Encrypt(sm4Key, PUBLIC_KEY);

// 3. 构造请求参数并用 SM4 加密
Map<String, Object> params = Map.of(
"username", "testuser",
"password", null,
"code", "123456",
"grant_type", "password",
"randomStr", "23242323234",
"scope", "server",
"verifyCodeType", "L"
);
String formData = params.entrySet().stream()
.map(e -> e.getKey() + "=" + encode(e.getValue()))
.collect(Collectors.joining("&"));
String queryChain = sm4Encrypt(formData, sm4Key);

然后用这些参数构造 HTTP 请求,服务端完全无法区分这是真前端还是假攻击脚本。

核心问题:前端生成的对称密钥应该是临时的、不可预测的、每次请求不同的——这没错。但后端不应该无条件信任前端加密的数据。 如果不考虑加签(签名)验证的话,可以加入防重放机制——比如要求每个请求携带一个随机 nonce 和时间戳,服务端校验 nonce 的唯一性和时效性。或者在验证码层面做更严格的限流——同一个 session 连续 N 次验证码错误就直接拒绝。

但现在这些都没有。

一个隐蔽的 bug:token 获取错误导致缓存完全失效

除了安全漏洞,还有一个隐蔽的功能 bug:

验证码 Filter 在处理加密请求时,会先从请求头中提取 token,然后用这个 token 去查缓存——如果同一个 token 之前已经解密过,直接用缓存的对称密钥,避免重复执行 SM2 解密。

代码是这样写的(脱敏):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 从请求头获取 token
String token = BearerTokenExtractor.resolveFromAuthorizationHeader(request);
// 用 token 查缓存
String requestSecretKey = loadUserCacheSecretKey(token, secretHeader);
if (StringUtils.hasText(requestSecretKey)) {
log.debug("使用缓存的对称密钥");
return requestSecretKey;
}

// 缓存没命中,执行 SM2 解密
try {
String secretKey = getSecret(secretHeader); // SM2 解密,有性能开销
// 存入缓存
CacheUtil.setObject(REDIS_KEY_HEADER + token, secretHeader, 60 * 60 * 1000);
CacheUtil.setObject(REDIS_KEY + token, secretKey, 60 * 60 * 1000);
return secretKey;
} catch (Exception e) {
log.error("解密(SM2)失败:", e);
return null;
}

逻辑看起来没问题。但 resolveFromAuthorizationHeader 是从 Authorization: Bearer xxx 中提取 token 的——而前端传的是 Authorization: Basic xxx

BearerBasic,一个单词的差别。因为这个差别,token 始终为 null

于是缓存永远不会命中。每次请求都走 SM2 解密(非对称加密的性能开销本来就大),然后再写缓存——用 null 作为 key。第二个请求来了,token 还是 null,缓存 key 匹配不上,又不命中,再解密一次。如此循环。

还有一个时间单位的 bug:注释写的是”缓存过期时间 1 小时”,代码传的是 60 * 60 * 1000,看起来是 1 小时(3600 秒 × 1000 毫秒)。但这个方法的签名是:

1
2
void setObject(String key, T value, long expiredTime)
// expiredTime 单位:秒

所以实际缓存时间是 60 * 60 * 1000 ,大约 1000 小时,而不是 1 小时。不过因为每次请求都重新写入,这个过长的过期时间在实际运行中反而不明显。

这两个 bug 合在一起的效果是:每个加密请求都执行了完整的 SM2 解密 + 两次 Redis 写入,缓存完全没起作用。 在正常流量下这不会出错,但在高并发场景下,SM2 解密的 CPU 开销会成为瓶颈——而这恰好是验证码本应该保护系统不遭受的”高并发攻击流量”场景。讽刺吧?用来防御攻击的机制,本身在攻击场景下先撑不住了。

密钥共享:一个全局的系统性风险

除了代码层面的问题,还有一个配置层面的安全风险——多个项目使用了相同的 SM2 密钥对。

验证方式很简单:用我们已经知道的框架默认密钥去尝试解密不同项目的加密流量。结果是,多个已上线的项目都没有修改默认密钥。只要攻击者手里有一个项目的加密数据,他就能解密所有使用同一密钥的项目的加密数据。

这是一个典型的”默认值风险”。框架为了降低接入门槛,提供了一套默认的 SM2 密钥对。这个出发点是好的——业务方接入的时候可以先用默认密钥跑通流程,等上线前再替换成自己的密钥。但现实是,跑通流程之后,没人记得去换密钥。或者更常见的情况是,换密钥这件事没有被列入上线 check list,上线检查也没人审这个。

所以在第十一篇文章里我提到,我们在 share-config.yaml 中把所有安全敏感配置的默认值改成了留空:

1
2
3
4
secret:
sm2:
privateKey: # 留空,强制业务方自己生成
publicKey: # 留空

这还不够。还需要:

  1. 在文档的显眼位置列出上线前必须替换的配置清单
  2. 在 CI 中加一个检查——如果检测到使用了默认密钥,编译阶段直接拦截
  3. 新人培训时强调这件事的严重性

漏洞不是代码审查发现的

这些漏洞没有一个是通过代码审查发现的。Checkstyle 检查了缩进,PMD 检查了 == 比较字符串,SonarQube 检查了重复率和复杂度——但没人检查”验证码校验和数据库查询的顺序对不对”。安全漏洞不会违反任何编码规范,代码逻辑完全正确,单元测试全部通过。

这是我在本系列中反复提到的”可观测性缺失”在安全层面的映射。代码质量工具能防止你写出不好的代码,但不能防止你写出有逻辑缺陷的代码。后者的发现需要真实的安全测试——手动构造边界条件下的请求、模拟攻击场景、分析日志链路。