一、短信验证码的现状与问题
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 防护
短信嗅探几乎无法在用户端防护。应用层面可以:
- 将短信作为"恢复手段"而非"主要认证手段"
- 结合设备指纹 + IP 位置校验(即使验证码被截获,攻击者环境不匹配也无法通过)
- 对敏感操作要求二次确认(如转账时再发送一条确认码)
- 使用推送通知作为备用验证方式(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 / 推送)
十五、总结
短信验证码的安全问题本质上是传输层不安全 + 依赖运营商基础设施。应用层面能做的是让短信成为"足够好"的方案而非"完美"的方案:
- 严格的速率限制 + 尝试次数限制
- 原子性的一次性校验(防并发)
- 统一响应(防枚举)
- 分级验证码触发(防轰炸)
- 结合其他信号(设备指纹、IP 位置)提升置信度
- 提供更安全的替代方案(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