WAF加白放行渗透流量引发nginx到ingress异常连接的排查
记录时间:2026-07-31
环境:外层 nginx(68.154,/AppHome/nginx2021/)+ ingress-nginx(K8s,10.202.17.x:80)+ WAF
一、问题现象
渗透压测期间,68.154 外层 nginx 大量报两类错:
no live upstreams(对外返回 502)recv() failed (104: Connection reset by peer)
影响:xxx.com 等业务间歇性 502。反常的是 nginx 和 ingress 的 CPU 负载、连接数都不高,看起来不像是被打爆了。
二、排查过程
2.1 先排除 ingress / K8s 被打挂
检查 ingress-nginx:CPU 不高、Pod 正常、conntrack 未满、SYN 队列正常。
ingress 自身没被打挂。
2.2 排除外层 nginx 连接数耗尽
nginx worker 连接数没到 worker_connections 上限。
排除连接耗尽。
2.3 压测脚本验证 ingress 健康
写了个 attack_sim 脚本(curl + 并发,按 curl 退出码区分 reset/refused/timeout/502),在同网段直打 ingress:
timeout 40s python3 attack_sim.py \
-u http://10.202.17.1:80 -c 50 -d 30 -m normal \
-H "Host: xxx.com"
结果:50 并发、1134 req/s,零 502 / 零 reset / 零 refused / 零 timeout,3xx/4xx 都是正常业务响应。
ingress 自身完全健康,问题不在 ingress 的处理能力。
2.4 回到外层 nginx 的 upstream 配置
定位到 upstream.conf 的 ingress 段:
upstream ingress {
server 10.202.17.1:80 max_fails=3 fail_timeout=10s;
server 10.202.17.2:80 max_fails=3 fail_timeout=10s;
server 10.202.17.3:80 max_fails=3 fail_timeout=10s;
keepalive 128;
}
配合两个事实:
- 引用它的 location 都没设
proxy_next_upstream,走默认error timeout,连接被 RST(104) 或超时都算失败,会计入max_fails。 - 全局
proxy_connect_timeout 600s(不是默认 60s)。
2.5 发现 keepalive 实际没生效
keepalive 128 因为 nginx 继承陷阱形同虚设:http 块设了 proxy_set_header Connection "",但每个 location 自己又写了 proxy_set_header Host,导致 Connection "" 不被继承,回退成 close,每个请求都新建短连接。
但 keepalive 不是这次 502 的主因——中间设备拦的是攻击特征,与连接长短无关。这只是排查中顺带发现的连接效率问题,别和 502 根因混在一起(详见三、根因分析)。
三、根因分析
完整链条:
渗透压测流量
↓
打到 WAF
↓
WAF 加白策略 → 渗透测试流量全部放行(源头没拦住)
↓
异常请求全量进入外层 nginx
↓
nginx → ingress 跨网段(中间经过安全设备)
↓
中间设备对扫描特征请求回 RST → 大量 104 error
↓
nginx 被动健康检查 max_fails=3:3 个节点攻击下毫秒级累计失败 → 全部摘除
↓
no live upstreams(对外 502)
× keepalive 未生效 → 海量短连接放大 RST 触发量与 TIME_WAIT
「中间设备拦」已坐实(两个硬证据):
- nginx 报
recv() failed (104 reset)(尝试连 ingress 时被 RST),但 ingress 侧没有任何这些请求的记录 → RST 在 nginx→ingress 中间发出,不是 ingress 自己发的(ingress 自己 RST 会有连接记录)。叠加 host47 同网段 c=500 零错误(ingress 节点连接层扛得住),RST 源锁定为nginx(68.154)→ingress(10.202.17.x)跨网段路径上的中间设备。 - scan-block 上线后 attack 在 nginx 本地 444、不转发 ingress,到 ingress 的 TIME_WAIT 从洪流降到 23,502 同时消失 → 中间设备是基于攻击特征在拦(看不到特征就不拦)。
安全同事”没拦”是查错了范围——查了内网别的设备/公网入口,没查 68.154↔10.202.17.x 这段跨网段路径。
为什么负载和连接数都不高:请求根本没走到 ingress 处理。它在 nginx→ingress 跨网段途中就被中间设备 RST,或节点被 nginx 的 max_fails 摘掉。ingress 自始至终没收到这些请求,所以 CPU、连接数都用不上去。
根因与放大器(修正定性):
- 根因:中间设备基于攻击特征拦,对 nginx→ingress 的连接注入 RST。
- 放大器:nginx
max_fails=3把中间设备注入的偶发 RST 放大成整组节点误摘(no live upstreams → 502)。
keepalive 未生效(短连接)只是连接效率问题,不是这次 502 的主因——中间设备拦的是攻击特征,长连接短连接一样拦。keepalive 修复是常规优化,不要当成治 502 的本。
关键认知:
max_fails只是买时间,不是根治。根治得让攻击特征不进入 nginx→ingress 这段(scan-block 已做、最有效),或推动网络/安全团队对这段跨网段设备放宽——前面已有 WAF + scan-block 双重防护,这台中间设备再做特征拦是冗余且有害的。


