验证码安全漏洞——代码审查不会告诉你的那些事
验证码安全漏洞——代码审查不会告诉你的那些事
dong4j验证码是用来干什么的
验证码的核心作用就一个:提高自动化滥用的成本。 让机器不能以极低成本去暴力枚举。说白了就是让非法的请求尽早被拦截,不要让它打到数据库。
这个定义反过来也成立:如果一个验证码实现,不能在前置阶段拦截非法请求,而是让请求先查一次数据库再校验验证码,那它就没起到验证码的作用。
不幸的是,我们的验证码实现就是这样。
先查数据库,再校验验证码
验证码校验逻辑在网关的一个 Filter 中实现。简化的流程是这样的:
1 | 收到登录请求 → 判断用户名是不是手机号 → |
关键代码(脱敏后):
1 | if (!Validator.isMobile(username)) { |
发现了吗?当用户不是手机号登录时,系统先做了一次数据库查询去查用户信息,拿到手机号,然后才去校验验证码。如果用户不存在,抛出的异常还是”用户名或密码错误”——也就是说这个异常是在数据库查询阶段抛出的,验证码还没被校验。
这意味着攻击者可以在不知道验证码的情况下,把一个请求打到数据库查询层。
三个攻击场景
基于这个逻辑缺陷,三个攻击场景可以轻松构造。
重放攻击
假设你截获了一个合法登录请求。这个请求里的加密数据(queryChain)和请求头签名(Message-Sign)都是有效的。你把请求原封不动地重放:
1 | ab -n 10000 -c 100 \ |
100 个并发,10000 个请求,全部打到了数据库。后端日志显示:
1 | 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 = ? |
每一次请求都执行了数据库查询。验证码完全没有起到拦截作用——因为验证码的校验检查在数据库查询之后。
DoS 攻击
即使攻击者不知道合法用户名,也可以发起 DoS 攻击。
只需要调大请求量和并发数:
1 | ab -n 100000 -c 1000 \ |
每个请求都会触发一次数据库查询。不需要暴力破解成功,单纯的请求量就能把数据库连接池打满。验证码功能在这种场景下不仅没有提供保护,反而增加了一次 CPU 开销(验证码缓存查询 + 校验逻辑)。
用户名枚举
这是最严重的一个。因为系统对”用户存在”和”用户不存在”返回了不同的业务错误信息:
- 用户名不存在 → 返回”用户名或密码错误”
- 用户名存在但验证码错误 → 返回”验证码错误”
攻击者可以构造两组请求:
1 | # 第一组 |
如果第一组返回”验证码错误”,第二组返回”用户名或密码错误”,攻击者就知道 aaaaaa 是一个真实用户。这就是典型的用户名枚举漏洞(Username Enumeration),OWASP Top 10 里有这条。
正确的做法是:不管用户名存不存在,都返回同样的错误信息。甚至在用户名不存在时也执行一次假的 bcrypt 比对,让响应时间也一致——防止基于时间的推断攻击。
为什么请求可以随意构造
有人可能会问:请求不是加密的吗?攻击者怎么构造参数?
答案是:加密机制的设计有缺陷。
前端加密的逻辑是这样的:
- 随机生成一个对称密钥(SM4 key),16 字节
- 用后端公钥(SM2)加密这个对称密钥,拼接成
04+ 密文,得到Message-Sign - 用对称密钥(SM4)加密请求参数,得到
queryChain
三个步骤看下来,问题在哪?对称密钥是前端生成的,后端直接信任并使用这个密钥解密数据。 这意味着攻击者可以自己生成对称密钥、自己决定要发送什么参数。
更糟糕的是,公钥在前端代码中是硬编码的,前端源码里可以直接找到。虽然公钥本身不是秘密——它本来就是公开的——但问题在于,攻击者不仅知道公钥,还知道完整的加密逻辑。前端编译后的 JS 代码可读性虽然差,但加密的核心流程是清晰的:
1 | // 前端加密逻辑(从编译后代码还原) |
用 Java 还原同样的逻辑:
1 | // 1. 生成随机对称密钥(和前端一样) |
然后用这些参数构造 HTTP 请求,服务端完全无法区分这是真前端还是假攻击脚本。
核心问题:前端生成的对称密钥应该是临时的、不可预测的、每次请求不同的——这没错。但后端不应该无条件信任前端加密的数据。 如果不考虑加签(签名)验证的话,可以加入防重放机制——比如要求每个请求携带一个随机 nonce 和时间戳,服务端校验 nonce 的唯一性和时效性。或者在验证码层面做更严格的限流——同一个 session 连续 N 次验证码错误就直接拒绝。
但现在这些都没有。
一个隐蔽的 bug:token 获取错误导致缓存完全失效
除了安全漏洞,还有一个隐蔽的功能 bug:
验证码 Filter 在处理加密请求时,会先从请求头中提取 token,然后用这个 token 去查缓存——如果同一个 token 之前已经解密过,直接用缓存的对称密钥,避免重复执行 SM2 解密。
代码是这样写的(脱敏):
1 | // 从请求头获取 token |
逻辑看起来没问题。但 resolveFromAuthorizationHeader 是从 Authorization: Bearer xxx 中提取 token 的——而前端传的是 Authorization: Basic xxx。
Bearer 和 Basic,一个单词的差别。因为这个差别,token 始终为 null。
于是缓存永远不会命中。每次请求都走 SM2 解密(非对称加密的性能开销本来就大),然后再写缓存——用 null 作为 key。第二个请求来了,token 还是 null,缓存 key 匹配不上,又不命中,再解密一次。如此循环。
还有一个时间单位的 bug:注释写的是”缓存过期时间 1 小时”,代码传的是 60 * 60 * 1000,看起来是 1 小时(3600 秒 × 1000 毫秒)。但这个方法的签名是:
1 | void setObject(String key, T value, long expiredTime) |
所以实际缓存时间是 60 * 60 * 1000 秒,大约 1000 小时,而不是 1 小时。不过因为每次请求都重新写入,这个过长的过期时间在实际运行中反而不明显。
这两个 bug 合在一起的效果是:每个加密请求都执行了完整的 SM2 解密 + 两次 Redis 写入,缓存完全没起作用。 在正常流量下这不会出错,但在高并发场景下,SM2 解密的 CPU 开销会成为瓶颈——而这恰好是验证码本应该保护系统不遭受的”高并发攻击流量”场景。讽刺吧?用来防御攻击的机制,本身在攻击场景下先撑不住了。
密钥共享:一个全局的系统性风险
除了代码层面的问题,还有一个配置层面的安全风险——多个项目使用了相同的 SM2 密钥对。
验证方式很简单:用我们已经知道的框架默认密钥去尝试解密不同项目的加密流量。结果是,多个已上线的项目都没有修改默认密钥。只要攻击者手里有一个项目的加密数据,他就能解密所有使用同一密钥的项目的加密数据。
这是一个典型的”默认值风险”。框架为了降低接入门槛,提供了一套默认的 SM2 密钥对。这个出发点是好的——业务方接入的时候可以先用默认密钥跑通流程,等上线前再替换成自己的密钥。但现实是,跑通流程之后,没人记得去换密钥。或者更常见的情况是,换密钥这件事没有被列入上线 check list,上线检查也没人审这个。
所以在第十一篇文章里我提到,我们在 share-config.yaml 中把所有安全敏感配置的默认值改成了留空:
1 | secret: |
这还不够。还需要:
- 在文档的显眼位置列出上线前必须替换的配置清单
- 在 CI 中加一个检查——如果检测到使用了默认密钥,编译阶段直接拦截
- 新人培训时强调这件事的严重性
漏洞不是代码审查发现的
这些漏洞没有一个是通过代码审查发现的。Checkstyle 检查了缩进,PMD 检查了 == 比较字符串,SonarQube 检查了重复率和复杂度——但没人检查”验证码校验和数据库查询的顺序对不对”。安全漏洞不会违反任何编码规范,代码逻辑完全正确,单元测试全部通过。
这是我在本系列中反复提到的”可观测性缺失”在安全层面的映射。代码质量工具能防止你写出不好的代码,但不能防止你写出有逻辑缺陷的代码。后者的发现需要真实的安全测试——手动构造边界条件下的请求、模拟攻击场景、分析日志链路。

















