最近线上遇到了一次比较“烧钱”的事故:  
短信验证码接口因为仅在前端页面做了60秒限制,后端接口调用没有限制,也没有人机验证,被持续 CC 攻击,高峰期每小时请求量达到 10 万次,直接带来了大量短信费用。

这篇文章是这次排查与加固的完整记录,主要包括:

  • 事故背景与现状
  • 基于 NGINX + OpenResty 的防护思路
  • 实战配置:IP 国别拦截 + CC 限流
  • 线上效果与后续优化方向

希望对同样有验证码业务、却还没做完备防护的同学有参考价值。


事故背景

我们的验证码接口后端没有做限制,只要请求接口就会直接下发短信。

某天开始,验证接口被恶意持续访问:

  • 高峰期:每小时 10 万次请求

  • 全部是真实下发短信请求,直接产生了可观的短信费用
  • 日志分析发现:有大量境外 IP 发起请求,同时 User-Agent 几乎没有特征

初步目标:  
在不改动后端业务逻辑的前提下,优先控制成本、阻断恶意请求。  
最终采取的核心策略:

  1. 同一源 IP 每分钟只能请求一次验证码接口

    (CC 防护)

  2. 禁止海外 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 信用、请求行为特征等)

更多推荐