跳转至

nginx 装 ModSecurity 3.0:生产 WAF 落地与误报治理实录

1. 为什么我决定给 nginx 上 WAF

那一晚的 access.log 让我下了决心。凌晨一点,日志里突然涌进大量来自 103.x.x.x 的 POST /wp-login.php 请求,每分钟上百次,密码字典轮询。我上了 fail2ban,第二天攻击者换了个 IP 接着打。真正让我坐不住的是几天后一条 GET /api/search?kw=1' union select 的探测——应用层的注入尝试,防火墙看不到、fail2ban 也判不出来,因为它是合法 HTTP 请求。那一刻我意识到:光靠网络层封禁防不住应用层攻击,得在 nginx 上装 WAF。

当晚的 access.log 长这样,我到现在还记得那个节奏:

103.42.176.12 - - [01/Oct/2026:01:12:04 +0800] "POST /wp-login.php HTTP/1.1" 200 1234 "-" "Mozilla/5.0"
103.42.176.12 - - [01/Oct/2026:01:12:04 +0800] "POST /wp-login.php HTTP/1.1" 200 1234 "-" "Mozilla/5.0"
103.42.176.12 - - [01/Oct/2026:01:12:05 +0800] "POST /wp-login.php HTTP/1.1" 200 1234 "-" "Mozilla/5.0"

同一秒内三条、无 referer、恒定间隔、密码字典——这不是用户行为,是脚本。判断"是不是攻击",看节奏比看内容更准,这也影响了后面对 WAF 的定位:我想要的不是"拦几条",而是"挡一类"。

2. 编译 ModSecurity 3.0 并动态加载

我不打算重编整棵 nginx,线上版本稳定,风险太大。所以我选了 modsecurity-nginx 连接器 + 动态模块方案,这样以后回滚只需注释掉一行 load_module。

# 1. 安装 ModSecurity 3.0 库(发行版包,省去自编译踩坑)
apt-get install -y libmodsecurity3 libmodsecurity-dev libpcre3-dev

# 2. 克隆 nginx 连接器
git clone --depth 1 https://github.com/owasp-modsecurity/ModSecurity-nginx.git

# 3. 拿到线上 nginx 的 configure 参数,保证兼容
nginx -V 2>&1 | grep configure | sed 's/^configure arguments: //' > /tmp/nginx-conf.txt

# 4. 下载与线上同版本的 nginx 源码,编译动态模块
wget https://nginx.org/download/nginx-1.24.0.tar.gz && tar xzf nginx-1.24.0.tar.gz
cd nginx-1.24.0
./configure --with-compat $(cat /tmp/nginx-conf.txt) \
    --add-dynamic-module=/root/ModSecurity-nginx
make modules
cp objs/ngx_http_modsecurity_module.so /etc/nginx/modules/

在 nginx.conf 顶部加载模块:

load_module /etc/nginx/modules/ngx_http_modsecurity_module.so;
nginx -t && nginx -s reload   # 必须 -t 通过再 reload

加载完先验证模块真的在干活,别等出事了才发现没生效:

# 1. 确认模块出现在运行配置里
nginx -T 2>/dev/null | grep -A1 "load_module.*modsecurity"

# 2. 打一条明显恶意请求,看是否被拦
curl -s -o /dev/null -w "%{http_code}\n" \
  "http://127.0.0.1/api/search?kw=1'%20union%20select%20user,pass,3%20from%20users--"
# 期望 403(DetectionOnly 灰度期会返回 200,但 audit log 里必有记录)

# 3. 看 error.log 是否出现 modsecurity 相关处理痕迹
tail -20 /var/log/nginx/error.log | grep -i modsecurity

# 4. 确认 ModSecurity 库版本(>= 3.0.4 才支持动态加载)
dpkg -l | grep libmodsecurity
nginx -V 2>&1 | grep -o "modsecurity[^ ]*"

这一步能筛掉 90% 的"装了但没生效"问题——我见过太多人把 load_module 写进 server 块,其实它必须在 http 块之外、文件最顶部。版本低于 3.0.4 的库,动态模块方案会静默失败,nginx -t 还通过,请求却完全不经过 WAF。

3. 接入 OWASP CRS 核心规则集

裸装 ModSecurity 等于没装,它本身没有规则。真正干活的是 OWASP CRS。

