Nginx Lua脚本实现WAF防火墙实战指南
发布日期: 2026/08/19 阅读总量: 1

真实场景:凌晨3点被拖库的教训

我的一个电商项目,PHP接口裸奔在Nginx后面。某天凌晨3点,监控告警突然炸了:数据库连接数飙到500+,CPU 100%。赶紧登录服务器,发现MySQL binlog里多了一批异常查询:

SELECT id,account,password FROM users WHERE id = 1 UNION SELECT 1,2,group_concat(table_name) FROM information_schema.tables -- 

有人在用SQLMap扫我的接口。因为没有WAF,Nginx把恶意请求原封不动转给PHP-FPM,PHP再把SQL拼给MySQL。等我看完日志,一个备份表已经被拖走。事后复盘:如果Nginx层能挡一下,后面的事根本不会发生。

我花了两个星期,用OpenResty的Lua脚本实现了一套WAF,部署之后同类攻击再没绕过。这篇文章把完整思路和代码写出来,照着抄,能少踩很多坑。

方案选型:ModSecurity vs Lua脚本

给Nginx加WAF,主流两条路:ModSecurity + CRS(老牌WAF)和OpenResty + Lua(自研规则)。我两个都试过,直接说结论。

对比项 ModSecurity 3.0.8 + CRS 4.0 OpenResty 1.25.3.1 + Lua 5.1
规则数 900+(CRS默认) 自研30条核心规则
QPS(ab -n 10000 -c 200) 从5200降到3600(-31%) 从5200降到4990(-4%)
CPU占用(压测期间) 350% → 620%(4核) 350% → 420%
内存占用 约150MB(含CRS规则) 约30MB(Lua VM)
误报率(1000条正常请求) 2.3% 0.9%
上线时间 开箱即用但调规则花时间 自己写规则,2天核心功能

我选Lua,不是因为它更安全——CRS规则确实全面。但ModSecurity在Docker模式下需要额外模块,每次Nginx升级都要重新编译,而且它的正则引擎在8核以下机器上性能衰减明显。Lua可以在不影响Nginx主进程的前提下,把逻辑放在请求访问阶段,灵活性和性能都够用。

WAF架构设计

OpenResty是带Lua支持的Nginx。我在http块里挂一个access_by_lua_file,所有请求先过Lua脚本再进后端。脚本要干四件事:

  • IP黑名单:内存/Redis里存恶意IP,命中直接返回403
  • User-Agent过滤:拦臭名昭著的扫描器UA
  • SQL注入/XSS检测:用正则匹配URL和POST参数
  • CC攻击限流:单IP每秒请求数超过阈值就临时封禁

这四件事按顺序执行,只要一步命中就终止请求。Lua脚本里没有阻塞操作,全走Nginx的shared dict或lua-resty-limit-traffic,不会卡事件循环。

核心代码实现

1. Nginx配置:挂载Lua WAF

我用的是OpenResty官方容器:openresty/openresty:1.25.3.1-focal。配置文件如下:

http {
    # 共享字典,存IP黑名单和限流计数器
    lua_shared_dict waf_blacklist 10m;
    lua_shared_dict waf_cc_zone 50m;

    # Lua脚本路径
    lua_package_path "/opt/waf/?/init.lua;/opt/waf/?.lua;;";

    server {
        listen 80;
        server_name shop.example.com;

        access_by_lua_file /opt/waf/waf.lua;

        location / {
            proxy_pass http://php_backend;
        }
    }
}

2. 主入口:waf.lua

-- /opt/waf/waf.lua
local blacklist = ngx.shared.waf_blacklist
local cc_zone   = ngx.shared.waf_cc_zone

-- 1. IP黑名单检查
local client_ip = ngx.var.remote_addr
if blacklist:get(client_ip) then
    ngx.status = 403
    ngx.say("Forbidden")
    ngx.exit(ngx.HTTP_FORBIDDEN)
end

-- 2. User-Agent检测
local ua = ngx.var.http_user_agent or ""
local bad_ua = {
    "sqlmap", "nikto", "masscan", "Nmap Scripting Engine",
    "ZmEu", "Python-requests", "curl/7.68.0", "Apache-HttpClient"
}
for _, pattern in ipairs(bad_ua) do
    if string.find(ua, pattern, 1, true) then
        block_ip(blacklist, client_ip, 3600)
        ngx.say("Forbidden")
        ngx.exit(ngx.HTTP_FORBIDDEN)
    end
end

-- 3. SQL注入/XSS检测
local args = {}
local method = ngx.req.get_method()
if method == "GET" then
    args = ngx.req.get_uri_args()
elseif method == "POST" then
    ngx.req.read_body()
    args = ngx.req.get_post_args()
end

