验证码绕过大全
验证码(OTP, One-Time Password)看似简单,但实现细节稍有不慎就会被绕过。下面从最基础到最刁钻,逐一拆解。
一、验证码生命周期
发送 → 存储 → 验证 → 销毁
│ │ │ │
│ │ │ └── 忘记销毁?→ 重复使用
│ │ └── 逻辑不严?→ 绕过校验
│ └── 存明文?→ 数据库泄露
└── 无频率限制?→ 短信轰炸
二、绕过方式汇总
| 编号 | 绕过方式 | 难度 | 根因 |
|---|---|---|---|
| 1 | 直接回显 | ⭐ | 调试代码未删 |
| 2 | 前端校验绕过 | ⭐ | 只在前端校验验证码 |
| 3 | 重放攻击 | ⭐⭐ | 验证码未一次性使用 |
| 4 | 爆破 | ⭐⭐⭐ | 无频率限制 + 非原子计数 |
| 5 | 响应操控 | ⭐⭐ | 根据响应差异判断 |
| 6 | 邮箱/短信转发 | ⭐⭐⭐ | 业务逻辑缺陷 |
| 7 | 替换手机号/邮箱 | ⭐⭐⭐ | 绑定关系未校验 |
| 8 | 万能验证码 | ⭐⭐⭐⭐ | 硬编码测试值 |
三、方式一:直接回显
3.1 漏洞描述
后端把生成的验证码直接返回给前端(调试用忘了删)。
3.2 漏洞代码
// VULNERABLE: 开发调试用,忘了删
app.post('/api/sms/send', async (req, res) => {
const { phone } = req.body;
const code = Math.floor(100000 + Math.random() * 900000);
await db.SmsRecord.create({ phone, code, expireAt: Date.now() + 300000 });
// ❌ 直接把验证码返回给前端!
res.json({
success: true,
code: code, // ← 就在这里
debug: 'DEV_MODE',
});
});
3.3 修复
// FIXED
app.post('/api/sms/send', async (req, res) => {
const { phone } = req.body;
const code = Math.floor(100000 + Math.random() * 900000);
await sendSms(phone, code); // 真的发短信
await db.SmsRecord.create({ phone, code, expireAt: Date.now() + 300000 });
// ✅ 只返回成功状态
res.json({ success: true, message: '验证码已发送' });
});
四、方式二:前端校验绕过
4.1 漏洞描述
验证码校验只在 JS 前端做,后端完全不校验。
4.2 漏洞代码
// 前端代码:看似安全
function verifyCode() {
const input = document.getElementById('smsCode').value;
if (input !== currentSentCode) {
alert('验证码错误');
return false;
}
submitLogin(); // 验证码正确才提交
}
// 后端代码:完全不校验验证码!
app.post('/api/login/sms', async (req, res) => {
const { phone } = req.body; // ❌ 根本不接收 code
const user = await db.User.findOne({ where: { phone } });
if (!user) return res.status(404).json({ error: '用户不存在' });
const token = jwt.sign({ id: user.id }, JWT_SECRET);
res.json({ token }); // 直接登录成功!
});
4.3 攻击
curl -X POST http://target/api/login/sms -H "Content-Type: application/json" -d '{"phone":"13800138000"}'
# 返回 token,直接登录!
4.4 修复
后端必须校验验证码,前端校验只是用户体验优化。
五、方式三:重放攻击
5.1 漏洞描述
验证码用过一次后没有销毁,可以重复使用。
5.2 漏洞代码
// VULNERABLE: 验证后不删除记录
app.post('/api/login/sms', async (req, res) => {
const { phone, code } = req.body;
const record = await db.SmsRecord.findOne({ where: { phone } });
if (!record || record.code !== code) {
return res.status(400).json({ error: '验证码错误' });
}
// ❌ 没有 destroy,验证码可以无限次使用
const token = jwt.sign({ phone }, JWT_SECRET);
res.json({ token });
});
5.3 修复
// FIXED: 使用后立即标记为已用
app.post('/api/login/sms', async (req, res) => {
const { phone, code } = req.body;
// ✅ 原子更新:只有未使用且未过期的才能通过
const [affected] = await db.SmsRecord.update(
{ used: true, usedAt: new Date() },
{
where: {
phone,
code,
used: false, // 未使用
expireAt: { $gt: Date.now() }, // 未过期
},
}
);
if (affected === 0) {
return res.status(400).json({ error: '验证码错误或已使用' });
}
// ✅ 使用 Redis 记录已用验证码,防止并发重放
await redis.set(`sms:used:${phone}:${code}`, '1', 'EX', 86400);
const token = jwt.sign({ phone }, JWT_SECRET);
res.json({ token });
});
六、方式四:爆破 + 时序响应
6.1 漏洞描述
不同错误返回不同响应——"验证码错误" vs "验证码不存在"——攻击者可以据此判断爆破进度。
6.2 示例
POST /api/login/sms
phone=13800138000&code=000000
→ {"error": "验证码错误"} ← 验证码存在但不对
phone=13800138000&code=000001
→ {"error": "验证码错误"} ← 同上
phone=13800138001&code=000000
→ {"error": "请先获取验证码"} ← 该手机号没有验证码记录
6.3 修复:统一响应
// FIXED: 所有错误返回相同的提示
app.post('/api/login/sms', async (req, res) => {
const { phone, code } = req.body;
const record = await db.SmsRecord.findOne({ where: { phone } });
// ✅ 统一错误信息
if (!record || record.code !== code || record.expireAt < Date.now()) {
// 用相同的错误消息,不泄露任何信息
return res.status(400).json({ error: '手机号或验证码错误' });
}
await record.destroy();
res.json({ token: jwt.sign({ phone }, JWT_SECRET) });
});
七、方式五:验证码替换 / 手机号操控
7.1 场景一:注册时替换手机号
// VULNERABLE: 验证码和手机号在不同接口验证,且绑定不严
// 步骤1:用手机号 A 申请验证码
POST /api/sms/send
{"phone": "13800138000"} // 验证码发到 A
// 步骤2:提交注册时,手机号换成 B,但验证码用 A 的
POST /api/register
{"phone": "13900139000", "code": "123456"} // 用 A 的验证码注册 B!
7.2 漏洞代码
// VULNERABLE
app.post('/api/register', async (req, res) => {
const { phone, code, password } = req.body;
// ❌ 只检查 code 是否存在,没检查这个 code 是不是属于这个 phone
const record = await db.SmsRecord.findOne({ where: { code } });
if (!record) return res.status(400).json({ error: '验证码无效' });
// 攻击者可以用 A 的验证码注册 B 的手机号
await db.User.create({ phone, password: hash(password) });
res.json({ success: true });
});
7.3 修复
// FIXED: 验证码必须和手机号同时匹配
app.post('/api/register', async (req, res) => {
const { phone, code, password } = req.body;
// ✅ phone 和 code 一起校验
const record = await db.SmsRecord.findOne({
where: { phone, code, used: false, expireAt: { $gt: Date.now() } },
});
if (!record) return res.status(400).json({ error: '验证码无效' });
await db.User.create({ phone, password: hash(password) });
await record.destroy();
res.json({ success: true });
});
八、方式六:万能验证码(测试后门)
8.1 漏洞描述
开发环境为了方便测试,硬编码了一个万能验证码,上线前忘记移除。
8.2 漏洞代码
// VULNERABLE: 测试代码上线
const MAGIC_CODES = ['888888', '000000', '123456']; // ❌ 忘了删!
app.post('/api/login/sms', async (req, res) => {
const { phone, code } = req.body;
// ❌ 先检查是不是万能码
if (MAGIC_CODES.includes(code)) {
console.warn('[SECURITY] Magic code used!', phone);
// 什么手机号都能登录!
const user = await db.User.findOne({ where: { phone } });
return res.json({ token: jwt.sign({ id: user.id }, JWT_SECRET) });
}
const record = await db.SmsRecord.findOne({ where: { phone } });
if (!record || record.code !== code) {
return res.status(400).json({ error: '验证码错误' });
}
// ...
});
8.3 修复
- 万能码只在开发环境生效(通过环境变量控制)
- CI/CD 上线前扫描硬编码的敏感值
- 定期审计日志,检测异常验证码使用
// FIXED
const MAGIC_CODES = process.env.NODE_ENV === 'development'
? ['888888']
: []; // 生产环境为空
if (MAGIC_CODES.includes(code)) {
// 生产环境永远不会走到这里
console.warn('[DEV ONLY] Magic code used');
}
九、综合防御方案
// 完整的验证码发送 + 校验实现
// 1. 发送
app.post('/api/sms/send', rateLimit, async (req, res) => {
const { phone } = req.body;
// ✅ 频率限制(Redis)
const cooldownKey = `sms:cooldown:${phone}`;
const sentKey = `sms:sent:${phone}`;
const inCooldown = await redis.get(cooldownKey);
if (inCooldown) {
const ttl = await redis.ttl(cooldownKey);
return res.status(429).json({ error: `请 ${ttl} 秒后再试` });
}
const dailyCount = await redis.incr(sentKey);
if (dailyCount === 1) await redis.expire(sentKey, 86400);
if (dailyCount > 10) {
return res.status(429).json({ error: '今日发送次数已达上限' });
}
const code = crypto.randomInt(100000, 999999).toString();
const hashedCode = crypto.createHash('sha256').update(code).digest('hex');
// ✅ 存哈希值而非明文
await db.SmsRecord.create({
phone,
codeHash: hashedCode,
expireAt: new Date(Date.now() + 300000),
});
await redis.set(cooldownKey, '1', 'EX', 60); // 60 秒冷却
await sendSms(phone, code);
res.json({ success: true });
});
// 2. 校验(通用函数,可复用)
async function verifySmsCode(phone, inputCode) {
const record = await db.SmsRecord.findOne({
where: {
phone,
used: false,
expireAt: { $gt: new Date() },
},
order: [['createdAt', 'DESC']],
});
if (!record) return { ok: false, reason: '验证码无效' };
// ✅ 比较哈希
const inputHash = crypto.createHash('sha256').update(inputCode).digest('hex');
if (inputHash !== record.codeHash) {
// ✅ 原子计数失败次数
const failKey = `sms:fail:${phone}:${record.id}`;
const failCount = await redis.incr(failKey);
if (failCount === 1) await redis.expire(failKey, 300);
if (failCount >= 5) {
await redis.set(`sms:lock:${phone}`, '1', 'EX', 1800);
return { ok: false, reason: '验证码已锁定30分钟' };
}
return { ok: false, reason: '验证码错误' };
}
// ✅ 原子标记已使用
await record.update({ used: true, usedAt: new Date() });
return { ok: true };
}
十、总结
验证码绕过的核心思路就两条:不校验 和 校验不严。防御的黄金法则:
- 验证码必须和目标对象绑定(手机号、邮箱),不能脱离使用
- 一次性使用:用后即焚
- 统一错误响应:不给攻击者任何进度提示
- 存哈希不存明文:降低数据库泄露风险
- 频率限制 + 失败锁定:提高爆破成本
- Redis 原子计数:防止竞态绕过锁定机制