一、短信验证码的现状与问题

1.1 为什么短信验证码如此脆弱

短信验证码曾被视为"第二因素认证"的简单方案,但如今已被证明极不安全:

国际移动用户识别码(IMSI)嗅探基站伪基站运营商内部人员SIM Swap 攻击攻击者获取短信
短信本身明文传输
SMS 服务是运营商基础设施的一部分,用户无法自主控制

1.2 NIST 的态度

NIST SP 800-63B 已明确表示不推荐将 SMS 作为唯一或主要的第二因素。 推荐优先使用 TOTP、WebAuthn、FIDO2 等更安全的方案。

二、漏洞一:验证码不校验 / 前端可绕

2.1 漏洞描述

服务端只是生成并发送了验证码,但校验逻辑缺失或只做了简单检查。

2.2 脆弱代码

# 🔴 完全没有校验
@app.route('/api/sms/verify', methods=['POST'])
def verify_code():
    phone = request.json['phone']
    code = request.json['code']

    # 直接重置密码!没有比对验证码
    reset_password(phone, request.json['new_password'])
    return jsonify({'status': 'ok'})

2.3 场景二:前端校验,后端不校验

// 🔴 前端代码只是弹了个框,后端根本没校验
function verifyCode() {
    const code = document.getElementById('code-input').value;
    if (code.length === 6) {
        // 前端随便检查一下格式
        fetch('/api/sms/verify', {
            method: 'POST',
            body: JSON.stringify({ phone: currentPhone, code }),
        });
    }
    // 攻击者直接跳过前端检查,发请求
}

2.4 正确实现

# ✅ 服务端强制校验验证码
import redis

@app.route('/api/sms/verify', methods=['POST'])
def verify_code():
    phone = request.json['phone']
    code = request.json['code']

    # 从 Redis 取出该手机的验证码
    stored_code = redis.get(f'sms_code:{phone}')

    if not stored_code:
        return jsonify({'error': 'no code sent or code expired'}), 400

    # 必须使用 timing-safe 比较防止时序攻击
    if not hmac.compare_digest(code, stored_code):
        # 记录失败次数
        redis.incr(f'sms_attempts:{phone}')
        return jsonify({'error': 'invalid code'}), 400

    # 验证通过后立即删除验证码(一次性使用)
    redis.delete(f'sms_code:{phone}')
    redis.delete(f'sms_attempts:{phone}')

    # 执行后续操作(重置密码等)
    return jsonify({'status': 'ok'})

三、漏洞二:验证码长期有效 + 可重放

3.1 问题

验证码不设过期时间,或者验证通过后不立即删除,导致:

  • 截获的旧验证码仍能使用
  • 同一验证码可被多次使用

3.2 攻击脚本

# 攻击者嗅探到一次短信验证码 code=123456
# 假设服务端不做一次性校验

for i in range(100):
    resp = requests.post('https://api.example.com/api/sms/verify', json={
        'phone': 'target_phone',
        'code': '123456',  # 同一个 code,重复使用
        'action': 'reset_password',
    })
    if resp.status_code == 200:
        print(f"[+] Replay success! Attempt #{i}")

3.3 修复

class SmsCodeService:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.code_ttl = 300  # 5 分钟
        self.max_attempts = 3  # 最多尝试 3 次
        self.lockout_duration = 3600  # 超过次数后锁定 1 小时

    def generate_and_send(self, phone: str, action: str) -> str:
        """生成并发送验证码"""
        # 检查是否被锁定
        if self.redis.exists(f'sms_lockout:{phone}:{action}'):
            raise Exception('too many attempts, please try later')

        code = ''.join(str(random.randint(0, 9)) for _ in range(6))

        # 存储验证码(带 TTL)
        self.redis.setex(
            f'sms_code:{phone}:{action}',
            self.code_ttl,
            code,
        )
        self.redis.delete(f'sms_attempts:{phone}:{action}')

        # 发送短信
        send_sms(phone, code)
        return code

    def verify(self, phone: str, code: str, action: str) -> bool:
        """验证验证码(一次性使用)"""
        attempts_key = f'sms_attempts:{phone}:{action}'
        code_key = f'sms_code:{phone}:{action}'

        # 检查锁定
        if self.redis.exists(f'sms_lockout:{phone}:{action}'):
            return False

        # 检查次数
        attempts = int(self.redis.get(attempts_key) or 0)
        if attempts >= self.max_attempts:
            self.redis.setex(
                f'sms_lockout:{phone}:{action}',
                self.lockout_duration,
                'locked',
            )
            self.redis.delete(code_key)
            return False

        stored_code = self.redis.get(code_key)
        if not stored_code:
            return False

        # 校验
        if hmac.compare_digest(str(code), str(stored_code)):
            # ✅ 关键:验证成功后立即删除!
            self.redis.delete(code_key)
            self.redis.delete(attempts_key)
            return True

        # 失败,记录次数
        self.redis.incr(attempts_key)
        self.redis.expire(attempts_key, self.code_ttl)
        return False

