WAF加白放行渗透流量引发nginx到ingress异常连接的排查

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

「中间设备拦」已坐实(两个硬证据)

  1. 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) 跨网段路径上的中间设备
  2. 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 双重防护,这台中间设备再做特征拦是冗余且有害的。

image-202607311111111

image-202607322222222

四、解决方案

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-trapgzgd-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 阈值,节点被摘,是上面两层叠加。
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