WAF 绕过手法剖析与防御补强实战配置

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 分级而非全局提级