真实场景:凌晨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特征
"