nginx验证码接口被打爆的实战与防护记录
最近线上遇到了一次比较“烧钱”的事故:
短信验证码接口因为仅在前端页面做了60秒限制,后端接口调用没有限制,也没有人机验证,被持续 CC 攻击,高峰期每小时请求量达到 10 万次,直接带来了大量短信费用。
这篇文章是这次排查与加固的完整记录,主要包括:
- 事故背景与现状
- 基于 NGINX + OpenResty 的防护思路
- 实战配置:IP 国别拦截 + CC 限流
- 线上效果与后续优化方向
希望对同样有验证码业务、却还没做完备防护的同学有参考价值。
事故背景
我们的验证码接口后端没有做限制,只要请求接口就会直接下发短信。
某天开始,验证接口被恶意持续访问:
-
高峰期:每小时 10 万次请求
- 全部是真实下发短信请求,直接产生了可观的短信费用
-
日志分析发现:有大量境外 IP 发起请求,同时 User-Agent 几乎没有特征
初步目标:
在不改动后端业务逻辑的前提下,优先控制成本、阻断恶意请求。
最终采取的核心策略:
- 同一源 IP 每分钟只能请求一次验证码接口
(CC 防护)
- 禁止海外 IP 请求验证码接口
(只允许中国大陆 IP)
技术选型上,我们选择了在已有的 OpenResty(NGINX + Lua) 层实现防护,优点是:
- 无需更改后端应用代码
- 性能开销可控
- 灵活度高,逻辑可随时调整
安装依赖环境
OpenResty Lua 模块(MaxMind)
用于在 NGINX 层做 IP → 国家 的判断。
yum install -y perl-Digest-MD5
cd /usr/local/openresty/bin
# 搜索 maxminddb 相关模块
./opm search maxminddb
# 安装 lua-resty-maxminddb
./opm get anjia0532/lua-resty-maxminddb
# 查看已安装模块
./opm list
安装 MaxMind 库
yum install -y libmaxminddb libmaxminddb-devel
# 确认动态库位置
find /usr -name"libmaxminddb.so*"2>/dev/null
# 配置动态库搜索路径
echo "/usr/lib64" > /etc/ld.so.conf.d/maxminddb.conf
ldconfig
ldconfig -p | grep libmaxminddb
完成后,重启 NGINX / OpenResty:
nginx -s stop
nginx
IP 地理库下载
使用 MaxMind 的 GeoLite2 国家库:
# 下载地址(示意)
https://github.com/8bitsaver/maxmind-geoip/releases
# 放置到服务器指定目录
/usr/local/share/GeoLite2-Country.mmdb
后面的 Lua 脚本会直接使用这个 mmdb 文件做 IP → 国家映射。
NGINX / OpenResty 配置
核心思路:
- 在 init_worker_by_lua_block 中加载 MaxMind 数据库,初始化允许访问的国家(默认只允许 CN)
- 使用 lua_shared_dict 存放 CC 限流计数
- 在验证码接口 /api/verify_code 上挂载 access_by_lua_file 做前置拦截
nginx.conf 关键配置
# 文件路径:/usr/local/openresty/nginx/conf/nginx.conf
http {
# Lua 模块搜索路径
lua_package_path "/usr/local/openresty/lualib/?.lua;/usr/local/openresty/nginx/lua/?.lua;;";
# 初始化 IP 地理信息 & 允许国家
init_worker_by_lua_block {
local maxmind = require "resty.maxminddb"
-- 加载 MaxMind 国家库
assert(maxmind.init("/usr/local/share/GeoLite2-Country.mmdb"))
-- 定义允许访问验证码接口的国家(默认只允许中国)
package.loaded.geo_allow = { CN = true }
}
# 共享字典:用于 CC 限流计数
lua_shared_dict cc_limit 20m;
upstream backend {
server 0.0.0.0;
balancer_by_lua_file /usr/local/openresty/nginx/lua/balancer.lua;
}
# 访问日志
access_log /usr/local/openresty/nginx/logs/access.log main;
access_log /usr/local/openresty/nginx/logs/prom_access.log prometheus;
server {
listen 80;
server_name localhost;
default_type text/html;
# 示例:从 cookie 里取 company_id(业务相关,可忽略)
if ($http_cookie ~* "company_id=(.*) (?:;|$)") {
set$cookie_company_id$1;
}
# 验证码接口
location = /api/verify_code {
error_log /usr/local/openresty/nginx/logs/verify_code_error.log info;
# 访问阶段:做国家拦截 + CC 限流
access_by_lua_file /usr/local/openresty/nginx/lua/verify-code-access.lua;
# 处理响应头, 记录日志
header_filter_by_lua_file /usr/local/openresty/nginx/lua/header-filter.lua;
proxy_pass http://backend;
default_type 'text/html';
proxy_buffers 8 256k;
proxy_buffer_size 256k;
proxy_ignore_client_abort on;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-Proto https;
}
}
}
Lua 脚本:IP 国家拦截 + CC 限流
这个脚本做了三件事:
- 从 X-Forwarded-For 或 remote_addr 获取真实客户端 IP
- 使用 MaxMind 判断 IP 所属国家,非允许国家直接 403
- 基于 IP + URI 做 CC 限流:默认 同一 IP 对同一接口,60 秒内最多 1 次
# 文件路径:/usr/local/openresty/nginx/lua/verify-code-access.lua
-- /usr/local/openresty/nginx/lua/verify-code-access.lua
local maxmind = require"resty.maxminddb"
local cjson = require"cjson.safe"
-- 从 init_worker_by_lua_block 传过来的允许国家(如果没传,就默认只允许 CN)
local ALLOWED_COUNTRY = package.loaded.geo_allow or { CN = true }
-- 共享字典,用于 CC 限流
local cc_dict = ngx.shared.cc_limit
----------------------------------------------------------------
-- 从 X-Forwarded-For 拿真实客户端 IP(只取第一个)
----------------------------------------------------------------
localfunction client_ip_from_xff()
local xff = ngx.var.http_x_forwarded_for
ifnot xff or xff == ""then
returnnil
end
-- 取第一个 IP
local ip = xff:match("^%s*([^,%s]+)")
if ip == "unknown"or ip == nilor ip == ""then
returnnil
end
return ip
end
----------------------------------------------------------------
-- CC 限流:同一 IP 对当前接口,在 period 秒内最多 max_count 次
-- 这里我们默认:max_count = 1, period = 60(即 1 分钟一次)
----------------------------------------------------------------
localfunction check_cc_limit(max_count, period)
max_count = max_count or1 -- 默认 1 次
period = period or60 -- 默认 60 秒
-- 使用与国家校验一致的 IP 获取逻辑
local client_ip = client_ip_from_xff() or ngx.var.remote_addr
ifnot client_ip then
-- 极端情况:连 remote_addr 都拿不到,这里选择直接拦截
ngx.log(ngx.ERR, "[VERIFY-CODE][CC] 无法获取客户端 IP,直接拦截(429)")
ngx.status = ngx.HTTP_TOO_MANY_REQUESTS
ngx.header["Content-Type"] = "application/json; charset=utf-8"
ngx.say('{"code":429,"message":"请求过于频繁,请稍后再试"}')
return ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
end
-- 同一 IP + 同一 URI 做限流
local uri = ngx.var.uri or""
local key = client_ip .. ":" .. uri
-- 先尝试自增计数
local new_val, err = cc_dict:incr(key, 1)
ifnot new_val then
-- key 不存在,说明是第一次访问:add 初始值 1,并设置过期时间
local ok, add_err = cc_dict:add(key, 1, period)
ifnot ok and add_err ~= "exists"then
-- 字典异常,为了不误杀请求,选择放行并打错误日志
ngx.log(ngx.ERR,
"[VERIFY-CODE][CC] failed to add cc key: ", key,
" | err: ", add_err or"nil"
)
return
end
-- 第一次访问,允许
ngx.log(ngx.INFO,
"[VERIFY-CODE][CC] 首次访问,放行",
" | IP: ", client_ip,
" | URI: ", uri
)
return
end
-- new_val >= 2,判断是否超过限制
if new_val > max_count then
-- 超限,返回 429
ngx.log(ngx.WARN,
"[VERIFY-CODE][CC] 触发限流,拦截(429)",
" | IP: ", client_ip,
" | URI: ", uri,
" | count_in_period: ", new_val,
" | period: ", period, "s"
)
ngx.status = ngx.HTTP_TOO_MANY_REQUESTS
ngx.header["Content-Type"] = "application/json; charset=utf-8"
ngx.say('{"code":429,"message":"请求过于频繁,请稍后再试"}')
return ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
end
-- 未超过限制,放行
ngx.log(ngx.INFO,
"[VERIFY-CODE][CC] 未超过限流阈值,放行",
" | IP: ", client_ip,
" | URI: ", uri,
" | current_count: ", new_val,
" | period: ", period, "s"
)
end
----------------------------------------------------------------
-- 只允许指定国家(默认只允许中国 CN)
----------------------------------------------------------------
localfunction enforce_cn_only()
-- 1. 获取最终用于查询的 IP
local client_ip = client_ip_from_xff() or ngx.var.remote_addr
ifnot client_ip then
-- 极端情况:连 remote_addr 都拿不到,直接 403
ngx.log(ngx.ERR, "[VERIFY-CODE] 无法获取客户端 IP,直接拦截(403)")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 2. MaxMind 查询
local res, err = maxmind.lookup(client_ip)
ifnot res then
ngx.log(ngx.ERR,
"[VERIFY-CODE] MaxMind 查询失败,IP: ", client_ip,
" | err: ", err or"nil",
" | 决策: 出于安全考虑,拦截(403)"
)
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 3. 提取国家代码
local iso_code = res.country and res.country.iso_code
ifnot iso_code then
ngx.log(ngx.ERR,
"[VERIFY-CODE] MaxMind 查询成功但没有国家代码,IP: ", client_ip,
" | raw_res: ", cjson.encode(res)
)
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 4. 判断是否允许国家
ifnot ALLOWED_COUNTRY[iso_code] then
ngx.log(ngx.WARN,
"[VERIFY-CODE] 拦截非允许国家请求(403)",
" | IP: ", client_ip,
" | 国家: ", iso_code,
" | URI: ", ngx.var.request_uri or"nil",
" | User-Agent: ", ngx.var.http_user_agent or"nil"
)
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- 允许的国家:打印一次放行日志
ngx.log(ngx.INFO,
"[VERIFY-CODE] 允许访问(放行)",
" | IP: ", client_ip,
" | 国家: ", iso_code,
" | URI: ", ngx.var.request_uri or"nil",
" | User-Agent: ", ngx.var.http_user_agent or"nil"
)
end
----------------------------------------------------------------
-- 执行顺序:
-- 1. 先做国家限制
-- 2. 再做 CC 限流(同一 IP + 接口,60s 内最多 1 次)
-- 3. 最后执行原 access-flow.lua 的逻辑
----------------------------------------------------------------
-- 1. 国家限制
enforce_cn_only()
-- 2. CC 限流:同一 IP 一分钟只能请求这个接口一次
-- 想放宽为“60 秒内最多 3 次”,可以改为 check_cc_limit(3, 60)
check_cc_limit(1, 60)
-- 能走到这里说明已通过国家校验 + CC 限流
dofile("/usr/local/openresty/nginx/lua/access-flow.lua")
上线效果与补充说明
防护效果
上线后,几乎立刻可以观察到:
- 验证码接口总请求量明显下降
- 海外 IP 请求基本全部被挡在外面
- 短信下发量恢复正常,短信费用回到可控范围
日志中可以看到大量的 403 / 429 记录,对应的都是被拦截的访问。
一些实践经验
IP 来源处理
- 如果前面有多层代理 / CDN,务必要确认 X-Forwarded-For 的真实来源,否则可能会被伪造。
- 在可信任的边界(如自家 SLB / 网关)之后再做这类拦截,会更安全。
限流粒度
- 当前策略是:IP + URI 维度限制 60 秒 1 次。
-
若希望对不同业务接口分别配置限流策略,可以:
- 按 URI 做不同规则
- 按账号 / 手机号做额外限流(需要业务层配合)
国家拦截策略
- 目前采用“黑名单式”的极简策略:只允许 CN。
- 如果业务本身有海外用户,需要改为“白名单国家列表”或更细粒度的风控策略。
报警与监控
-
建议对以下指标做监控:
- 验证码接口总 QPS
- 403/429 返回码占比
-
短信发送量、成本
一旦异常波动,可以尽早发现类似攻击行为。
后续优化方向
当前这套方案已经解决了“短时间内短信费用暴涨”的问题,但从安全和体验角度,还有不少优化空间:
-
当某 IP 被 CC 命中接入人机验证(接第三方验证码服务、HTML验证码表单)
- 不返回 JSON,而是 302 跳转到一个 NGINX 提供的 /captcha_page。
- /captcha_page 是一个由 NGINX 返回的 HTML,里面嵌一个简单的验证码表单或第三方人机组件。
- 用户通过人机后,再返回一个“验证成功,点这里继续操作”的页面。
-
按手机号维度做限流(如同一手机号 5 分钟内最多获取 N 次验证码)
-
引入更完善的风控系统(设备指纹、IP 信用、请求行为特征等)
更多推荐



所有评论(0)