git clone --depth 1 -b v3.3.5 https://github.com/coreruleset/coreruleset.git \
    /etc/nginx/modsecurity/crs
cd /etc/nginx/modsecurity/crs
cp crs-setup.conf.example crs-setup.conf
cp rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf.example \
   rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
cp rules/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf.example \
   rules/RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf

基础开关 modsecurity.conf,我放在 /etc/nginx/modsecurity/:

SecRuleEngine On
SecRequestBodyAccess On
SecResponseBodyAccess Off
SecRequestBodyLimit 131072
SecRequestBodyNoFilesLimit 131072
SecAuditEngine RelevantOnly
SecAuditLog /var/log/nginx/modsec_audit.log
SecAuditLogType Serial
SecAuditLogParts ABIJDEFHZ

crs-setup.conf 是 CRS 的"总闸",几个必须调的点:

# Paranoia Level:默认 1,灰度期千万别拉高,误报会淹死你
SecAction "id:900100,phase:1,nolog,pass,\
  setvar:tx.paranoia_level=1,\
  setvar:tx.executing_paranoia_level=1"

# 允许的 Content-Type,没列的会走严格协议检查(920420)
SecAction "id:900110,phase:1,nolog,pass,\
  setvar:tx.allowed_request_content_type=application/json|application/x-www-form-urlencoded|multipart/form-data"

# 内网信任名单:把这些网段的 inbound 检测直接放行
SecAction "id:900130,phase:1,nolog,pass,\
  setvar:tx.crs_exclusions_trusted_ip=192.168.0.0/16|10.0.0.0/8"

CRS 是按请求阶段分文件的,知道每份文件管什么,排查时才知道去哪找:

规则文件 管什么
REQUEST-901-INITIALIZATION 初始化事务变量,别动
REQUEST-905-COMMON-EXCEPTIONS 常见例外(OPTIONS、.well-known 等)
REQUEST-910-IP-REPUTATION IP 信誉、封禁列表
REQUEST-911-METHOD-ENFORCEMENT HTTP 方法白名单
REQUEST-920-PROTOCOL-ENFORCEMENT 协议头/内容类型强制
REQUEST-930/931 LFI / RFI 文件包含
REQUEST-932/933 RCE、PHP 注入
REQUEST-941 XSS 注入
REQUEST-942 SQL 注入
REQUEST-949 / RESPONSE-959 阻塞评估(拦截决策点)

看到没,949/959 才是真正"判死刑"的文件。如果 403 拦得莫名其妙,先看是不是 949 的阻塞逻辑误判,而不是怀疑某条具体的 941/942。

4. 读懂 SecRule / SecAction 关键指令

WAF 配置的"语法"就两样:SecRule(条件匹配执行动作)和 SecAction(无条件执行动作)。一个典型的注入拦截长这样:

SecRule ARGS "(\bunion\b\s+\bselect\b|sleep\s*\(|information_schema)" \
    "id:950101,phase:2,deny,status:403,log,msg:'SQL Injection attempt'"
  • ARGS 是内置变量,代表所有请求参数;还有 REQUEST_URI、REQUEST_HEADERS、REMOTE_ADDR、TX(事务变量)。
  • phase:2 指请求体解析阶段,注入检测放这里;phase:1 只看请求行和头。
  • deny + status:403 是拦截动作;pass 则只记日志不拦截,调试误报时很常用。
  • id 必须唯一,这是后续排除规则的索引。

四个 phase 各司其职,排查时先分清是哪个阶段拦的:

phase 检测内容 典型规则
phase:1 请求行、请求头、URI 920 协议强制、恶意 UA
phase:2 请求体、POST 参数 941/942/932 注入检测
phase:3 响应头 RESPONSE-980
phase:4 响应体 RESPONSE-959 阻塞评估

403 但 audit log 里没有 phase:2 的记录,那多半是 phase:1 或 nginx 自身 deny 拦的,方向完全不一样。

SecAction 常用来设状态,比如下面 CC 防护里做计数器。而 SecDefaultAction 给一批规则设统一动作,避免每条重复写:

SecDefaultAction "phase:2,deny,status:403,log,auditlog,pass"

指令速查表,排查时翻它比翻文档快:

