コンテンツへスキップ
ブログに戻る
6 分で読めます

Watching every port with eBPF: detect binds the moment they happen

eBPF
Linux
Observability
Security

"What is listening on this box, and when did that change?" is a question you ask during an incident, a security review, or just while chasing a mystery port. The usual answers — ss -tlnp, netstat, lsof -i on a cron — all share the same flaw: they sample. Run them every 5 seconds and a process that binds a port, does its thing, and exits in between is invisible. Crank the interval down and you pay for it in CPU and still miss the fast ones. Sampling can't see events; it can only see state that happens to still be there when you look.

eBPF flips this around. Instead of asking the kernel "what's true right now?" over and over, you ask it to tell you the moment something changes — with the port, the PID, and the process name attached. No polling, no gaps, near-zero overhead.

What eBPF actually is

eBPF lets you load a small, sandboxed program into the running Linux kernel and attach it to a hook — a tracepoint, a kprobe on a kernel function, a network event. When that hook fires, your program runs in kernel context, can read the relevant data, and can ship a compact event up to a userspace process. A verifier checks your program before it loads (bounded loops, no wild pointers), so a bug can't panic the kernel.

The important part for us: this is the same machinery tcpdump, bpftrace, and most modern observability agents are built on. It's stable, it's in every kernel since ~4.x, and it's the right tool for "notice this the instant it happens."

The kernel signal for a port bind

When a TCP service starts listening, its socket moves into the LISTEN state. When it stops, the socket goes LISTEN → CLOSE. Since Linux 4.16 there's a stable tracepoint that fires on every TCP socket state change:

sock:inet_sock_set_state

It hands you the old state, the new state, the ports, the address family, and the protocol — everything needed to classify the transition. The state numbers we care about (from the kernel's TCP state enum):

State Value Meaning
TCP_ESTABLISHED 1 a connection is up
TCP_CLOSE 7 socket closed
TCP_LISTEN 10 service is now listening (a bind)

So the rules are simple:

  • → LISTEN  = a service just bound a port
  • LISTEN → CLOSE = it just released it
  • → ESTABLISHED / a live socket → CLOSE = a connection came up or went down

A working detector in ten lines of bpftrace

bpftrace is the quickest way to try this — it compiles from the kernel's BTF type info, so on a modern kernel you need no headers and no build step. Save this as ports.bt:

#!/usr/bin/env bpftrace
// Every TCP socket state change on the host.
tracepoint:sock:inet_sock_set_state
/args->protocol == 6/          // 6 = IPPROTO_TCP
{
    // 10 = TCP_LISTEN, 7 = TCP_CLOSE
    if (args->newstate == 10) {
        printf("BIND    %-16s pid=%-6d port=%d\n", comm, pid, args->sport);
    } else if (args->oldstate == 10 && args->newstate == 7) {
        printf("UNBIND  %-16s pid=%-6d port=%d\n", comm, pid, args->sport);
    }
}

Run it, then start any server in another terminal:

sudo bpftrace ports.bt
# in another shell:  python3 -m http.server 8080
BIND    python3          pid=48213  port=8080
UNBIND  python3          pid=48213  port=8080

That's the whole idea. The event arrives the instant the socket enters LISTEN — not up to 5 seconds later, and not "never" for a process that already exited. Drop the protocol filter's if guards and you can watch CONNECT/DISCONNECT too, since the same tracepoint sees → ESTABLISHED and the teardown to CLOSE.

From a one-liner to something you'd actually run

The bpftrace script proves the signal, but a real deployment wants more: the full process name and command line, the /etc/services name for the port (22 → ssh), the owning user and systemd unit, structured JSON for your log pipeline, and it needs to survive a busy host without losing events. That work happens in userspace, after the kernel hands the raw event up a perf buffer:

kernel tracepoint ──perf buffer──▶ userspace: enrich (/proc, services) ──▶ JSON + log

I packaged exactly this into a small, installable tool — ebpf-port-monitoring — that turns each bind/unbind (and optional connect/disconnect) into a line like:

{"timestamp":"2026-08-22T13:04:11Z","action":"BIND","protocol":"tcp","port":8080,"pid":48213,"process":"python3","service":"http-alt","systemd_unit":"myapp.service","user":"deploy"}

It uses BCC so the probe compiles against the running kernel (one install works across distros), ships the header-free bpftrace backend above for hosts without kernel headers, and installs as a systemd/OpenRC service that starts on boot.

What about UDP?

UDP has no state machine — no LISTEN, so the TCP tracepoint never sees it. To catch a UDP bind (DNS, DHCP, QUIC…) you attach a kprobe to the kernel's inet_bind function instead and read the protocol and port off the socket:

kprobe:inet_bind
{
    // sock->sk->sk_protocol == 17 (IPPROTO_UDP)
    // read the port from the sockaddr and emit a BIND event
}

Because a kprobe reads kernel structs directly, this path does need the kernel headers for your running kernel — which is why UDP support is usually opt-in while the header-free TCP tracepoint is the default.

The gotchas nobody mentions

A few things that will bite you if you skip them:

  • Lost events under load. The perf ring buffer is finite. If you enable connection tracking on a busy server, bursts can overflow it — count and report the drops, and move enrichment/writing off the callback thread so the buffer keeps draining.
  • Container PID namespaces. bpf_get_current_pid_tgid() returns the host PID. Inside a container without the host PID namespace, /proc/<pid> lookups come back empty — run the collector with --pid=host (Docker) or hostPID: true (Kubernetes).
  • Teardown context. Bind events fire in the binding process's context, so the PID is reliable. Some teardown paths run in a softirq, so pid there may be a kernel worker — fall back to the eBPF-captured comm.
  • Privileges. Loading BPF needs root, or CAP_BPF + CAP_PERFMON on kernel 5.8+ (or CAP_SYS_ADMIN on older ones), plus a mounted debugfs/tracefs.

Why this matters

Once you're getting a precise event for every port lifecycle change, useful things fall out for free: a live audit trail of what listened and when, detection of a process that opens a port it never should, and a way to catch the short-lived binds that sampling tools structurally cannot see. The kernel already knows the moment a port opens — eBPF is just how you get it to tell you.


Get the code. The full, installable tool — BCC + the header-free bpftrace backend, JSON/log output, rotation, metrics, and a docker-compose that runs it across several Linux distros — is open source: github.com/shivamkumar99/ebpf-port-monitoring.