From e489a28c0897ee48ea5a0b200448eee8c08f9e03 Mon Sep 17 00:00:00 2001 From: LinuxDev9002 Date: Sat, 18 Jul 2026 01:42:39 -0700 Subject: [PATCH] Add targeted TCP connection quarantine tool --- .github/workflows/test-libbpf.yml | 5 + src/51-tcp-quarantine/.config | 10 + src/51-tcp-quarantine/.gitignore | 12 ++ src/51-tcp-quarantine/Makefile | 92 +++++++++ src/51-tcp-quarantine/tcp_quarantine.bpf.c | 57 ++++++ src/51-tcp-quarantine/tcp_quarantine.c | 188 ++++++++++++++++++ src/51-tcp-quarantine/tcp_quarantine.h | 13 ++ .../tests/test_tcp_quarantine.py | 109 ++++++++++ 8 files changed, 486 insertions(+) create mode 100644 src/51-tcp-quarantine/.config create mode 100644 src/51-tcp-quarantine/.gitignore create mode 100644 src/51-tcp-quarantine/Makefile create mode 100644 src/51-tcp-quarantine/tcp_quarantine.bpf.c create mode 100644 src/51-tcp-quarantine/tcp_quarantine.c create mode 100644 src/51-tcp-quarantine/tcp_quarantine.h create mode 100644 src/51-tcp-quarantine/tests/test_tcp_quarantine.py diff --git a/.github/workflows/test-libbpf.yml b/.github/workflows/test-libbpf.yml index c90df60..864ecab 100644 --- a/.github/workflows/test-libbpf.yml +++ b/.github/workflows/test-libbpf.yml @@ -179,6 +179,11 @@ jobs: run: | make -C src/50-tcx + - name: test 51 TCP quarantine + run: | + make -C src/51-tcp-quarantine + sudo make -C src/51-tcp-quarantine test + - name: test features bpf_token run: | make -C src/features/bpf_token diff --git a/src/51-tcp-quarantine/.config b/src/51-tcp-quarantine/.config new file mode 100644 index 0000000..c36f54f --- /dev/null +++ b/src/51-tcp-quarantine/.config @@ -0,0 +1,10 @@ +level=Depth +type=Security +kernel_min=6.5 +kernel_min_basis=documented +architectures=x86_64 +btf=required +kernel_config=CONFIG_BPF=y,CONFIG_BPF_SYSCALL=y,CONFIG_DEBUG_INFO_BTF=y,CONFIG_INET=y +hardware=none +root=required +test_status=ci-runtime diff --git a/src/51-tcp-quarantine/.gitignore b/src/51-tcp-quarantine/.gitignore new file mode 100644 index 0000000..5f2d486 --- /dev/null +++ b/src/51-tcp-quarantine/.gitignore @@ -0,0 +1,12 @@ +# Build artifacts +.output/ +*.o +*.skel.h + +# Generated binaries +tcp_quarantine + +# Editor files +*.swp +*~ +.vscode/ diff --git a/src/51-tcp-quarantine/Makefile b/src/51-tcp-quarantine/Makefile new file mode 100644 index 0000000..89a8863 --- /dev/null +++ b/src/51-tcp-quarantine/Makefile @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +OUTPUT := .output +CLANG ?= clang +LIBBPF_SRC := $(abspath ../third_party/libbpf/src) +BPFTOOL_SRC := $(abspath ../third_party/bpftool/src) +LIBBPF_OBJ := $(abspath $(OUTPUT)/libbpf.a) +BPFTOOL_OUTPUT ?= $(abspath $(OUTPUT)/bpftool) +BPFTOOL ?= $(BPFTOOL_OUTPUT)/bootstrap/bpftool +ARCH ?= $(shell uname -m | sed 's/x86_64/x86/' \ + | sed 's/arm.*/arm/' \ + | sed 's/aarch64/arm64/' \ + | sed 's/ppc64le/powerpc/' \ + | sed 's/mips.*/mips/' \ + | sed 's/riscv64/riscv/' \ + | sed 's/loongarch64/loongarch/') +VMLINUX := ../third_party/vmlinux/$(ARCH)/vmlinux.h +INCLUDES := -I$(OUTPUT) -I../third_party/libbpf/include/uapi -I$(dir $(VMLINUX)) -I. +CFLAGS := -g -Wall +ALL_LDFLAGS := $(LDFLAGS) $(EXTRA_LDFLAGS) + +APPS = tcp_quarantine + +CLANG_BPF_SYS_INCLUDES ?= $(shell $(CLANG) -v -E - &1 \ + | sed -n '/<...> search starts here:/,/End of search list./{ s| \(/.*\)|-idirafter \1|p }') + +ifeq ($(V),1) + Q = + msg = +else + Q = @ + msg = @printf ' %-8s %s%s\n' "$(1)" \ + "$(patsubst $(abspath $(OUTPUT))/%,%,$(2))" \ + "$(if $(3), $(3))"; + MAKEFLAGS += --no-print-directory +endif + +define allow-override + $(if $(or $(findstring environment,$(origin $(1))),\ + $(findstring command line,$(origin $(1)))),,\ + $(eval $(1) = $(2))) +endef + +$(call allow-override,CC,$(CROSS_COMPILE)cc) +$(call allow-override,LD,$(CROSS_COMPILE)ld) + +.PHONY: all clean test +all: $(APPS) + +clean: + $(call msg,CLEAN) + $(Q)rm -rf $(OUTPUT) $(APPS) + +test: all + python3 tests/test_tcp_quarantine.py ./tcp_quarantine + +$(OUTPUT) $(OUTPUT)/libbpf $(BPFTOOL_OUTPUT): + $(call msg,MKDIR,$@) + $(Q)mkdir -p $@ + +$(LIBBPF_OBJ): $(wildcard $(LIBBPF_SRC)/*.[ch] $(LIBBPF_SRC)/Makefile) | $(OUTPUT)/libbpf + $(call msg,LIB,$@) + $(Q)$(MAKE) -C $(LIBBPF_SRC) BUILD_STATIC_ONLY=1 \ + OBJDIR=$(dir $@)/libbpf DESTDIR=$(dir $@) \ + INCLUDEDIR= LIBDIR= UAPIDIR= install + +$(BPFTOOL): | $(BPFTOOL_OUTPUT) + $(call msg,BPFTOOL,$@) + $(Q)$(MAKE) ARCH= CROSS_COMPILE= OUTPUT=$(BPFTOOL_OUTPUT)/ -C $(BPFTOOL_SRC) bootstrap + +$(OUTPUT)/%.bpf.o: %.bpf.c $(LIBBPF_OBJ) $(wildcard %.h) $(VMLINUX) | $(OUTPUT) $(BPFTOOL) + $(call msg,BPF,$@) + $(Q)$(CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) \ + $(INCLUDES) $(CLANG_BPF_SYS_INCLUDES) \ + -c $(filter %.c,$^) -o $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + $(Q)$(BPFTOOL) gen object $@ $(patsubst %.bpf.o,%.tmp.bpf.o,$@) + +$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o | $(OUTPUT) $(BPFTOOL) + $(call msg,GEN-SKEL,$@) + $(Q)$(BPFTOOL) gen skeleton $< > $@ + +$(patsubst %,$(OUTPUT)/%.o,$(APPS)): %.o: %.skel.h + +$(OUTPUT)/%.o: %.c $(wildcard %.h) | $(OUTPUT) + $(call msg,CC,$@) + $(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $(filter %.c,$^) -o $@ + +$(APPS): %: $(OUTPUT)/%.o $(LIBBPF_OBJ) | $(OUTPUT) + $(call msg,BINARY,$@) + $(Q)$(CC) $(CFLAGS) $^ $(ALL_LDFLAGS) -lelf -lz -o $@ + +.DELETE_ON_ERROR: +.SECONDARY: diff --git a/src/51-tcp-quarantine/tcp_quarantine.bpf.c b/src/51-tcp-quarantine/tcp_quarantine.bpf.c new file mode 100644 index 0000000..2c3eedf --- /dev/null +++ b/src/51-tcp-quarantine/tcp_quarantine.bpf.c @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-2.0 +#include "vmlinux.h" +#include +#include +#include +#include "tcp_quarantine.h" + +#define AF_INET 2 +#define TCP_ESTABLISHED 1 + +char LICENSE[] SEC("license") = "GPL"; + +const volatile __u32 target_addr; +const volatile __u16 target_port; +const volatile bool apply; + +struct quarantine_stats stats; + +extern int bpf_sock_destroy(struct sock_common *sock) __weak __ksym; + +SEC("iter/tcp") +int quarantine_tcp(struct bpf_iter__tcp *ctx) +{ + struct sock_common *sk = ctx->sk_common; + __u32 dst_addr; + __u16 dst_port; + __u16 family; + __u8 state; + int err; + + if (!sk) + return 0; + + stats.scanned++; + family = BPF_CORE_READ(sk, skc_family); + state = BPF_CORE_READ(sk, skc_state); + if (family != AF_INET || state != TCP_ESTABLISHED) + return 0; + + stats.established++; + dst_addr = BPF_CORE_READ(sk, skc_daddr); + dst_port = BPF_CORE_READ(sk, skc_dport); + if (dst_addr != target_addr || dst_port != bpf_htons(target_port)) + return 0; + + stats.matched++; + if (!apply) + return 0; + + err = bpf_sock_destroy(sk); + if (err) + stats.failed++; + else + stats.destroyed++; + + return 0; +} diff --git a/src/51-tcp-quarantine/tcp_quarantine.c b/src/51-tcp-quarantine/tcp_quarantine.c new file mode 100644 index 0000000..543f6e2 --- /dev/null +++ b/src/51-tcp-quarantine/tcp_quarantine.c @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "tcp_quarantine.h" +#include "tcp_quarantine.skel.h" + +static struct env { + const char *destination; + unsigned int port; + bool apply; + bool verbose; +} env; + +static int libbpf_print_fn(enum libbpf_print_level level, const char *format, + va_list args) +{ + if (level == LIBBPF_DEBUG && !env.verbose) + return 0; + return vfprintf(stderr, format, args); +} + +static void usage(const char *program) +{ + fprintf(stderr, + "Usage: %s --destination IPv4 --port PORT [--apply] [--verbose]\n" + "\n" + "Find established TCP client connections to an exact destination.\n" + "The default is a safe dry run; --apply destroys matching sockets.\n" + "\n" + "Options:\n" + " -d, --destination IPv4 exact remote IPv4 address\n" + " -p, --port PORT exact remote TCP port (1-65535)\n" + " -a, --apply destroy matching sockets\n" + " -v, --verbose print libbpf diagnostics\n" + " -h, --help show this help\n", + program); +} + +static int parse_port(const char *value, unsigned int *port) +{ + char *end = NULL; + unsigned long parsed; + + errno = 0; + parsed = strtoul(value, &end, 10); + if (errno || !end || *end || parsed == 0 || parsed > 65535) + return -EINVAL; + *port = parsed; + return 0; +} + +static int parse_args(int argc, char **argv) +{ + static const struct option options[] = { + { "destination", required_argument, NULL, 'd' }, + { "port", required_argument, NULL, 'p' }, + { "apply", no_argument, NULL, 'a' }, + { "verbose", no_argument, NULL, 'v' }, + { "help", no_argument, NULL, 'h' }, + {}, + }; + int option; + + while ((option = getopt_long(argc, argv, "d:p:avh", options, NULL)) != -1) { + switch (option) { + case 'd': + env.destination = optarg; + break; + case 'p': + if (parse_port(optarg, &env.port)) { + fprintf(stderr, "invalid TCP port: %s\n", optarg); + return -EINVAL; + } + break; + case 'a': + env.apply = true; + break; + case 'v': + env.verbose = true; + break; + case 'h': + usage(argv[0]); + exit(0); + default: + return -EINVAL; + } + } + + if (!env.destination || !env.port || optind != argc) + return -EINVAL; + return 0; +} + +static int run_iterator(struct bpf_program *program) +{ + struct bpf_link *link; + char buffer[256]; + int iter_fd, length, err; + + link = bpf_program__attach_iter(program, NULL); + err = libbpf_get_error(link); + if (err) { + fprintf(stderr, "failed to attach TCP iterator: %s\n", strerror(-err)); + return err; + } + + iter_fd = bpf_iter_create(bpf_link__fd(link)); + if (iter_fd < 0) { + err = iter_fd; + fprintf(stderr, "failed to create TCP iterator: %s\n", strerror(-err)); + goto cleanup; + } + + while ((length = read(iter_fd, buffer, sizeof(buffer))) > 0) + ; + if (length < 0) { + err = -errno; + fprintf(stderr, "failed while scanning TCP sockets: %s\n", strerror(errno)); + } + close(iter_fd); + +cleanup: + bpf_link__destroy(link); + return err; +} + +int main(int argc, char **argv) +{ + struct tcp_quarantine_bpf *skel = NULL; + struct in_addr destination; + int err; + + err = parse_args(argc, argv); + if (err) { + usage(argv[0]); + return 1; + } + if (inet_pton(AF_INET, env.destination, &destination) != 1) { + fprintf(stderr, "invalid IPv4 destination: %s\n", env.destination); + return 1; + } + + libbpf_set_print(libbpf_print_fn); + skel = tcp_quarantine_bpf__open(); + if (!skel) { + fprintf(stderr, "failed to open BPF skeleton\n"); + return 1; + } + + skel->rodata->target_addr = destination.s_addr; + skel->rodata->target_port = env.port; + skel->rodata->apply = env.apply; + + err = tcp_quarantine_bpf__load(skel); + if (err) { + fprintf(stderr, + "failed to load TCP quarantine program: %s\n" + "This tool requires Linux 6.5+ with BTF, TCP BPF iterators, " + "and the bpf_sock_destroy kfunc.\n", + strerror(-err)); + goto cleanup; + } + + err = run_iterator(skel->progs.quarantine_tcp); + if (err) + goto cleanup; + + printf("mode=%s destination=%s:%u scanned=%llu established=%llu " + "matched=%llu destroyed=%llu failed=%llu\n", + env.apply ? "apply" : "dry-run", env.destination, env.port, + skel->bss->stats.scanned, skel->bss->stats.established, + skel->bss->stats.matched, skel->bss->stats.destroyed, + skel->bss->stats.failed); + if (skel->bss->stats.failed) + err = -EIO; + +cleanup: + tcp_quarantine_bpf__destroy(skel); + return err != 0; +} diff --git a/src/51-tcp-quarantine/tcp_quarantine.h b/src/51-tcp-quarantine/tcp_quarantine.h new file mode 100644 index 0000000..586b011 --- /dev/null +++ b/src/51-tcp-quarantine/tcp_quarantine.h @@ -0,0 +1,13 @@ +/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */ +#ifndef __TCP_QUARANTINE_H +#define __TCP_QUARANTINE_H + +struct quarantine_stats { + unsigned long long scanned; + unsigned long long established; + unsigned long long matched; + unsigned long long destroyed; + unsigned long long failed; +}; + +#endif /* __TCP_QUARANTINE_H */ diff --git a/src/51-tcp-quarantine/tests/test_tcp_quarantine.py b/src/51-tcp-quarantine/tests/test_tcp_quarantine.py new file mode 100644 index 0000000..de76217 --- /dev/null +++ b/src/51-tcp-quarantine/tests/test_tcp_quarantine.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""End-to-end smoke test for the TCP quarantine tool.""" + +from __future__ import annotations + +import errno +import pathlib +import socket +import subprocess +import sys +import time + + +BROKEN_ERRORS = {errno.ECONNABORTED, errno.ECONNRESET, errno.EPIPE} + + +def open_connection() -> tuple[socket.socket, socket.socket, socket.socket, int]: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + client = socket.create_connection(listener.getsockname(), timeout=2) + server, _ = listener.accept() + client.settimeout(1) + server.settimeout(1) + return listener, client, server, listener.getsockname()[1] + + +def round_trip(client: socket.socket, server: socket.socket, payload: bytes) -> None: + client.sendall(payload) + assert server.recv(len(payload)) == payload + server.sendall(payload) + assert client.recv(len(payload)) == payload + + +def run_tool(binary: pathlib.Path, port: int, apply: bool) -> str: + command = [str(binary), "--destination", "127.0.0.1", "--port", str(port)] + if apply: + command.append("--apply") + result = subprocess.run(command, check=True, text=True, capture_output=True) + print(result.stdout, end="") + return result.stdout + + +def wait_for_destroy(client: socket.socket) -> None: + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + try: + client.send(b"x") + except OSError as error: + if error.errno in BROKEN_ERRORS: + return + raise + error = client.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) + if error in BROKEN_ERRORS: + return + time.sleep(0.05) + raise AssertionError("quarantined TCP socket remained usable") + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit(f"usage: {sys.argv[0]} /path/to/tcp_quarantine") + if not hasattr(socket, "SO_ERROR"): + raise SystemExit("SO_ERROR is unavailable") + + binary = pathlib.Path(sys.argv[1]).resolve() + if not binary.is_file(): + raise SystemExit(f"missing binary: {binary}") + + invalid = subprocess.run( + [str(binary), "--destination", "not-an-ip", "--port", "1"], + text=True, + capture_output=True, + ) + assert invalid.returncode != 0 + assert "invalid IPv4 destination" in invalid.stderr + + target = open_connection() + control = open_connection() + all_sockets = target[:3] + control[:3] + try: + round_trip(target[1], target[2], b"before-target") + round_trip(control[1], control[2], b"before-control") + + dry_run = run_tool(binary, target[3], apply=False) + assert "mode=dry-run" in dry_run + assert "matched=1" in dry_run + assert "destroyed=0" in dry_run + round_trip(target[1], target[2], b"after-dry-run") + round_trip(control[1], control[2], b"control-after-dry-run") + + applied = run_tool(binary, target[3], apply=True) + assert "mode=apply" in applied + assert "matched=1" in applied + assert "destroyed=1" in applied + assert "failed=0" in applied + wait_for_destroy(target[1]) + round_trip(control[1], control[2], b"control-survives") + finally: + for sock in all_sockets: + sock.close() + + print("PASS: dry-run preserved both connections; apply destroyed only the target") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())