四、漏洞三:并发竞争(Race Condition)

4.1 漏洞描述

短信验证码验证和删除之间存在时间窗口,攻击者可以并发发送多个请求,绕过"一次性使用"的限制。

4.2 攻击场景

发送 10 个并发请求,所有请求几乎同时到达:
Request 1 → 校验 code=123456 → 有效 → 执行重置密码 ✅
Request 2 → 校验 code=123456 → 还没被删!→ 也有效 ✅
...
Request 10 → 可能也有效!

导致:攻击者用一个验证码触发了 10 次密码重置 / 10 次转账 / ...

4.3 修复:使用 Redis Lua 脚本原子操作

VERIFY_AND_DELETE_LUA = """
local key = KEYS[1]
local expected = ARGV[1]

-- GET + DEL 原子操作
local stored = redis.call('GET', key)
if stored == false then
    return 0
end

if stored == expected then
    redis.call('DEL', key)
    return 1
end

return 0
"""

def verify_code_atomic(phone: str, code: str, action: str) -> bool:
    code_key = f'sms_code:{phone}:{action}'
    result = redis.eval(VERIFY_AND_DELETE_LUA, 1, code_key, code)
    return result == 1

五、漏洞四:短信接口无速率限制 → 短信轰炸

5.1 问题

攻击者可以无限触发短信发送,造成:

  • 目标用户被大量短信轰炸
  • 服务平台被消耗大量短信额度(成本损失)
  • 手机号码被枚举(发送时返回不同错误)

5.2 脆弱实现

# 🔴 完全没有发送频率限制
@app.route('/api/sms/send', methods=['POST'])
def send_code():
    phone = request.json['phone']
    code = generate_code()
    send_sms(phone, code)
    return jsonify({'status': 'ok'})

5.3 防护

class SmsRateLimiter:
    def __init__(self, redis_client):
        self.redis = redis_client
        self.config = {
            'min_interval': 60,              # 60 秒内只能发一次
            'per_phone_per_hour': 5,         # 每手机每小时最多 5 条
            'per_phone_per_day': 20,         # 每手机每天最多 20 条
            'per_ip_per_hour': 30,           # 每 IP 每小时最多 30 条
            'requires_captcha_after': 3,     # 第 3 次触发验证码
        }

    def can_send(self, phone: str, ip: str) -> tuple[bool, str]:
        """检查是否可以发送,返回 (是否允许, 原因)"""
        cfg = self.config

        # 1. 最小间隔
        last_sent = self.redis.get(f'sms_last_sent:{phone}')
        if last_sent and time.time() - float(last_sent) < cfg['min_interval']:
            remaining = cfg['min_interval'] - int(time.time() - float(last_sent))
            return False, f'please wait {remaining}s before requesting again'

        # 2. 每手机/小时
        hourly = self.redis.incr(f'sms_count:{phone}:hour')
        if hourly == 1:
            self.redis.expire(f'sms_count:{phone}:hour', 3600)
        if hourly > cfg['per_phone_per_hour']:
            return False, 'too many requests per hour'

        # 3. 每手机/天
        daily = self.redis.incr(f'sms_count:{phone}:day')
        if daily == 1:
            self.redis.expire(f'sms_count:{phone}:day', 86400)
        if daily > cfg['per_phone_per_day']:
            return False, 'too many requests per day'

        # 4. 每 IP/小时
        ip_hourly = self.redis.incr(f'sms_ip_count:{ip}:hour')
        if ip_hourly == 1:
            self.redis.expire(f'sms_ip_count:{ip}:hour', 3600)
        if ip_hourly > cfg['per_ip_per_hour']:
            return False, 'ip rate limit exceeded'

        return True, ''

    def record_sent(self, phone: str):
        self.redis.set(f'sms_last_sent:{phone}', str(time.time()))

    def requires_captcha(self, phone: str) -> bool:
        """是否需要验证码"""
        hourly = int(self.redis.get(f'sms_count:{phone}:hour') or 0)
        return hourly >= self.config['requires_captcha_after']

