WAF 绕过防御的本质,是消除 WAF 与后端应用之间的语义差异。攻击者并不需要”攻破”WAF,只要让同一个请求在 WAF 眼里是 A、在 PHP 或 Java 眼里是 B,规则就形同虚设。本文先拆解七类可复现的语义差异,再给出 Nginx 规范化、ModSecurity 补强、源站直连封堵三层配置,最后提供一套可回归的验证流程。
适用场景
- 已部署 ModSecurity 或云 WAF,但渗透测试报告仍显示注入、上传类漏洞”可利用”
- 业务侧反馈”WAF 放行了本该拦截的请求”,需要定位是规则缺失还是解析差异
- 上线新规则后需做回归测试,确认编码变形、分块、参数重复等变体仍能被拦住
- WAF 日志显示拦截成功,但源站访问日志里出现对应请求,需要排查链路断点
前置条件
- Nginx 1.25.1 以上(
http2 on;指令要求),或按注释改用旧写法 - ModSecurity v3 + OWASP CRS 4.x 已编译进 Nginx
- 一套独立测试环境,源站可按需放开防护做对比
- WAF 审计日志(
SecAuditLog)已开启并可按 request id 检索 - 测试必须在自有测试环境进行,禁止对生产入口发送破坏性载荷
原理说明
WAF 是一个”额外的解析器”。它要自己完成 URL 解码、参数拆分、字符集归一化、multipart 解析,才能对内容做规则匹配;后端框架(Nginx → PHP-FPM、Tomcat、Spring)会按另一套规则做同样的事。两套解析器的实现细节只要有一处不同,就产生了绕过空间。规则写得再全,也补不上解析器之间的缝。
七类常见的语义差异
- 编码层级:
%2527(双重 URL 编码)、Unicode 全角字符、HTML 实体',WAF 解一层就匹配,后端解两层才执行 - 分隔符替换:用
/**/、换行、制表符、多个空格替代规则里要求的字面空格 - 大小写与拼接:
UnIoN SeLeCt、UN/**/ION,规则按字面匹配就会漏 - 传输层分块:
Transfer-Encoding: chunked或与Content-Length冲突,WAF 按行的解析逻辑容易失效 - 参数结构:同名参数重复(HPP)、数组参数
a[]=、JSON 与 form 混用,各解析器取值规则不同 - 路径规范化:
/./、//、%2e%2e、;.jpg后缀,WAF 与后端归一化结果不一致会直接绕过路径类规则 - 边界与体积:超长 header、超大 body、慢速提交超出检测缓冲区,WAF 放弃检测后直接放行
还有一条不属于技术绕过、但现实中最致命:源站 IP 直接可达。攻击者拿到真实 IP 后根本不经过 WAF,任何规则都拦不到。
操作步骤
步骤一:建立可复现的测试基线
先用无害标记串确认”规则是否生效”,再谈”能否绕过”。下面的循环会输出每个变体的状态码,403 表示被拦,200 表示穿透:
BASE='https://test.example.com/item?id='
for p in \
"1'" \
"1%27" \
"1%2527" \
"1/**/AND/**/1=1" \
"1+AND+1=1" \
"1%0aAND%0a1=1" \
"1%20UnIoN%20SeLeCt%201" \
"1&id=2" \
"1%00" ; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "$BASE$p")
printf '%-24s %s\n' "$p" "$code"
done
步骤二:Nginx 层先做规范化
让进入 WAF 的请求已经是”归一化之后”的形态,能消掉一大半编码类绕过。关键是 merge_slashes on(合并重复斜杠)与严格的请求体、header、超时上限:
map $request_method $waf_bad_method {
default 1;
GET 0;
POST 0;
HEAD 0;
}
server {
listen 443 ssl;
http2 on; # Nginx < 1.25.1 请改写为 listen 443 ssl http2;
server_name www.example.com;
merge_slashes on;
client_max_body_size 2m;
client_body_buffer_size 128k;
client_body_timeout 10s;
client_header_timeout 10s;
send_timeout 15s;
large_client_header_buffers 4 8k;
if ($waf_bad_method) { return 405; }
# 编码点号、编码斜杠、分号参数一律拒绝,阻断路径规范化差异
if ($request_uri ~* "(%2e|%2f|%5c|;|\.\./)") { return 400; }
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
注意 client_max_body_size 必须与 WAF 的检测上限一致。常见事故是 WAF 只检测前 128KB、Nginx 允许 10MB,攻击者把载荷塞在第 200KB 处,两侧都”正常”。
步骤三:ModSecurity 补强解析器与阈值
SecRuleEngine On
# 请求体检测上限必须与 Nginx 对齐,Action 选 Reject 而不是 ProcessPartial
SecRequestBodyLimit 2097152
SecRequestBodyNoFilesLimit 131072
SecRequestBodyInMemoryLimit 131072
SecRequestBodyLimitAction Reject
SecPcreMatchLimit 150000
SecPcreMatchLimitRecursion 1500
SecAuditEngine RelevantOnly
SecAuditLog /var/log/modsecurity/audit.log
# CRS 异常评分阈值:默认 5,敏感业务可降到 3
SecAction "id:900000,phase:1,pass,nolog,setvar:tx.paranoia_level=2"
SecAction "id:900001,phase:1,pass,nolog,setvar:tx.inbound_anomaly_score_threshold=5"
# 非白名单 Content-Type 直接 415,堵住类型混淆绕过
SecRule REQUEST_METHOD "@streq POST" \
"id:1001,phase:1,deny,status:415,log,chain"
SecRule REQUEST_HEADERS:Content-Type \
"!@rx ^(application/x-www-form-urlencoded|multipart/form-data|application/json)(;|$)" \
"t:none"
# 仅对不接受流式上传的入口拒绝分块传输,避免检测缺失
SecRule REQUEST_HEADERS:Transfer-Encoding "@rx (?i)chunked" \
"id:1002,phase:1,deny,status:411,log"
# 同名参数重复直接拒绝(把 HPP 从"解析差异"变成"明确错误")
SecRule &ARGS:id "@gt 1" \
"id:1003,phase:2,deny,status:400,log,msg:'duplicate parameter id'"
SecRequestBodyLimitAction 必须设为 Reject。设成 ProcessPartial 时,超出部分不被检测但仍会转发给后端,这正是”超大包绕过”的成因。
步骤四:封堵源站直连
这一步的收益远大于任何规则调优。源站只允许 WAF/CDN 回源地址访问 443,其余全部丢弃:
for ip in 203.0.113.10 203.0.113.11 203.0.113.12; do
iptables -A INPUT -p tcp --dport 443 -s "$ip" -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -s "$ip" -j ACCEPT
done
iptables -A INPUT -p tcp --dport 443 -j DROP
iptables -A INPUT -p tcp --dport 80 -j DROP
# 持久化
iptables-save > /etc/iptables/rules.v4
同时清理历史解析记录:mail.example.com、ftp.example.com、旧测试域名常指向同一台源站,等于给攻击者留了一条绕过通道。用 dig +short 逐个核对,凡是解析到源站 IP 的子域,要么迁走要么下线。
步骤五:日志复核闭环
# 找出"WAF 判定拦截、但源站仍收到"的请求
grep -E 'id "949110"|status:403' /var/log/modsecurity/audit.log \
| awk -F'[][]' '{print $2}' | sort | uniq -c | sort -rn | head
# 对比同一时间段两侧请求量,差值即为穿透量
awk '{print $4}' /var/log/nginx/access.log | cut -c14-19 | sort | uniq -c | tail -3
grep -c 'Request' /var/log/modsecurity/audit.log
配置验证
改完配置后不要只看”业务是否正常”,必须逐条回归测试集。判定标准是:编码变体在 Nginx 层返回 400,类型混淆返回 415,分块返回 411,重复参数返回 400,只有原始正常请求返回 200。
nginx -t && systemctl reload nginx
for u in \
'https://www.example.com/./admin' \
'https://www.example.com//admin' \
'https://www.example.com/%2e%2e/etc/passwd' \
'https://www.example.com/item?id=1&id=2' ; do
printf '%-58s %s\n' "$u" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "$u")"
done
# 分块传输测试
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H 'Transfer-Encoding: chunked' --data-binary @payload.txt \
https://www.example.com/api/upload
常见问题
Q1:把 Paranoia Level 提到 3 之后误杀严重,怎么折中?
不要全局提级。正确做法是按 location 分级:登录、支付、后台、上传等敏感入口用 PL3 并配 inbound_anomaly_score_threshold=3;静态资源与公开查询接口维持 PL1。提级后必须跑一遍真实业务流量回放,把误报规则逐条 SecRuleUpdateActionById 降级,而不是整体调回去。经验上 PL2 是大多数站点的性价比拐点,PL3 需要有人持续维护误报清单。
Q2:用云 WAF 无法改后端解析,语义差异怎么办?
两条路。一是在源站前置一层自建 Nginx,先做规范化(步骤二)再交给业务,把”归一化”的责任从云 WAF 手里接回来;二是把关键校验下沉到应用层——参数类型强校验、SQL 全参数化、上传白名单校验文件头,让”绕过 WAF”不再等于”利用成功”。WAF 只能是纵深防御的一层,指望它独自兜住所有注入是不现实的。
Q3:为什么 WAF 日志显示已拦截,业务日志里仍有成功请求?
按可能性排序排查:一是源站存在第二条入口(旧域名、IP 直连、备用端口),请求根本没经过 WAF;二是 WAF 只拦了首个请求,后续在同连接上复用了已建立的会话;三是规则命中但 action 被配置为 pass 而非 deny(CRS 的异常评分模式下很常见);四是 Nginx 与 WAF 部署在不同节点,日志时间不同步导致比对错位。用 X-Request-ID 贯穿两侧日志,比按时间戳对齐可靠得多。
总结
WAF 绕过防御不是”把规则写得更狠”,而是把解析差异抹平、把入口收敛干净。规则匹配的精度再高,也挡不住从源站 IP 直接进来的流量。
- 先做规范化:merge_slashes on + 拒绝编码点号与分号参数,消掉大部分编码类绕过
- 检测上限必须与 Nginx 的
client_max_body_size对齐,Action 用 Reject - Content-Type、Transfer-Encoding、重复参数三点做白名单式拒绝
- 源站只允许 WAF 回源地址访问,并清理指向源站的历史子域
- 敏感入口用 PL3,其余维持 PL1/PL2,按 location 分级而非全局提级