指令 作用
SecRuleEngine On/Off/DetectionOnly 全局开关,灰度期用 DetectionOnly 只记不拦
SecAction 无条件执行动作,常用来设变量、计数
SecDefaultAction 给后续规则统一默认动作,避免逐条写
SecRuleUpdateTargetById 精准豁免某规则的部分目标变量(最常用)
SecRuleRemoveById 整条剔除规则,等于对该规则全面投降,慎用
SecRuleUpdateActionById 把 deny 降级为 pass,过渡期观察用
SecAuditEngine RelevantOnly 只记录命中事务,避免日志量爆炸

还有一个容易被忽略的细节:变换(transformation)。CRS 的规则几乎都带 t:,它决定 payload 怎么"洗"过再匹配。比如:

# 先 URL 解码、再统一小写、再去空字符,最后做正则匹配
SecRule ARGS "@rx (?i)union\s+select" \
    "id:950102,phase:2,deny,status:403,t:urlDecodeUni,t:lowercase,t:removeNulls"

如果 payload 被 %0a 或大小写混着绕过,多半是变换没加全。误报/漏报排查时,先看规则有没有你要的 t: 变换。

5. 误报治理方法论:排除规则 + 白名单

WAF 上线第一周,真正让我熬夜的不是攻击,是误报。我总结的治理顺序是:先看 audit log 确认命中,再判断是规则太糙还是业务本身敏感,最后用排除规则或白名单放行。

# 方式一:按规则 ID 整条剔除(慎用,等于对该规则全面投降)
SecRuleRemoveById 941100

# 方式二:只对某个 URI 豁免某条规则(最常用,精准)
SecRuleUpdateTargetById 941100 "!ARGS:/rich_editor/content"

# 方式三:修改规则动作,从 deny 降级为 pass(过渡期观察)
SecRuleUpdateActionById 941100 "pass,log"

# 方式四:IP 白名单,可信来源直接跳过检测
SecRule REMOTE_ADDR "@ipMatch 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" \
    "id:990001,phase:1,nolog,pass,allow"

排除规则别随手写在主配置里,集中放 REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf,CRS 升级时这个文件不会被覆盖。我的真实文件长这样:

# 排除规则要写清"为什么",三个月后你未必记得
# 1. 富文本编辑器:内容里含转义后的 <script> 标签做代码展示,非执行
SecRuleUpdateTargetById 941100 "!ARGS:/admin/post/content"

# 2. 搜索接口:CRS 942100 把"1-1"这种纯数学查询误判为 SQLi,实际是业务减法逻辑
SecRuleUpdateTargetById 942100 "!ARGS:/api/search/q"

# 3. 监控拨测 UA 白名单:zabbix 健康检查不该被 920 拦
SecRule REQUEST_HEADERS:User-Agent "@streq Zabbix" \
    "id:990400,phase:1,nolog,pass,allow"

每一条排除规则我都留了注释和日期,这是误报治理能不能持久的关键——不然下个同事接手,看到豁免列表只会一脸懵。

误报日志分析我固定走三步,比拍脑袋改规则靠谱:

# 第一步:按 URI 聚合,看哪个接口最容易被误伤
grep -oE 'GET /[^ ]+' /var/log/nginx/modsec_audit.log | sort | uniq -c | sort -rn | head

# 第二步:按规则 ID 聚合,看哪条规则"杀性最重"
grep -oE 'id "[0-9]+"' /var/log/nginx/modsec_audit.log | sort | uniq -c | sort -rn | head

# 第三步:对 top1 的规则看 Matched Data,确认它匹配到了什么
grep -A1 'Matched Data' /var/log/nginx/modsec_audit.log | head -20

第二步最有价值——如果某条规则占了 80% 的命中,且都是误报,那问题很可能不在业务,而在规则本身太宽,这种就该用 SecRuleUpdateTargetById 收窄目标,而不是无限豁免。

还要反过来盯漏报:误报治好了别放松,漏报才是安全失效。我定期从 DetectionOnly 阶段积累的 audit log 里反查——哪些"可疑但没拦"的请求其实是攻击,据此补自定义规则。误报治理和安全兜底是两件事,别只做前一半。

6. 攻击类案例:我拦下来的三次真实攻击

6.1 SQL 注入:凌晨的 union select

某个凌晨,告警日志开始刷 950101,同一规则 ID 每分钟十几次。我翻 audit log,看到 payload:

GET /api/search?kw=1'%20union%20select%20user,pass,3%20from%20users--