四、解决方案
4.1 P0 止血一:调整 upstream 失败策略
upstream.conf(ingress + kj-ingress 一起改):
| 参数 | 原值 | 新值 | 说明 |
|---|---|---|---|
| max_fails | 3 | 30 | 拉高摘除阈值,攻击期更难误摘 |
| fail_timeout | 10s | 5s | 缩短恢复窗口,摘了也尽快回 |
upstream ingress {
server 10.202.17.1:80 max_fails=30 fail_timeout=5s;
server 10.202.17.2:80 max_fails=30 fail_timeout=5s;
server 10.202.17.3:80 max_fails=30 fail_timeout=5s;
keepalive 128;
}
4.2 P0 止血二:外层 nginx 拦截扫描特征(return 444)
抽 scan-block.conf,在各外网入口 server(xxx.com 的 8443 主入口)include。命中扫描特征直接 return 444 断连,不进 upstream、不计 max_fails,掐断 error 计数源头。原有的 sleep() 时间盲注拦截也并入,统一 444。
# scan-block.conf(节选)。业务不跑 PHP,.php 一律拦;/wp- 覆盖 WordPress 全家桶;/vendor 覆盖 Composer 扫描
if ($request_uri ~* "(\.\./|%2e%2e|/etc/passwd|/\.env|/\.git/|/\.svn/|/phpinfo|/wp-|/cgi-bin|/vendor|\.php|\$\{|base64_decode|@print|union[%20\s]+select|<script|%3cscript|sleep\s*\(|waitfor|pg_sleep\s*\()") {
return 444;
}
刻意不拦
.action,业务用 Struts/CAS,.action是正常后缀,一刀切会误杀。
4.3 常规优化:修 keepalive 陷阱(非 502 主因)
keepalive 未生效只是让短连接多、TIME_WAIT 堆积,不是这次 502 的主因(中间设备拦的是攻击特征,与连接长短无关)。修复它是常规连接优化,排进日常维护即可,别当成治 502 的本。每个走 ingress 的 location 显式补两行(或抽 snippet include):
proxy_http_version 1.1;
proxy_set_header Connection "";
4.4 根因侧:推动网络/安全团队查 nginx→ingress 跨网段中间设备
RST 源已锁定在 nginx(68.154)→ingress(10.202.17.x) 跨网段路径上的中间设备(ingress 侧无请求记录 + scan-block 拦特征后 502 即消,两个硬证据)。安全同事”没拦”是查错了范围。给网络/安全团队这两个证据,让他们定位是哪台设备(防火墙/IPS/SLB)、拦的什么策略,要么对这段流量放宽(前面已有 WAF + scan-block 双重防护),要么把拦截日志对上事故时间点。同时复核 WAF 加白策略(源头:渗透来源不该加白)。
4.5 nginx 侧对冲:proxy_next_upstream 重试
偶发 RST 时 nginx 自动重试下一个 ingress 节点,用户无感,不直接返 502。配在每个 ingress location 或 http 块全局:
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 3s;
注意:默认
proxy_next_upstream error timeout已会切节点,但解决不了”3 个节点全被摘”。它只让偶发失败时重试,防 502 主要还是靠 scan-block(不进 upstream)+ max_fails(延缓摘)。
4.6 监控告警 + 应急预案
- 监控:nginx error log 的
recv() failed/no live upstreams突增告警;WAF 拦截率突降告警(漏拦早发现,用数据代替人言)。 - 应急:502 再现第一时间两端抓包(swProxy2 + ingress 节点),对比 RST 是不是 ingress 发的;临时调大 max_fails / 拦攻击源 IP / 启 limit_req。
五、验证
- ✅ A 组压测:ingress 健康(1134 req/s、零错误,排除 ingress 侧)
- ✅ scan-block 444:已 reload 生效,attack 在 nginx 本地 444 不进 upstream,到 ingress TIME_WAIT 从洪流降到 23,502 同时消失(反向印证中间设备基于特征拦)
- ✅
max_fails=30:ingress + kj-ingress 已配 - ✅ 根因坐实:ingress 侧无请求记录 + scan-block 拦特征后 502 即消,两个硬证据锁定 RST 源在 nginx→ingress 跨网段中间设备
六、注意事项
444规则上线前必须 grep 历史 access log 确认零误杀,.action是业务正常后缀,不能一刀切。- reload 不 restart,每次留好回滚(备份
upstream.conf、注释include scan-block.conf行)。 max_fails调大只是缓解不是根治;nginx 侧(scan-block/max_fails/proxy_next_upstream)都是下游兜底,根因在中间设备基于特征拦 + WAF 加白放行,根治要推动网络/安全团队查那段设备,别只改 nginx 就当解决。
七、参考资料
- nginx upstream
max_fails/fail_timeout:http://nginx.org/en/docs/http/ngx_http_upstream_module.html#server - nginx
proxy_next_upstream默认值为error timeout - nginx upstream keepalive 生效要求(
proxy_http_version 1.1+Connection ""):http://nginx.org/en/docs/http/ngx_http_upstream_module.html#keepalive - 项目内记录:
nginx-upstream-keepalive-trap、gzgd-8443-port-mapping
八、附录:测试脚本与运行命令
8.1 攻击模拟脚本 attack_sim.py
复现 no live upstreams(502) 与 recv() failed (104),按错误类型区分 reset_by_peer / refused / timeout / http_502,通过同网段 vs 跨网段、normal vs attack 的对比定位问题层。仅用 Python3 标准库,合规上只对自有/已授权系统使用,用轻量路径、控制强度。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
attack_sim.py — Nginx/Ingress 链路攻击模拟排障工具(仅限授权测试)
目的:复现 "no live upstreams"(对外 502) 与
"recv() failed (104: Connection reset by peer)" 现象,
通过【同网段直打 ingress】与【跨网段经外层 nginx/直打 ingress】的对比,
定位问题在:ingress 自身、外层 nginx 误摘节点(max_fails)、还是中间安全设备。
合规:仅可对自有/已授权系统使用;用轻量路径;控制强度;避开业务高峰。
依赖:仅 Python3 标准库。
"""
import argparse
import random
import socket
import ssl
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
# 轻量正常路径:优先静态/重定向/404 类,避免打到 Java/DB 等重逻辑
NORMAL_PATHS = ["/", "/cas", "/openapiGateway", "/favicon.ico", "/robots.txt"]
# 攻击/扫描特征路径:用于触发 IPS/WAF/特征引擎的拦截策略
ATTACK_PATHS = [
"/etc/passwd", "/../../../etc/passwd", "/.%2e/.%2e/.%2e/etc/passwd",
"/member/index.action", "/index.action", "/struts/webconsole.html",
"/cgi-bin/php", "/phpinfo.php", "/.env", "/.git/config", "/wp-admin/",
"/?id=1'%20or%20'1'='1",
"/?q=${@print(md5(123))}",
"/?redirect:${#a=@java.lang.Runtime",
"/?assert(base64_decode(ZXZhbCgkX1BPU1RbY10pKQ==))",
]
stats = {}
stats_lock = threading.Lock()
stop_event = threading.Event()
def init_stats():
return {k: 0 for k in [
"total", "success_2xx", "redirect_3xx", "client_4xx",
"http_502", "http_503", "http_504", "server_5xx_other",
"reset_by_peer", "refused", "timeout", "other_error"]}
def tally(key):
# 每个请求只计 1 次 total,分项单独累计,避免重复计数
with stats_lock:
stats["total"] += 1
stats[key] += 1
def tally_status(code):
if 200 <= code < 300: tally("success_2xx")
elif 300 <= code < 400: tally("redirect_3xx")
elif 400 <= code < 500: tally("client_4xx")
elif code == 502: tally("http_502") # nginx no live upstreams
elif code == 503: tally("http_503")
elif code == 504: tally("http_504")
elif 500 <= code < 600: tally("server_5xx_other")
else: tally("other_error")
def build_opener(insecure):
# 关闭自动重定向,便于观察到 3xx(部分业务用 302 跳转)
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
opener = urllib.request.build_opener(NoRedirect)
if insecure:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE # 仅排障,默认不开启
opener.add_handler(urllib.request.HTTPSHandler(context=ctx))
return opener
def worker(base_url, mode, timeout, headers, insecure):
opener = build_opener(insecure)
pool = NORMAL_PATHS if mode == "normal" else \
ATTACK_PATHS if mode == "attack" else NORMAL_PATHS + ATTACK_PATHS
while not stop_event.is_set():
url = base_url.rstrip("/") + random.choice(pool)
req = urllib.request.Request(url, headers=headers, method="GET")
try:
with opener.open(req, timeout=timeout) as resp:
tally_status(resp.status)
except urllib.error.HTTPError as e:
tally_status(e.code)
except ConnectionResetError:
tally("reset_by_peer")
except ConnectionRefusedError:
tally("refused")
except (socket.timeout, TimeoutError):
tally("timeout")
except urllib.error.URLError as e:
r = e.reason
if isinstance(r, ConnectionResetError): tally("reset_by_peer")
elif isinstance(r, ConnectionRefusedError): tally("refused")
elif isinstance(r, (socket.timeout, TimeoutError)): tally("timeout")
else: tally("other_error")
except Exception:
tally("other_error")
def reporter():
keys = ["total", "http_502", "reset_by_peer", "refused", "timeout"]
while not stop_event.is_set():
time.sleep(5)
with stats_lock:
print(" [进度] " + " ".join(f"{k}={stats[k]}" for k in keys), flush=True)
def main():
global stats
stats = init_stats()
ap = argparse.ArgumentParser(description="Nginx/Ingress 攻击链路模拟(仅限授权排障)")
ap.add_argument("-u", "--url", required=True,
help="目标基址,如 http://10.202.17.1:80 或 https://xxx.com")
ap.add_argument("-c", "--concurrency", type=int, default=100, help="并发线程数")
ap.add_argument("-d", "--duration", type=int, default=30, help="持续秒数")
ap.add_argument("-m", "--mode", choices=["normal", "attack", "mixed"],
default="mixed", help="normal=正常路径 attack=攻击特征 mixed=混合")
ap.add_argument("-t", "--timeout", type=float, default=5.0, help="单请求超时(秒)")
ap.add_argument("-H", "--header", action="append", default=[],
help="自定义请求头,可多次。必填 Host,如 -H 'Host: xxx.com'")
ap.add_argument("--insecure", action="store_true", help="跳过 TLS 证书校验(仅排障)")
args = ap.parse_args()
headers = {"User-Agent": "attack-sim/1.0 (authorized-test)"}
for h in args.header:
if ":" in h:
k, v = h.split(":", 1)
headers[k.strip()] = v.strip()
print(f"[目标] {args.url}")
print(f"[参数] 并发={args.concurrency} 时长={args.duration}s 模式={args.mode} 超时={args.timeout}s")
print(f"[Host] {headers.get('Host', '(未指定,可能命中默认 server)')}")
print("[提示] Ctrl-C 提前停止;务必用 timeout 兜底运行。")
print("-" * 64)
threading.Thread(target=reporter, daemon=True).start()
start = time.time()
end = start + args.duration
try:
with ThreadPoolExecutor(max_workers=args.concurrency) as ex:
for _ in range(args.concurrency):
ex.submit(worker, args.url, args.mode, args.timeout, headers, args.insecure)
while time.time() < end:
time.sleep(0.3)
stop_event.set()
except KeyboardInterrupt:
print("\n[中断] 收到 Ctrl-C,停止中(最多等单请求超时)...")
stop_event.set()
elapsed = time.time() - start
with stats_lock:
s = dict(stats)
print("-" * 64)
rps = s["total"] / elapsed if elapsed > 0 else 0
print(f"[汇总] 运行 {elapsed:.1f}s | 总请求 {s['total']} | 吞吐 ≈ {rps:.1f} req/s")
print("结果分布:")
for k in ["success_2xx", "redirect_3xx", "client_4xx",
"http_502", "http_503", "http_504", "server_5xx_other",
"reset_by_peer", "refused", "timeout", "other_error"]:
if s[k]:
print(f" {k:18s}: {s[k]}")
print("-" * 64)
print("[解读]")
print(" http_502 多 → 外层 nginx 把 ingress 节点摘光(no live upstreams)")
print(" reset_by_peer 多 → 链路中被 RST(中间设备/ingress 侧)")
print(" refused 多 → 目标端口未监听/被防火墙直接拒")
print(" 同网段正常、跨网段 reset/502 多 → 锁定中间安全设备")
if __name__ == "__main__":
main()
8.2 验证测试运行命令
三组对照,都用 timeout 兜底;跑完查残留 pgrep -fl attack_sim.py,有则 pkill -f attack_sim.py。
# A 组|同网段直打 ingress,normal(验证 ingress 自身健康)—— 已执行,零错误
timeout 40s python3 attack_sim.py \
-u http://10.202.17.1:80 -c 50 -d 30 -m normal \
-H "Host: xxx.com"
# D 组|跨网段机执行,直打 ingress,mixed(绕过外层 nginx,验证中间设备是否拦)
timeout 65s python3 attack_sim.py \
-u http://10.202.17.1:80 -c 200 -d 60 -m mixed \
-H "Host: xxx.com"
# C 组|跨网段机执行,经外层 nginx,mixed(验证是否触发 502 误摘)
timeout 65s python3 attack_sim.py \
-u https://xxx.com -c 200 -d 60 -m mixed \
-H "Host: xxx.com"
8.3 结果判读
http_502多 → 外层 nginx 把 ingress 节点摘光(no live upstreams)。reset_by_peer多 → 连接被 RST(中间设备或 ingress 侧)。refused多 → 端口未监听 / 被防火墙直接拒。timeout多 → 中间设备静默丢包。- A 组正常、D 组或 C 组 reset/502 激增 → 问题在跨网段中间设备或外层 nginx 误摘,不在 ingress 本身。
- C 组专项判读(区分”外层 nginx 误摘”还是”中间设备 RST”):
http_502激增 → 坐实外层 nginx 把 ingress 节点摘光(no live upstreams),重点调max_fails/fail_timeout+ 特征 444 拦截。reset_by_peer多但http_502少 → 中间设备在 RST,但节点还没被摘到全 down,此时业务仍可用、只是零星失败;max_fails调大可延缓演变成 502。- 两者都高 → 中间设备 RST 已经把失败计数推过
max_fails阈值,节点被摘,是上面两层叠加。