一、为什么站长的第一站该是 WAF,而不是防火墙
很多自建站的站长一听说「安全」,第一反应是去折腾 iptables 和云厂商的安全组。方向没错,但顺序反了。防火墙只能看到 IP 和端口,而绝大多数打站的攻击——SQL 注入、XSS、恶意爬取、漏洞扫描、批量爆破后台——都发生在 80/443 端口已经被允许之后。也就是说,防火墙对它们完全无感。
WAF(Web Application Firewall,Web 应用防火墙)的位置在应用层:它读的是 HTTP 报文,看的是 URL、参数、Cookie、User-Agent、请求体里的内容。这正好补上了防火墙看不见的那一半。对个人站长来说,ModSecurity 是目前最现实的选择:开源、与 Nginx/Apache 原生集成、规则库 OWASP CRS 免费且维护活跃,一条 apt install 就能上手。
但要提前说清楚一件事:WAF 不是装完就完事了,它的主要矛盾永远在「误杀」和「漏杀」之间。默认规则全开,大概率第一天就把你自己的后台、编辑器、API 全拦掉;规则全关,等于没装。本文要讲的是怎么在这两端之间找到那个能睡觉的位置。
二、部署前的三个前提,少一个都别装
ModSecurity 和 Nginx 的集成有两种模式:一种是编译进 Nginx 的模块(需要自己编译,PHP 5.6 环境尤其麻烦),另一种是作为 Nginx 的独立进程——社区版 ModSecurity v3 提供的 nginx-module。下面按 v3 + Nginx 的常规路线讲。
第一个前提是 Nginx 版本与模块 ABI 匹配。如果是发行版自带的 Nginx,直接装发行版的 libnginx-mod-http-modsecurity 最省事;如果 Nginx 是编译安装的(很多面板环境都是),就得按 Nginx 源码版本重新编译模块,版本差一个 patch 都可能加载失败。装之前先确认:
nginx -V 2>&1 | tr ' ' '\n' | grep -E 'nginx version|configure arguments'
ls /etc/nginx/modules-enabled/ 2>/dev/null第二个前提是 有完整的 access/error 日志可回放。WAF 上线最怕的就是「不知道拦了什么」。至少要保证拦截日志单独落到一个文件,而且这个文件能按天切分、能被 grep。日志格式里务必带上 request_id,否则同一个用户的多条日志串不起来。
第三个前提是 有一个可灰度的小流量入口。不要一上来就全站开拦截。正确做法是先只用 SecRuleEngine DetectionOnly 跑三天,规则只在日志里「记账」不做事;三天后看日志,把误杀的规则 ID 一条条放行,再把模式改成 On。这三天的日志是后面所有调优的基础,别跳。
三、最小可用配置:引擎模式、审计日志与请求体
先写主配置。ModSecurity v3 的推荐主文件是 /etc/nginx/modsec/main.conf:
Include /etc/nginx/modsec/modsecurity.conf
Include /etc/nginx/modsec/crs/crs-setup.conf
Include /etc/nginx/modsec/crs/rules/*.conf其中 modsecurity.conf 只需改这几行关键项,其余保持默认:
SecRuleEngine DetectionOnly
SecRequestBodyAccess On
SecRequestBodyLimit 13107200
SecRequestBodyNoFilesLimit 131072
SecRequestBodyLimitAction Reject
SecResponseBodyAccess Off
SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"
SecAuditLogParts ABIJDEFHZ
SecAuditLogType Concurrent
SecAuditLogStorageDir /var/log/modsecurity/audit
SecAuditLog /var/log/modsecurity/modsec_audit.log
SecDebugLogLevel 0几个地方值得单独解释。第一,SecResponseBodyAccess Off 必须是 Off——打开它意味着几 MB 的图片、压缩包都要进内存做检查,一个视频站能直接把内存打爆,而且它带来的防护收益极小。第二,SecRequestBodyLimit 13107200(约 12.5MB)要与 Nginx 的 client_max_body_size 对齐,且请求体上限必须小于 body 限制,否则大文件上传会在 WAF 层被拒,报错还是看不懂的 403。第三,SecAuditLogStorageDir 的目录要让 Nginx 工作进程能写,权限用 750 加上正确的属主,这一步错会导致审计日志静默不落盘。
Nginx 侧只需在需要防护的 server 块里加一行:
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/main.conf;注意 modsecurity_rules_file 写的是主配置文件路径,不是 rules 目录。nginx -t 通过只代表语法对,规则解析错误要直接请求一次页面再看 error.log 才能发现。
四、OWASP CRS:装完立刻会遇到的三类误杀
CRS(Core Rule Set)是规则本体,它的默认配置在 crs-setup.conf 里,最关键的是异常评分阈值:
SecAction "id:900110,phase:1,nolog,pass,t:none,setvar:tx.inbound_anomaly_score_threshold=5,setvar:tx.outbound_anomaly_score_threshold=4"默认 5 分是个很好的起点。分数越高越不容易误杀但漏杀越多,往低调到 3 会开始大量拦截正常请求。不要在没看日志的情况下改这个数。
装完 CRS 之后,个人站长站点最常见的三类误杀:
- 后台富文本编辑器被拦。WordPress、Typecho 的编辑器和一些笔记类后台会 POST 大量带标签的内容,CRS 的 XSS 规则群会判定为攻击。正确处置是按 URI 白名单放行:
SecRule REQUEST_URI "@beginsWith /admin/" "id:1000,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942100-942999",只对后台路径移除 XSS 目标,不要整条链关掉。 - 正常搜索被当注入。站点搜索框里出现单引号、括号、
OR这类字符时,942系列的 SQL 注入规则会误判。处置是只对搜索页移除对应 target,或者对ARGS:q这类单参数放行。 - CDN 回源被拦。用了 CDN 之后,真实客户端 IP 在
X-Forwarded-For里,而 Nginx 的remote_addr全是 CDN 节点。此时如果规则里有基于 IP 的封禁,会把整个 CDN 网段一起封掉,变成「全站 403」。必须先配set_real_ip_from和real_ip_header,再启用任何按 IP 的规则。
还有一类不那么明显的误杀:文件上传后的路径穿越规则。很多中文文件名上传时会被 URL 编码成 %E4%B8%AD...,而某些规则把连续的 %2e%2e 视为穿越,正常文件里恰好包含这种字节组合时就会被拒。这类问题只能靠「先 DetectionOnly、从审计日志里捞出被拦的 URI」来定位。
五、从检测到拦截:三天的灰度节奏
三天的 DetectionOnly 之后,应该有一份「被标记但未拦截」的清单。处理顺序建议如下:
第一天之前,先确认站点所有关键路径都走过一遍——登录、退出、发文、上传、搜索、评论、分页、后台保存、RSS、sitemap。这些路径是人手点一遍,不是靠日志猜。每走一步就看一眼 /var/log/modsecurity/modsec_audit.log,被标记的记录里 [id "..."] 就是规则 ID,[uri "..."] 就是路径。把「路径 + 规则 ID」这对组合记下来,这就是你的白名单条目。
第二天开始收紧。SecRuleEngine On 之后不要一次改完,先只对静态资源和前台内容页开拦截,后台和 API 继续 DetectionOnly。用 SecRuleEngine 的 location 级覆盖即可:
location ^~ /admin/ {
modsecurity on;
modsecurity_rules 'SecRuleEngine DetectionOnly';
}第三天再全量打开。打开之后第一件事不是庆祝,而是盯 403 数量:正常情况下拦截量是稳定的一条平线,如果曲线突然起跳,多半是规则更新引入了新的误杀,或者站点某个页面被爬虫疯狂请求触发了 IP 封禁规则。CRS 默认会做 IP 封禁(ip.block),一旦某个网段被拉黑,站长自己可能也被挡在外面——所以务必先把自家 IP 加进 SecRule REMOTE_ADDR "@ipMatch 你的IP" "id:1,phase:1,pass,nolog,ctl:ruleEngine=Off" 这个例外,这条规则要写在所有规则之前。
六、规则更新的纪律:别让 WAF 自己把自己打下线
CRS 是持续更新的,每次更新都可能带来新的误杀。见过最典型的翻车是:半夜自动更新了规则,第二天早上站点全站 403,排查两小时才发现是 WAF。所以规则更新必须有纪律:
- 不要自动更新。改成每月手动执行一次,更新后先
nginx -t,再把引擎临时切回 DetectionOnly 观察一天,没问题再开拦截。 - 保留上一版规则目录。更新时
cp -a crs crs.bak.$(date +%Y%m%d),出问题一条命令换回去。 - 更新前先记录当前拦截量。以 24 小时的 403 条数为基线,更新后对比,涨了 30% 以上就回滚。
- 本地规则单独放一个文件,例如
/etc/nginx/modsec/custom/rules.conf,与 CRS 目录物理隔离。CRS 更新时直接覆盖整个目录,你写的白名单不会丢。
七、验证:四条命令确认 WAF 真的在工作
装完必须自己打一下,不能指望「应该生效了」:
# 1) 明显的注入尝试,应返回 403 且在审计日志里出现
curl -s -o /dev/null -w "%{http_code}" "https://your.site/?id=1%20UNION%20SELECT%201,2,3"
# 2) 正常页面,应仍是 200
curl -s -o /dev/null -w "%{http_code}" "https://your.site/"
# 3) 审计日志里应能看到刚被打的那条
grep -c "UNION SELECT" /var/log/modsecurity/modsec_audit.log
# 4) 确认引擎状态(规则里临时加一条打印,或看 startup 日志)
grep -i "modsecurity" /var/log/nginx/error.log | tail -5四条里第 2 条最重要。很多人只验证「能拦住」,不验证「没拦住不该拦的」,结果上线三天后才发现所有访客都被 403,流量掉了一半。上线后 24 小时内的 200 响应数要与上周同期持平,这是唯一的客观判据。
八、小结:WAF 是安全带,不是护栏
ModSecurity 能挡住绝大部分自动化扫描和常规注入尝试,但它挡不住逻辑漏洞——比如越权访问、支付金额篡改、验证码可复用,这些在报文层面看起来完全合法。真正的安全顺序是:先修应用层漏洞,再上 WAF 做兜底,最后才谈规则调优。
对个人站长来说,性价比最高的组合是:后台路径加基础认证或 IP 限制、关闭目录遍历、保持 Typecho/WordPress 与插件更新、WAF 只做 DetectionOnly 加少量硬规则(命令注入、SQL 注入、路径穿越),把「拦一切」的幻想放下。一个每天拦五条正常请求的 WAF,比一个从不拦截的 WAF 更危险——因为它会让你误以为站点是安全的。
