验证码绕过大全

验证码(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 };
}

十、总结

验证码绕过的核心思路就两条:不校验校验不严。防御的黄金法则:

  1. 验证码必须和目标对象绑定(手机号、邮箱),不能脱离使用
  2. 一次性使用:用后即焚
  3. 统一错误响应:不给攻击者任何进度提示
  4. 存哈希不存明文:降低数据库泄露风险
  5. 频率限制 + 失败锁定:提高爆破成本
  6. Redis 原子计数:防止竞态绕过锁定机制