攻击者在用 union select 探数据库结构,明显是手工注入。CRS 的 942100(SQLi 检测)把它拦下了,返回 403。我没有直接封 IP——因为对方可能换 IP,而是确认应用层确实没做参数化查询后,把这条参数纳入 SecRuleUpdateTargetById 之外的严格检测,并推动后端补了 PDO 预处理。WAF 是止损,不是根治,这点我一直提醒自己。

排查时怎么从日志里捞这条命中的规则?一条真实的 audit log transaction 长这样:

# 按规则 ID 提取,-A 看上下文
grep -B2 -A4 "950101" /var/log/nginx/modsec_audit.log | tail -30
--1a2b3c4d-A--
[01/Oct/2026:02:14:33 +0800] 1a2b3c4d 192.0.2.77 56789 443
--1a2b3c4d-B--
GET /api/search?kw=1'%20union%20select%20user,pass,3%20from%20users-- HTTP/1.1
Host: ops.jiunotes.top
User-Agent: curl/7.81.0
--1a2b3c4d-F--
HTTP/1.1 403 Forbidden
--1a2b3c4d-H--
Message: SQL Injection attempt [id "950101"]
Matched Data: union select found within ARGS:kw

Matched Data 直接告诉你哪个变量、命中了什么特征,这是定位误报的钥匙。

CRS 对 SQLi 的检测是分层计分的,不是"命中一条就 403":

规则 ID 检测点 计分
942100 union select 连用 +3
942200 引号后跟 SQL 关键字 +2
942430 单引号加注释符 +3
942431 反斜杠逃逸探测 +3

累加到阈值(默认 PL1 是 5 分)才由 949 落 403。所以只命中小分规则不会拦,这解释了为什么有些明显注入没被拦——是分数不够,不是规则没生效。

6.2 CC 攻击:单 IP 高频打接口

某次活动大促前,一个商品详情接口被高频请求刷到 P99 飙升。攻击特征很明显:同 IP、同 UA、无 referer、请求间隔几乎恒定。我在 CRS 之外加了一段基于 SecAction 计数的规则:

# 每个 IP 每分钟最多 120 次请求
SecAction "id:990100,phase:1,initcol:ip=%{REMOTE_ADDR},nolog,pass"
SecRule IP:count "@gt 120" "id:990101,phase:1,deny,status:429,log,msg:'CC detected'"
SecRule IP:count "@gt 0" "id:990102,phase:1,setvar:ip.count=+1,nolog,pass"

429 比 403 更语义化,也能让客户端知道是限流而非被"拒绝"。配合 limit_req 做瞬时保护,CC 明显回落。

limit_req 管的是"瞬间尖峰",ModSecurity 的 setvar 计数管的是"持续速率",两者互补:

limit_req_zone $binary_remote_addr zone=api_rate:10m rate=30r/s;
location /api/ {
    limit_req zone=api_rate burst=60 nodelay;
    proxy_pass http://backend;
}

后来我把这个场景抽象成一句话:CC 用计数封、瞬时用 limit_req 削、恶意 IP 用 fail2ban 封禁,三层各管一段,别混在一起。

注意:initcol:ip 创建的集合默认在内存,多久清零由 SecCollectionTimeout 控制,默认 600 秒。我把它显式设成与计数窗口一致:

SecCollectionTimeout 60

60 秒窗口配 120 次上限,逻辑才自洽。如果集合超时比窗口大,计数会累积,正常用户也会被误封——这类"间歇性误报"我排查过不止一次。

6.3 恶意爬虫与暴力破解

/wp-login.php 的字典爆破、/admin 的路径扫描、爬虫 UA 伪装,这些我统一定义在 REQUEST-900 排除规则之前的自定义文件里:

# 爬虫 UA 黑名单(抓到的真实 UA)
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)(scrapy|python-requests|masscan|nikto|sqlmap)" \
    "id:990200,phase:1,deny,status:403,log,msg:'Malicious UA'"

# 登录接口限速:5 分钟内同一 IP 超过 10 次即封
SecRule REQUEST_URI "@contains /wp-login.php" "id:990210,phase:2,setvar:tx.login_count=+1,nolog,pass"
SecRule TX:login_count "@gt 10" "id:990211,phase:2,deny,status:403,log,msg:'Brute force'"

