个人网站为什么也需要一个 WAF
只要网站上线,扫描器就从来没停过:探测后台路径、尝试弱口令、往 URL 里塞 SQL 注入语句、拿 XSS payload 试探留言功能。打开 Nginx 的访问日志翻一翻,每天都有大量 404 和带奇怪参数的请求,那都是扫描器在自动试探。靠 deny 白名单和 fail2ban 能挡掉一部分,但那是"按 IP 封"的思路,攻击者换一个 IP 就卷土重来,分布式扫描更是封不胜封。WAF 的思路是"按请求内容拦":不管 IP 怎么换,只要请求里带着明显的攻击特征就直接拒绝,根本不给你后面的 PHP 和数据库反应的机会。ModSecurity 是老牌开源 WAF 引擎,搭配 OWASP 的 CRS 规则集,等于给 Nginx 装了一套持续更新的攻击特征库,完全免费,个人站长自己就能部署,不必去买云厂商按年收费的 WAF 套餐。这篇文章记录完整的部署过程和我实际踩过的坑。
一、先搞清楚 ModSecurity 3.0 的组件关系
ModSecurity 早期是 Apache 的一个模块,2017 年前后独立成 3.0 版本,把核心引擎抽成了独立的 libmodsecurity 库。Nginx 想用它,需要额外编译一个连接器模块叫 ModSecurity-nginx,它负责把 Nginx 的请求转发给引擎处理。所以完整的链路是:Nginx 加载 ModSecurity-nginx 动态模块,模块调用 libmodsecurity 引擎,引擎加载 CRS 规则集对每个请求逐条检测。这里要特别强调:CRS 规则集不是 ModSecurity 自带的,需要单独从 OWASP 的 coreruleset 仓库下载。很多人装完 ModSecurity 发现什么都不拦,十有八九就是漏了这一步——引擎是空的,没有规则可跑,自然形同虚设。
二、编译安装 Nginx 动态模块
个人站大多用一键环境包或者发行版自带的 Nginx,好在从 Nginx 1.9.11 开始支持动态模块,不必为了装 ModSecurity 重新编译整个 Nginx,只需要编译一个 .so 文件再加载进来。以 Debian/Ubuntu 为例,先安装编译工具链、libmodsecurity 和它的一堆依赖库:
apt install libmodsecurity3 libmodsecurity-dev libtool automake autoconf \
libpcre2-dev libxml2-dev libyajl-dev libcurl4-openssl-dev libssl-dev \
libgeoip-dev libmaxminddb-dev build-essential
git clone --depth 1 https://github.com/owasp-modsecurity/ModSecurity
cd ModSecurity
git submodule update --init --recursive
./build.sh && ./configure && make -j4 && make installlibmodsecurity 编译安装完成后,再拉取 Nginx 连接器模块的源码:
git clone --depth 1 https://github.com/owasp-modsecurity/ModSecurity-nginxcd ModSecurity-nginx
接下来是关键一步:查一下当前 Nginx 的版本和编译参数,下载同版本的官方源码包来编译模块。模块和 Nginx 主程序版本差异太大会导致加载失败,报 module 符号不匹配之类的错误:
nginx -v # 记下版本号,比如 1.24.0
nginx -V 2>&1 # 记下 configure arguments,后面要带上wget https://nginx.org/download/nginx-1.24.0.tar.gz
tar zxf nginx-1.24.0.tar.gz && cd nginx-1.24.0
./configure --with-compat --add-dynamic-module=../ModSecurity-nginx
make modules
--with-compat 参数保证编译出来的模块和现有 Nginx 的二进制兼容,这一点非常重要,漏了它即使版本相同也可能加载失败。编译产物在 objs/ngx_http_modsecurity_module.so,把它复制到 Nginx 的模块目录,然后在 nginx.conf 最顶部加载:
cp objs/ngx_http_modsecurity_module.so /usr/lib/nginx/modules/nginx.conf 第一行
load_module /usr/lib/nginx/modules/ngx_http_modsecurity_module.so;
load_module 指令必须放在 events 块之前,也就是配置文件的最开头。跑 nginx -t 验证没有报错,再把 ModSecurity 的主配置文件复制到约定位置:
mkdir -p /etc/nginx/modsec
cp ModSecurity/modsecurity.conf-recommended /etc/nginx/modsec/modsecurity.conf
cp ModSecurity/unicode.mapping /etc/nginx/modsec/
如果你用的是宝塔、LNMP 这类一键包,Nginx 可能是自己编译的,版本比较特殊,建议先找到它的编译参数(一般写在安装目录的 nginx.conf 注释或者 /www/server/nginx 的版本文件里),再下载完全相同的版本源码来编模块,成功率最高。我一开始图省事用了发行版源里的 Nginx 版本编译,结果和面板的 Nginx 对不上,折腾了半小时,后来老老实实按面板版本号重编才通过。
三、部署 OWASP CRS 规则集
CRS(Core Rule Set)是目前使用最广的开源 WAF 规则集,由 OWASP 维护,规则按攻击类型分类编号,比如 920 系列管协议合规、930 系列管文件包含、941 系列管 XSS、942 系列管 SQL 注入、943 管会话固定、944 管命令注入,每个系列里又细分了几十条具体规则。下载并初始化:
git clone --depth 1 https://github.com/coreruleset/coreruleset /etc/nginx/modsec/crs
cd /etc/nginx/modsec/crs
cp crs-setup.conf.example crs-setup.conf
crs-setup.conf 是规则集的"总开关"和调参入口,文件里注释非常详细,一项一项说明了每个参数的作用。至少要做两件事:一是按自己站点的实际情况设置允许的请求体大小,默认值比较保守,有文章投稿功能的站要把 SecRequestBodyLimit 调大,否则用户发长文会被 413 拦掉;二是确认规则里对本地回环地址做了放行,避免你自己服务器上的健康检查请求被误伤。规则本体按编号放在 rules 目录下,一般不需要改动,Nginx 侧只需要把 crs-setup.conf 和 rules 目录一起 include 进来。
四、Nginx 配置接入
先把 ModSecurity 的检测模式设为 DetectionOnly,只记录不拦截——这是上线前必须经历的一个阶段,直接开拦截几乎必然误杀一片:
# 修改 /etc/nginx/modsec/modsecurity.conf
SecRuleEngine DetectionOnly
然后在站点配置里启用。建议全局开启、静态资源目录单独关闭,图片和 CSS 没必要逐请求过一遍正则,能省下不少性能:
server {listen 443 ssl;
server_name www.example.com;
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/modsecurity.conf;
location /usr/uploads/ {
modsecurity off; # 静态资源路径跳过检测
}
}
这里有个容易绕晕的点:modsecurity_rules_file 指令只能指向一个文件,而 CRS 是一整个目录的规则。解决办法是在 modsecurity.conf 的末尾用 Nginx 的 include 语法把 CRS 引进来,这样站点配置里只写一行就行:
# 追加到 /etc/nginx/modsec/modsecurity.conf 末尾
Include /etc/nginx/modsec/crs/crs-setup.conf
Include /etc/nginx/modsec/crs/rules/*.conf
注意这里的 Include 首字母是大写的,Nginx 内部对 include 指令的解析区分大小写,写小写会静默不生效,官方文档里专门提醒过。改完 nginx -t 检查语法再 reload。此时攻击请求会被记录但不会被拦截,先以这个状态跑几天,收集真实流量下的表现。
五、看日志、处理误杀、再开拦截
DetectionOnly 模式下,规则命中会写进 Nginx 的 error.log,每行以 ModSecurity: Warning 开头,里面能看到命中的规则 ID、请求的 URL、来源 IP 和具体的匹配片段。日志里还能看到按阶段分的标记,比如 phase 1 是请求头阶段、phase 2 是请求体阶段,排查问题时要能看懂这些字段。观察几天之后做两件事:第一,确认规则确实在拦截——日志里应该能看到针对后台路径、wp-login 之类扫描的命中记录,说明引擎和规则都在正常工作;第二,处理误杀。CRS 默认比较严格,个人站常见的误杀有这么几类:文章正文里贴了带攻击语句的代码片段、搜索关键词里带了 union select 之类的字样、评论里含特殊字符的英文内容被当成注入尝试。处理误杀的标准做法是精确排除,而不是图省事把整个引擎关掉。单独建一个 exclude.conf 文件,里面按规则 ID 排除:
# /etc/nginx/modsec/exclude.conf,然后在 modsecurity.conf 末尾 Include 进来
SecRuleRemoveById 942100 942110
SecRuleUpdateTargetById 941100 "!REQUEST_URI"
第一条是直接移除指定的规则 ID,第二条更精细:保留规则,但让它跳过 URI 字段只检测其他字段。命中的规则 ID 在 error.log 里都能看到,对照着处理就行。把误杀排除得差不多之后,再把 SecRuleEngine 改成 On 并 reload,正式开启拦截。保守一点的策略是前台保持宽松,只对后台路径单独启用严格模式,毕竟攻击者最想进的还是你的管理后台。
六、启用后的验证与日常维护
部署完成后一定要做验证,很多人跳过这一步,结果 WAF 装了半年根本没生效。最简单的验证是模拟一次无害的攻击请求,看返回码和日志:
# 模拟 SQL 注入探测(无害测试,不会真执行)
curl -I "https://你的域名/?id=1%27%20OR%201=1--"模拟 XSS 探测
curl -I "https://你的域名/?q=<script>alert(1)</script>"
开启拦截模式后,这两条请求应该返回 403,同时 error.log 里出现对应的命中记录;DetectionOnly 模式下则返回正常状态码但日志里同样有记录。如果返回 200 且日志里什么都没有,说明规则根本没加载,回头检查 include 的大小写和路径。验证通过后,把测试用的 UA 或 IP 记下来,避免以后自己把自己误封。
七、套了 CDN 别忘了处理真实 IP
个人站基本都套了 CDN,这时候有一个必须先解决的问题:CDN 回源时,Nginx 看到的来源 IP 是 CDN 节点的地址,而不是攻击者的真实 IP。ModSecurity 的规则本身不太依赖来源 IP,但 CRS 里有一部分基于 IP 的规则(比如恶意 IP 黑名单、扫描器惩罚),还有你的 fail2ban,都需要真实 IP 才能正常工作。解决办法是用 Nginx 的 realip 模块,把 CDN 回源头里的真实地址恢复出来:
# 确认 Nginx 编译时带了 realip 模块(nginx -V 查看)
set_real_ip_from 103.21.244.0/22; # 换成你用的 CDN 官方回源网段
set_real_ip_from 103.22.200.0/22;
real_ip_header CF-Connecting-IP; # Cloudflare 用这个头,其他 CDN 看文档
real_ip_recursive on;
CDN 的回源网段在每个 CDN 官网上都能查到,务必只信任 CDN 的网段,如果随便信任 X-Forwarded-For 头,攻击者自己伪造一个头就能骗过 IP 相关的所有防护。这块配置和 ModSecurity 是两套独立的东西,但少了他,WAF 的防护能力会打折扣。
八、性能开销与规则更新
ModSecurity 对每个请求都要跑一遍正则匹配,性能开销是实打实的。静态文件已经在上文配置里跳过检测了,剩下的动态请求一般会有百分之十到三十的吞吐量损耗,对个人站来说完全在可接受范围内。想知道自己的机器扛不扛得住,就在压测下对比开启前后的吞吐量,如果损耗超过预期,优先检查是不是把静态资源也放进检测范围了。日常维护其实就两件事:定期更新 CRS 规则,以及每周瞄一眼 error.log 里有没有新类型的攻击在试探。更新规则就一条命令:
cd /etc/nginx/modsec/crs && git pull
但更新前先看一眼更新日志,官方偶尔会调整规则行为,收紧某些判定,导致新的误杀,更新后的头两天多盯一下日志更稳妥。最后说下定位:ModSecurity 负责应用层的攻击检测,fail2ban 负责来源 IP 的封禁,Nginx 限流负责防刷,后台二次验证负责账号安全,它们各管一层,组合起来才是完整的防护体系。装了 WAF 不代表可以裸奔,但至少那些每天准时来报到的扫描器,从此会在你的日志里留下一片 403,而不是一次次试探你的程序漏洞。