Add targeted TCP connection quarantine tool

This commit is contained in:
LinuxDev9002
2026-07-18 01:42:39 -07:00
parent c054586a39
commit e489a28c08
8 changed files with 486 additions and 0 deletions

View File

@@ -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

View File

@@ -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

12
src/51-tcp-quarantine/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Build artifacts
.output/
*.o
*.skel.h
# Generated binaries
tcp_quarantine
# Editor files
*.swp
*~
.vscode/

View File

@@ -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 - </dev/null 2>&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:

View File

@@ -0,0 +1,57 @@
// SPDX-License-Identifier: GPL-2.0
#include "vmlinux.h"
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_endian.h>
#include <bpf/bpf_helpers.h>
#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;
}

View File

@@ -0,0 +1,188 @@
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
#include <arpa/inet.h>
#include <errno.h>
#include <getopt.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#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;
}

View File

@@ -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 */

View File

@@ -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())