Appearance
短信验证码接口被刷、手机被短信轰炸?用 IP 归属地加一道防线(Python 完整实现)
做 App 的朋友对这事不陌生:验证码发送接口上线没几天,凌晨被脚本刷了几万条,短信费烧掉上千块;更恶心的是「短信轰炸」——攻击者拿你的发送接口对某个倒霉手机号狂发验证码,受害者投诉到运营商,你的签名可能直接被封。网上讲防刷的文章不少,但翻来覆去就是「限频 + 图形验证码 + 加黑名单」,没有一篇把 IP 归属地 当成独立防线讲。这篇用 Python 搭一套完整实现:频率层挡脚本,IP 归属地层拦海外和机房,同号多地聚集识别轰炸,一层层叠起来。
一、刷验证码接口的流量有什么特征
先看攻击者怎么刷。三类最常见:
- 脚本直连:同一个 IP 高频调发送接口,一分钟几十次,最简单也最笨,靠频率限制就能挡。
- 代理池 / 云主机轮换:攻击者手上是一批机房 IP,分布在全国甚至海外,每发几条换个出口。单看频率,每个 IP 都不算高频,但「海外 IP 大量出现」「清一色机房出口」这两个特征非常扎眼。
- 短信轰炸:目标不是你的短信费,是折磨某个手机号。攻击者会拿大量 IP 对同一个号码反复触发发送,还可能故意用你接口的漏洞换个马甲继续发。
看出来了吗:频率限制只能挡第一种。第二、第三种攻击者的 IP 是「散」的,单 IP 维度看不出问题,但把 IP 归属地 加进来就有意思了——机房出口、海外出口、同一号码短时间跨多省出现,都是真实用户身上极少发生的特征。
IP9 免费接口 https://ip9.com.cn/get?ip=<ip> 返回国家、省份、城市、运营商,免注册 60 次/分钟/IP;判断机房出口,免费版可以用返回的运营商字段做近似(下面代码里给了启发式),要精确的 ip_type(ISP 家庭 / BUS 企业 / IDC 机房)和 ip_asn 是 VIP 版字段,代码里会标出来。
二、方案:三道闸怎么编排
发送验证码之前,按顺序过三道闸,全部放行才真正调短信服务商:
- 频率闸:同一 IP 一分钟最多发 3 次、同一手机号一小时内最多 5 次(后面这条要业务侧把手机号传进来当 key)。挡脚本直连。
- 归属地闸:查一次发起请求的 IP 归属地——海外 IP 直接拒绝;机房/云厂商出口(
ip_type=IDC或 isp 文本特征)不放行,升级成图形验证码,让真人能过、脚本过不了。 - 聚集闸:同一手机号 30 分钟内出现的归属地省份 ≥3 个,判定为轰炸/接码特征,直接停发。
每道闸的判定都不「一刀切拒绝」,能降级成验证码就降级,原因见第四节。归属地查询结果 30 分钟缓存一次,发码接口本身又有频率限制,免费额度完全够用。
三、Python 实现
一个文件跑通:Flask 起发送接口,requests 查归属地,计数先放内存(生产换 Redis,代码注释里标了)。
python
import time
import threading
import requests
from flask import Flask, request, jsonify
app = Flask(__name__)
lock = threading.Lock()
# 以下三个 dict 生产环境换成 Redis,key 加业务前缀
ip_hits = {} # ip -> 最近发码时间戳列表
mobile_provs = {} # 手机号 -> {省份: 最近时间戳},只做省份维度统计
geo_cache = {} # ip -> (过期时间, 归属地dict)
UA = {"User-Agent": "Mozilla/5.0 (compatible; SmsGuard/1.0)"} # 显式带UA,规避默认请求特征被拦
def get_ip_geo(ip: str) -> dict:
"""查归属地,30分钟缓存。失败返回空dict,调用方降级。"""
now = time.time()
cached = geo_cache.get(ip)
if cached and cached[0] > now:
return cached[1]
try:
r = requests.get("https://ip9.com.cn/get", params={"ip": ip}, headers=UA, timeout=3)
j = r.json()
data = j.get("data", {}) if j.get("ret") == 200 else {}
except Exception:
data = {}
geo_cache[ip] = (now + 1800, data)
return data
def is_cloud_exit(geo: dict) -> bool:
"""机房/云出口判断。VIP版用 ip_type 精确判断(ISP家庭/BUS企业/IDC机房);
免费版退而求其次,用运营商文本特征近似。"""
if geo.get("ip_type") == "IDC": # VIP 版字段
return True
isp = geo.get("isp", "")
return any(k in isp for k in ("阿里云", "腾讯云", "华为云", "AWS", "Google", "Azure", "机房"))
def sms_guard(mobile: str, ip: str) -> dict:
"""返回 {"ok": True} 放行,或 {"ok": False, "action": reject/captcha, "reason": ...}"""
now = time.time()
geo = get_ip_geo(ip) # 网络调用放锁外面,别占着锁等接口
with lock:
# 第一道闸:同 IP 一分钟最多 3 次
ts = ip_hits.setdefault(ip, [])
ts[:] = [t for t in ts if now - t < 60]
if len(ts) >= 3:
return {"ok": False, "action": "reject", "reason": "发送太频繁,请稍后再试"}
ts.append(now)
# 第二道闸:归属地规则
if geo:
cc = geo.get("country_code", "")
if cc and cc != "cn":
return {"ok": False, "action": "reject", "reason": "当前网络暂不支持发送验证码"}
if is_cloud_exit(geo):
return {"ok": False, "action": "captcha", "reason": "请先完成图形验证"}
prov = geo.get("prov", "")
# 第三道闸:同一手机号 30 分钟内跨 ≥3 省 -> 轰炸/接码特征
if prov:
pm = mobile_provs.setdefault(mobile, {})
pm = {p: t for p, t in pm.items() if now - t < 1800}
pm[prov] = now
mobile_provs[mobile] = pm
if len(pm) >= 3:
return {"ok": False, "action": "reject", "reason": "发送异常,请稍后重试"}
return {"ok": True, "action": "send"}
@app.route("/send-code", methods=["POST"])
def send_code():
body = request.get_json(silent=True) or {}
mobile = str(body.get("mobile", ""))
if not mobile:
return jsonify({"ok": False, "reason": "缺少手机号"}), 400
# 取真实IP:Nginx 反代场景配 real_ip 模块后取 remote_addr,
# 或取可信反代注入的 X-Real-IP;不要直信客户端能伪造的 X-Forwarded-For
ip = request.headers.get("X-Real-IP") or request.remote_addr
result = sms_guard(mobile, ip)
if not result["ok"]:
return jsonify(result), 200
# ……到这里才真正调短信服务商发码、落库……
return jsonify({"ok": True, "msg": "验证码已发送"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)几个值得说的点:
- 限频和归属地查询分离:网络请求放在锁外面,否则一个慢请求会把整个接口堵死。归属地查询 3 秒超时 + 失败返回空 dict,空 dict 时只放行不拦(见下)。
- 海外判断用
country_code:返回cn之外的都算海外。注意港澳台和出差的真实用户也会被误伤,见第四节怎么兜。 - 机房判断免费版是启发式:运营商字段出现「阿里云/腾讯云/AWS」这类文本就高度可疑,因为家庭宽带的运营商只会是电信/联通/移动。想精确到
ISP/BUS/IDC三类,上 VIP 版查ip_type。 - 本文只把手机号当计数 key 用,不做任何手机号归属地查询——那是另一类数据,别混。
四、误伤怎么兜、阈值怎么调
- 海外用户和出差的员工:人在国外收不到码会直接投诉。兜底做法:海外 IP 不直接拒,改成「图形验证码 + 提示联系客服」,或者做成可配置——业务只做大陆就拒,有海外用户就降级验证码。港澳台同理。
- 公司/校园共享出口:一个办公室几十号人同一个出口 IP,一分钟 3 次很容易误伤。把
ip_hits的窗口和阈值放宽到「5 分钟 10 次」,或对已知办公出口加白名单。共享出口这个坑,做风控的都懂。 - 先观察再拦截:上线头一周只记日志不拦截,把每个请求的省份、运营商、命中规则打出来,对着真实用户分布调阈值。机房误伤率比想象中高——有些小公司宽带就是走的云主机。
- 兜底顺序:拿不准就放行 + 图形验证码,别直接拒绝。验证码能拦住脚本,又给真人留了路。
总结
短信验证码防刷,频率限制只是第一层。把 IP 归属地加进来后,海外直拒、机房出口升级验证码、同一号码跨省聚集停发——这三条规则能挡住绝大多数代理池和轰炸攻击。用 IP9 免费接口 https://ip9.com.cn/get?ip=<ip> 查归属地,免注册、60 次/分钟/IP,缓存做好完全够用;需要精确机房判断(ip_type/ip_asn)再看官网 https://www.ip9.com.cn 的 VIP 版说明。代码照上面抄,先跑日志模式观察一周再开拦截,基本不会误伤。