setvar 是 ModSecurity 的灵魂——TX 变量能跨请求计数,这正是它能做状态化防护(封禁、限速)的原因。

暴力破解这种"高频小动作",我会再交给 fail2ban 做硬封禁,两者职责分开:ModSecurity 检测+记录,fail2ban 封 IP:

# /etc/fail2ban/jail.local
[nginx-modsec]
enabled  = true
filter   = nginx-modsec
logpath  = /var/log/nginx/modsec_audit.log
maxretry = 5
bantime  = 3600
# /etc/fail2ban/filter.d/nginx-modsec.conf
[Definition]
failregex = ^.*id "990211".*$

这样即使某个 IP 反复换 UA 撞登录接口,fail2ban 也会在 5 次后把它封 1 小时,而 WAF 只负责判断"是不是恶意",不负责"惩罚多久"。

7. 误报类案例:我误杀过的两次合法用户

7.1 富文本编辑器被误杀

运营同事反馈:后台发文章一直 403。我查 audit log,命中 941100(XSS 检测),payload 是富文本里的一段代码高亮:

<pre><code>&lt;script&gt;alert(1)&lt;/script&gt;</code></pre>

CRS 把转义后的 <script> 当成了 XSS。但这是展示代码的语法高亮,不是执行。我用了 URI + 参数双重定位,把编辑器内容豁免:

SecRuleUpdateTargetById 941100 "!ARGS:/admin/post/content"

改完让运营重发,正常。误报的根源常常是业务数据恰好长得像攻击 payload,排除规则要比全局降级精准得多。

验证豁免是否生效,用同一 payload 打两个接口对比:

# 编辑接口(已豁免)应返回 200,普通接口(未豁免)仍应 403
curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST http://ops.jiunotes.top/admin/post/content -d 'content=<script>alert(1)</script>'
curl -s -o /dev/null -w "%{http_code}\n" \
  -X POST http://ops.jiunotes.top/api/comment -d 'content=<script>alert(1)</script>'

对照实验才能证明豁免"精准命中"而非"误伤一片"。

7.2 内网办公网段被误杀

更尴尬的一次:公司同事从办公室访问线上后台,全员 403。一看 audit log,命中 941130(JavaScript 事件属性检测),触发点是登录后的跳转 URL 带了 ?redirect=javascript:... 参数——这是我们自己前端写的合法跳转逻辑。因为办公网是可信内网,我直接加了白名单:

SecRule REMOTE_ADDR "@ipMatch 192.168.100.0/24" \
    "id:990300,phase:1,nolog,pass,allow"

allow 动作会跳过后续规则,可信网段完全不经过 CRS,性能还更好了。白名单要窄:只放行办公网段,绝不放行整个公网。

验证白名单是否生效,我从办公网发起一次明显恶意请求:

# 从 192.168.100.0/24 网段内的机器执行
curl -s -o /dev/null -w "%{http_code}\n" \
  "http://ops.jiunotes.top/api/search?kw=<script>alert(1)</script>"
# 期望 200:因为 allow 在 phase:1 就放行了,根本不进 CRS

这一步很关键——确认白名单真的跳过,否则你以为放行了,实际仍被 941 拦截,业务方会再报一轮"又误杀了"。

8. 完整生产配置模板

把前面所有碎片拼成一份可复制的完整配置。先看独立的 modsecurity.conf(WAF 本体参数):

# /etc/nginx/modsecurity/modsecurity.conf
# 全局开关:灰度期用 DetectionOnly,稳定后改 On
SecRuleEngine On

# 请求体检测:这是注入检测的基础,必须开
SecRequestBodyAccess On
SecRequestBodyLimit 131072
SecRequestBodyNoFilesLimit 131072
SecRequestBodyInMemoryLimit 65536
SecRequestBodyLimitAction Reject

# 响应体检测:默认关,开销大且易误报,需要再单点开
SecResponseBodyAccess Off

# 审计日志:RelevantOnly 只记命中的事务,日志量可控
SecAuditEngine RelevantOnly
SecAuditLog /var/log/nginx/modsec_audit.log
SecAuditLogType Serial
SecAuditLogParts ABIJDEFHZ
SecAuditLogFormat JSON
SecAuditLogStorageDir /var/log/nginx/modsec_audit

# 超时与失败处理:检测超时降级为 pass,别让 WAF 本身拖垮服务
SecRuleUpdateActionById 949110 "pass"
SecRuleUpdateActionById 959130 "pass"
SecDefaultAction "phase:2,deny,status:403,log,auditlog"