六、漏洞五:手机号枚举

6.1 场景

不同手机号发送验证码时返回不同的错误信息:

GET /api/sms/send?phone=13800000000 → 200 OK(发送了)
GET /api/sms/send?phone=13800000001 → 404 Not Found(该号不存在)
GET /api/sms/send?phone=13800000002 → 400 Invalid Phone Format

攻击者可以批量发送请求,枚举哪些手机号注册过该服务。

6.2 扫描脚本

import requests

def enumerate_phones(prefix: str = '138', range_end: int = 99999999):
    """枚举某号段的已注册手机号"""
    found = []

    for suffix in range(range_end):
        phone = f"{prefix}{suffix:08d}"

        resp = requests.post('https://api.example.com/api/sms/send',
            json={'phone': phone, 'action': 'login'})

        if resp.status_code == 200:
            found.append(phone)
            print(f"[+] Phone exists: {phone}")

        elif resp.status_code == 404:
            print(f"[-] Phone {phone} not registered")

        time.sleep(0.2)  # 限速

    return found

6.3 统一响应修复

@app.route('/api/sms/send', methods=['POST'])
def send_code():
    phone = request.json['phone']
    action = request.json.get('action', 'login')

    # 🔑 无论手机号是否存在,都返回相同的成功响应
    try:
        user = db.get_user_by_phone(phone)

        if user:
            # 只有存在的用户才真正发送
            code = generate_code()
            redis.setex(f'sms_code:{phone}:{action}', 300, code)
            send_sms(phone, code)

        # 🔑 关键:不存在的号码也返回成功,不暴露存在性
        # 但内部做了限速检测(防止轰炸不存在的号码)
        update_rate_limit(phone, request.remote_addr)

    except Exception:
        # 吞掉所有异常,避免泄露
        pass

    # 统一返回
    return jsonify({
        'status': 'ok',
        'message': 'if this number is registered, a code has been sent',
    })

七、漏洞六:短信嗅探

7.1 攻击原理

通过 IMSI 嗅探器/伪基站监听目标手机的短信:

攻击者设备(伪基站)
  │
  │ 1. 发送更强信号,迫使受害者手机连接
  │ 2. 拦截发往受害者手机号的所有短信
  │ 3. 转发给受害者不影响使用(不被察觉)
  │
  ▼
受害者手机 ←── 正常接收短信(但攻击者也拿到了)

7.2 防护

短信嗅探几乎无法在用户端防护。应用层面可以:

  1. 将短信作为"恢复手段"而非"主要认证手段"
  2. 结合设备指纹 + IP 位置校验(即使验证码被截获,攻击者环境不匹配也无法通过)
  3. 对敏感操作要求二次确认(如转账时再发送一条确认码)
  4. 使用推送通知作为备用验证方式(App Push Notification)

八、漏洞七:SIM Swap 攻击

8.1 攻击流程

1. 攻击者收集目标的 PII(姓名、身份证号、手机号)
2. 打电话给运营商客服,冒充目标,理由如:手机丢了、SIM 损坏
3. 运营商给攻击者补办一张 SIM 卡
4. 攻击者控制了目标的手机号,可以接收所有短信
5. 重置所有绑定该手机号的账号密码
6. 目标手机号失效,无法收到任何提醒

8.2 防护措施

# 1. 在账号中记录主要认证方式
PRIMARY_AUTH_METHOD = 'totp'  # 优先使用 TOTP

# 2. 当使用短信验证码登录时,标记为"低置信度"
@app.route('/api/login/sms', methods=['POST'])
def login_by_sms():
    phone = request.json['phone']
    code = request.json['code']

    if not verify_code(phone, code, 'login'):
        return jsonify({'error': 'invalid code'}), 400

    user = db.get_user_by_phone(phone)

    # 🔑 关键:如果用户设置了其他 MFA,短信登录后强制二次验证
    if user.has_totp_enabled:
        session['sms_authenticated_user'] = user.id
        session['auth_confidence'] = 'low'
        return jsonify({'require_additional_verification': 'totp'}), 200

    return complete_login(user, auth_level='sms')