local danger_patterns = {
    -- SQL注入特征
    "union%s+select",
    "update%s+set",
    "insert%s+into",
    "drop%s+table",
    "select%s+group_concat",
    "'%s*or%s*'1'='1",
    -- XSS特征
    " limit then
    block_ip(blacklist, client_ip, 60)
    ngx.say("Too Many Requests")
    ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
end

3. 辅助函数:block_ip

-- /opt/waf/block.lua
local blacklist = ngx.shared.waf_blacklist

function block_ip(blacklist_key, ip, expire_seconds)
    local ok, err = blacklist_key:set(ip, 1, expire_seconds)
    if not ok then
        ngx.log(ngx.ERR, "Failed to add IP to blacklist: ", err)
    end
    -- 加一条错误日志,方便审计
    ngx.log(ngx.ALERT, "[WAF] blocked IP " .. ip .. " for " .. expire_seconds .. "s")
end

return block_ip

4. 规则文件分离:规则数据用JSON

规则多了之后,我把它单独放到JSON文件里,不用改Lua代码就能加规则:

{
    "bad_ua": [
        "sqlmap",
        "nikto",
        "masscan"
    ],
    "sql_patterns": [
        "union%s+select",
        "update%s+set",
        "drop%s+table"
    ],
    "xss_patterns": [
        "# 不带WAF的基线
ab -n 10000 -c 200 -k http://127.0.0.1:8080/test.php

# 带WAF
ab -n 10000 -c 200 -k http://localhost/test.php

# 攻击模拟:带SQL注入参数
ab -n 100 -c 10 -k "http://localhost/test.php?id=1%20UNION%20SELECT%20*%20FROM%20users"

部署与配置细节

把上面的文件放到/opt/waf/下,nginx.conf里access_by_lua_file指向waf.lua。注意Lua需要用到ngx.re.find,这个必须开启PCRE库,OpenResty默认支持。如果你的Nginx是纯官方版,必须自己编译ngx_http_lua_module,我建议直接用OpenResty,省得折腾。

共享字典的大小根据流量调。我线上QPS约2000,IP黑名单用10m装1万个IP,CC计数器50m大约能存20万个IP,之前没打完过。内存吃紧就调小一点,或者换Redis存储。

上线前一定要跑回归测试。我把所有正常商品详情页、搜索页、登录接口都过了一遍,把所有合法的带特殊字符的分页参数(比如page=1%2C2)加到白名单里,不然会误杀。

效果数据:拦截率和性能损耗

上线后在测试环境用真实攻击流量做了一轮验证:

测试项 结果
SQLMap自动化扫描 150个请求,全部被拦,拦截率100%
XSS payload(100个变种) 拦截98个,漏掉2个(需人工加规则)
正常业务请求(脱敏) 1000条,误报9条,误报率0.9%
压测QPS(ab -n 10000 -c 200) 无WAF:5200;有WAF:4990(损耗4%)
压测CPU(4核) 无WAF:350%;有WAF:420%
压测内存 无WAF:320MB;有WAF:355MB

4%的性能损耗在可控范围内。相比ModSecurity的31%,Lua方案牺牲了一点规则覆盖度,换来了更低的资源开销。

线上运行3个月,安全告警数从平均每周12次降到0(除了我自己的模拟攻击),来自公网的扫描请求全部被挡在Nginx层。MySQL慢查询里再没出现过UNION SELECT。

避坑指南

这套WAF我踩过不少坑,列几条最有价值的:

  • 别用Lua的table.concat拼请求参数再做正则:原来我用table.concat(args, " ")把参数拼成一个字符串去匹配,结果100KB的body能把Nginx worker CPU打满。后来改成遍历参数,每条string类型最长4096,超过直接截断,这样才扛住慢速攻击。
  • 正则里必须用“o”编译选项ngx.re.find(..., "isjo")里的o是编译一次缓存结果。如果忘了加,每个请求都会重新编译正则,性能损失接近30%。
  • POST请求必须调read_body:在access阶段想拿POST参数,不调ngx.req.read_body()拿到的永远是nil。注意这个函数只能调一次,重复调用会报错。
  • shared_dict的incr不设expire会有脏数据:CC限流里我写了incr(key, 1, 0, 1),第四参数是初始值,第五参数是过期时间。如果只写incr(key, 1),计数永远不会清零,一个正常用户刷两分钟页面就被封了。
  • 别忽略前端的CDN IP:如果Nginx前有Cloudflare或阿里云CDN,remote_addr全是CDN节点IP,黑名单会把CDN封掉。必须从X-Forwarded-For取真实IP,并校验来源IP。我线上只信任CDN回源IP段,不是回源IP就拒绝读取XFF头。
  • Lua脚本报错会500:如果Lua里有语法错误,Nginx会直接返回500,整个请求断裂。上线前先在测试环境跑resty -c /opt/waf/waf.lua做语法检查,再压一轮再切流量。

后续优化方向

这个WAF目前还是“基于正则”,不能识别语义复杂的攻击。如果预算够,可以加一层机器学习模型,用Lua调用TensorFlow Serving或ONNX Runtime做文本分类,但延迟会增加10ms左右。另外,规则已经按JSON做了外置,后续可以接一个管理后台,动态下发规则,不用重启Nginx。

如果你需要的是快速上线、规则可控、对性能敏感,Lua WAF绝对值得搞。别迷信大厂WAF——本机和在边缘网关上都挂一层,纵深防御才是正经事。