其中 SecAuditLogFormat JSON 是我强烈建议的——结构化日志能被 ELK/Loki 直接解析,排查时按 rule_id 聚合快得多。

接下来是 site 级配置:

# /etc/nginx/conf.d/site-waf.conf
load_module /etc/nginx/modules/ngx_http_modsecurity_module.so;

http {
    # 基础 WAF 开关
    modsecurity on;
    modsecurity_config /etc/nginx/modsecurity/modsecurity.conf;

    # CRS 接入(放 http 级,全站生效)
    include /etc/nginx/modsecurity/crs/crs-setup.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-901-INITIALIZATION.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-905-COMMON-EXCEPTIONS.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-910-IP-REPUTATION.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-911-METHOD-ENFORCEMENT.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-920-PROTOCOL-ENFORCEMENT.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-930-APPLICATION-ATTACK-LFI.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-931-APPLICATION-ATTACK-RFI.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-932-APPLICATION-ATTACK-RCE.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-933-APPLICATION-ATTACK-PHP.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-941-APPLICATION-ATTACK-XSS.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf;
    include /etc/nginx/modsecurity/crs/rules/REQUEST-949-BLOCKING-EVALUATION.conf;
    include /etc/nginx/modsecurity/crs/rules/RESPONSE-959-BLOCKING-EVALUATION.conf;
    include /etc/nginx/modsecurity/crs/rules/RESPONSE-980-HTTP-EXCEPTIONS.conf;

    server {
        listen 443 ssl http2;
        server_name ops.jiunotes.top;
        ssl_certificate     /etc/nginx/ssl/ops.jiunotes.top.crt;
        ssl_certificate_key /etc/nginx/ssl/ops.jiunotes.top.key;

        # 业务白名单:可信内网跳过 WAF(窄范围,别放行整个公网)
        SecRule REMOTE_ADDR "@ipMatch 192.168.100.0/24" \
            "id:990300,phase:1,nolog,pass,allow";
        # 富文本编辑内容豁免 XSS 检测(精准豁免而非整条剔除)
        SecRuleUpdateTargetById 941100 "!ARGS:/admin/post/content";
        # 自定义 CC 防护:同 IP 每分钟超 120 次即 429
        SecAction "id:990100,phase:1,initcol:ip=%{REMOTE_ADDR},nolog,pass";
        SecRule IP:count "@gt 120" "id:990101,phase:1,deny,status:429,log,msg:'CC'";
        SecRule IP:count "@gt 0" "id:990102,phase:1,setvar:ip.count=+1,nolog,pass";

        # 管理后台单独收紧:登录接口限速
        location /wp-login.php {
            SecRule TX:login_count "@gt 10" \
                "id:990211,phase:2,deny,status:403,log,msg:'Brute force'";
            proxy_pass http://backend;
            proxy_set_header Host $host;
        }

        location / {
            proxy_pass http://backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }

        # 友好错误页:别把"被 WAF 拦"和"系统故障"混为一谈
        error_page 403 /403.html;
        location = /403.html {
            root /usr/share/nginx/html;
            internal;
        }
    }
}

灰度切换建议:第一周 SecRuleEngine DetectionOnly 只记不拦,把误报清干净;第二周再切 On。别一步到位——我用一次全员 403 换来的教训是:误报会摧毁业务方对 WAF 的信任。

调试技巧:上线初期把 SecRuleEngine 设为 DetectionOnly,让 WAF 只记不拦,观察几天 audit log 确认无误报后再切回 On。这是最稳的灰度方式。

9. 调优与监控:我踩过的坑

坑一:响应体检测别乱开。 我试过 SecResponseBodyAccess On,结果大 JSON 接口的响应体被完整缓存解析,内存和延迟双双飙升。最终关掉,只保留请求侧检测。

坑二:正则回溯让 CPU 打满。 CRS 默认规则的 @rx 在超长输入下可能有回溯开销,我加了 SecRequestBodyLimit 131072 并把 SecRuleUpdateTargetById 用于超长字段,防住了恶意超大包。

坑三:忘了看审计日志。 有一次误报排查半天,才发现 SecAuditEngine 被我误设为 Off,压根没记录。生产环境务必 RelevantOnly,只记命中的事件,否则日志量爆炸。

监控建议: 把 $status 为 403/429 的比例、modsec_audit.log 的条目增长率接进 Prometheus。403 比例突然上升,八成是攻击或误报,要立刻看 audit log 定位。

上线前必须压测,别拿"应该不会慢"赌生产。我用的对比方法:

# 开关 WAF 各跑一轮,对比 QPS 和 P99
# 关:注释掉 load_module + modsecurity on,reload 后跑
ab -n 20000 -c 200 http://127.0.0.1/api/health > /tmp/no_waf.txt
# 开:恢复配置,reload 后再跑
ab -n 20000 -c 200 http://127.0.0.1/api/health > /tmp/with_waf.txt
grep "Requests per second" /tmp/no_waf.txt /tmp/with_waf.txt

如果 P99 涨幅超过 50ms,优先做三件事:SecResponseBodyAccess Off(响应体检测最贵)、SecRequestBodyLimit 收紧到业务上限、把高频接口的字段加进豁免列表。别急着降 Paranoia Level,先查性能热点在不在你不需要的检测上。

日常巡检命令,我挂在 crontab 里每分钟跑:

# 403/429 占比骤升就告警
awk '{print $9}' /var/log/nginx/access.log | grep -cE '^(403|429)$' 
# audit log 条目数突变也要盯
wc -l /var/log/nginx/modsec_audit.log

modsec_audit.log 增长极快,务必配 logrotate,不然磁盘被填满是迟早的事:

# /etc/logrotate.d/nginx-modsec
/var/log/nginx/modsec_audit.log {
    daily
    rotate 14
    compress
    missingok
    notifempty
    create 0640 www-data adm
    postrotate
        nginx -s reopen   # 必须告诉 nginx 重新打开日志文件
    endscript
}

注意 postrotate 里的 nginx -s reopen,漏了它 audit log 会一直写进被 rotate 走的旧文件,你以为在轮转,其实根本没生效——这个坑我踩过,日志文件删了还占着 inode。

排障速查,遇到问题按这个顺序过一遍:

# 1. 配置语法对不对
nginx -t

# 2. 模块到底加载没有
nginx -T 2>/dev/null | grep -c "load_module.*modsecurity"

# 3. 规则文件有没有 include 进来(缺一个文件就少一层防护)
nginx -T 2>/dev/null | grep -c "REQUEST-942-APPLICATION-ATTACK-SQLI"

# 4. 是"没拦"还是"拦了没记录"?
grep -c "modsecurity" /var/log/nginx/error.log
tail -20 /var/log/nginx/modsec_audit.log

# 5. 临时关掉 WAF 对比:注释 modsecurity on 后 reload,请求是否恢复正常
#    恢复了 → 大概率误报;没恢复 → 问题不在 WAF,别赖 WAF

第 5 条我称之为"开关对比法",是判断问题是不是 WAF 引起的金标准。很多同事一看到 403 就急着改规则,其实用开关对比法 5 分钟就能定性。

升级 CRS 的坑:每次升级 CRS 小版本,规则 ID 会新增、REQUEST-900 排除文件可能被覆盖。升级后我必做三件事:nginx -t 过语法、跑一遍 curl 冒烟(打正常接口 + 恶意接口各一)、看 audit log 有没有新误报。别直接 reload 就完事。

FAQ

Q:ModSecurity 会拖慢 nginx 吗? A:会。实测 CRS 默认 PL1 下 QPS 下降 5%-15%,主要是请求体解析和正则开销。上线前必须压测,我的底线是 P99 增加不超过 50ms。

Q:误报怎么快速定位? A:SecAuditEngine RelevantOnly 只记录触发规则的事件,一条误报的 audit log 里有 transaction 编号,能直接看到命中规则 ID 和原始 payload,照着加排除规则即可。

Q:Paranoia Level 要不要开到 4? A:别在生产直接开。PL1 是默认,PL2 误报率已明显上升,PL3/PL4 只适合高安全敏感场景。建议 PL1 起步,用排除规则补个别业务风险。

Q:误封的 IP 怎么快速解封? A:若用了 CRS 的 IP 封禁规则(REQUEST-910),把 REMOTE_ADDR 临时加入白名单。更稳妥的是用 fail2ban 做封禁层、ModSecurity 只做检测,解封逻辑分开,避免误伤。