# 3. 对"低置信度"会话限制敏感操作
def require_high_confidence_auth(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        if session.get('auth_confidence') != 'high':
            return jsonify({'error': 'this action requires higher authentication'}), 403
        return f(*args, **kwargs)
    return wrapper

@app.post('/api/transfer')
@require_high_confidence_auth  # 转账必须高置信度认证
def transfer():
    # ...

九、漏洞八:验证码发送渠道欺诈

9.1 场景

攻击者操纵运营商短信接口,将目标的验证码短信转发到自己号码。

9.2 防护

  • 使用短信签名(防止钓鱼短信,同时也在一定程度上防转发欺诈)
  • 使用推送通知(App Push)作为备用验证码渠道
  • 使用 SS7 协议安全加固(运营商层面)

十、漏洞九:图形验证码缺失 → 短信轰炸

10.1 防护:分级图形验证码触发

@app.route('/api/sms/send', methods=['POST'])
def send_code():
    phone = request.json['phone']
    captcha_token = request.json.get('captcha_token')

    # 根据该手机的请求频率判断是否需要验证码
    sms_service = SmsRateLimiter(redis)

    if sms_service.requires_captcha(phone):
        if not captcha_token or not verify_captcha(captcha_token):
            return jsonify({'error': 'captcha required'}), 400

    can, reason = sms_service.can_send(phone, request.remote_addr)
    if not can:
        return jsonify({'error': reason}), 429

    sms_service.generate_and_send(phone, 'login')
    sms_service.record_sent(phone)

    return jsonify({'status': 'ok'})

十一、漏洞十:验证码明文存储 / 日志泄露

11.1 问题

# 🔴 把验证码明文打印到日志
logger.info(f"Sending code {code} to {phone}")

# 🔴 把验证码存入数据库(即使是临时表也不行)
db.execute("INSERT INTO sms_log VALUES (?, ?, ?)", (phone, code, datetime.now()))

11.2 修复

# ✅ 日志脱敏
def mask_phone(phone: str) -> str:
    if len(phone) >= 11:
        return phone[:3] + '****' + phone[-4:]
    return '***'

# 日志中永远不要出现明文验证码
logger.info(f"Sending SMS to {mask_phone(phone)}, request_id={request_id}")

# 用 Redis 存储(带自动过期,且不落盘日志)
redis.setex(f'sms_code:{phone}', 300, code)
# 🔑 code 只是在内存中存在 5 分钟,不会被持久化

十二、更安全的替代方案

12.1 Authy / TOTP

✅ 与手机网络无关(不需要蜂窝信号)
✅ 验证码由用户设备本地生成(无法被嗅探)
✅ 时间同步机制保证双方匹配
❌ 需要用户安装 App

12.2 WebAuthn / FIDO2

✅ 硬件密钥(如 YubiKey),无法被窃取
✅ 抗钓鱼(每次挑战不同)
✅ 无密码(Passwordless)
❌ 硬件成本
❌ 旧浏览器可能不支持

12.3 应用内推送通知

# 推送通知作为验证码替代方案
def send_push_approval(user_id: str) -> bool:
    """发送推送通知请求用户确认登录"""
    push_payload = {
        'title': 'Login Request',
        'body': f'Tap to approve login from {request.remote_addr}',
        'data': {
            'login_request_id': request_id,
            'expires': int(time.time()) + 60,
        },
    }

    fcm.send(user.device_token, push_payload)
    return True

# 服务端等待用户确认
@app.get('/api/login/push-await/<request_id>')
def await_push_approval(request_id: str):
    # 轮询 Redis 中是否有确认记录
    result = redis.get(f'push_approval:{request_id}')
    if result:
        return jsonify({'status': 'approved'})
    return jsonify({'status': 'pending'})

十三、完整的短信验证码安全实现

class SmsAuthService:
    """完整的短信验证码服务"""

    CODE_LENGTH = 6
    CODE_TTL = 300          # 5 分钟过期
    MAX_ATTEMPTS = 3        # 最多 3 次错误
    LOCKOUT_TTL = 3600      # 错误次数超限后锁定 1 小时
    SEND_MIN_INTERVAL = 60  # 发送最小间隔 60 秒

    def __init__(self, redis_client, sms_provider):
        self.redis = redis_client
        self.sms = sms_provider

    def _code_key(self, phone: str, action: str) -> str:
        return f'sms:code:{action}:{phone}'

    def _attempts_key(self, phone: str, action: str) -> str:
        return f'sms:attempts:{action}:{phone}'

    def _lockout_key(self, phone: str, action: str) -> str:
        return f'sms:lockout:{action}:{phone}'

    def can_send(self, phone: str) -> tuple[bool, str]:
        # 检查锁定
        if self.redis.exists(self._lockout_key(phone, 'login')):
            ttl = self.redis.ttl(self._lockout_key(phone, 'login'))
            return False, f'account locked for {ttl}s'

        # 检查发送间隔
        last_sent = self.redis.get(f'sms:last_sent:{phone}')
        if last_sent and time.time() - float(last_sent) < self.SEND_MIN_INTERVAL:
            return False, 'please wait before requesting another code'

        return True, ''

    def send(self, phone: str, action: str = 'login') -> tuple[bool, str]:
        allowed, reason = self.can_send(phone)
        if not allowed:
            return False, reason

        code = ''.join(str(random.randint(0, 9)) for _ in range(self.CODE_LENGTH))

        # Lua 脚本原子设置
        self.redis.setex(self._code_key(phone, action), self.CODE_TTL, code)
        self.redis.set(f'sms:last_sent:{phone}', str(time.time()))

        # 发送短信(生产中应异步)
        try:
            self.sms.send(phone, f'[{APP_NAME}] Your {action} code is {code}. Valid for 5 minutes.')
        except Exception as e:
            self.redis.delete(self._code_key(phone, action))
            return False, f'sms send failed: {e}'

        # 注意:不要返回验证码!
        return True, 'code sent'

    def verify(self, phone: str, code: str, action: str = 'login') -> tuple[bool, str]:
        if self.redis.exists(self._lockout_key(phone, action)):
            return False, 'account locked'

        # 原子校验 + 删除
        lua = """
        local code = ARGV[1]
        local key = KEYS[1]
        local stored = redis.call('GET', key)
        if stored == false then return 'expired' end
        if stored == code then
            redis.call('DEL', key)
            return 'ok'
        end
        return 'invalid'
        """
        result = self.redis.eval(lua, 1, self._code_key(phone, action), code)

        if result == 'ok':
            self.redis.delete(self._attempts_key(phone, action))
            return True, 'verified'

        if result == 'expired':
            return False, 'code expired'

        if result == 'invalid':
            # 记录错误次数
            attempts = self.redis.incr(self._attempts_key(phone, action))
            self.redis.expire(self._attempts_key(phone, action), self.CODE_TTL)

            if attempts >= self.MAX_ATTEMPTS:
                self.redis.setex(self._lockout_key(phone, action), self.LOCKOUT_TTL, 'locked')
                self.redis.delete(self._code_key(phone, action))
                return False, 'too many attempts, locked for 1 hour'

            remaining = self.MAX_ATTEMPTS - attempts
            return False, f'invalid code, {remaining} attempts remaining'

        return False, 'unknown error'

十四、审计清单

  • 验证码是否服务端校验(不是只做前端校验)
  • 验证码是否一次性使用(验证成功后立即删除)
  • 是否有过期时间(建议 5 分钟内)
  • 是否用 Redis Lua 脚本做原子校验+删除
  • 是否有发送频率限制(每手机、每 IP 维度)
  • 是否有尝试次数限制(验证码输错上限)
  • 是否有图形验证码分级触发(防短信轰炸)
  • 手机号枚举漏洞是否修复(统一响应)
  • 日志中是否脱敏了手机号和验证码
  • 是否提供了更安全的替代方案(TOTP / WebAuthn / 推送)

十五、总结

短信验证码的安全问题本质上是传输层不安全 + 依赖运营商基础设施。应用层面能做的是让短信成为"足够好"的方案而非"完美"的方案:

  1. 严格的速率限制 + 尝试次数限制
  2. 原子性的一次性校验(防并发)
  3. 统一响应(防枚举)
  4. 分级验证码触发(防轰炸)
  5. 结合其他信号(设备指纹、IP 位置)提升置信度
  6. 提供更安全的替代方案(TOTP、WebAuthn、Push)

十六、参考资料

  • NIST SP 800-63B: Digital Identity Guidelines
  • PortSwigger 2FA Bypass Cheat Sheet
  • OWASP Authentication Cheat Sheet
  • SIM Swap Attack: Krebs on Security
  • 3GPP TR 33.809: IMS Ciphering
  • Auth0 SMS OTP Security Analysis