XCloak / blog
← All posts
2026-07-03 · Engineering · 6 min read

Building a real-time eBPF connection collector in Go

Most endpoint agents watch network connections by polling — run ss or read /proc/net/tcp every few seconds and diff the results. It works, but it means a short-lived connection can open and close entirely between two polling intervals and never show up anywhere. For a security agent, that's exactly the kind of gap an attacker's tooling is likely to fall into — C2 beacons and scanning tools often make short, deliberate connections.

XCloak's Linux agent uses a different approach for its primary collection path: a kprobe on tcp_v4_connect, streaming events through an eBPF ring buffer as they happen, with a documented fallback to the polling approach when eBPF isn't usable. This post is about that design — what it buys you, what it costs, and where it breaks.

Why not just poll faster?

You can shrink the polling interval, but you're trading CPU and I/O for coverage, and you still can't observe anything shorter than your interval. Kernel-level instrumentation sidesteps the tradeoff entirely: the kernel tells you the moment a connection attempt happens, with the ordering intact.

The kprobe/kretprobe approach

The naive version of this — a single kprobe on tcp_v4_connect that reads the socket fields immediately — doesn't actually give you reliable process attribution, because the socket isn't fully populated yet at function entry. XCloak's collector instead uses a kprobe/kretprobe pair: the entry probe stashes the sock* pointer keyed by the calling thread's pid/tgid, and the return probe looks it back up and reads the now-populated address and port fields — but only if the connect actually succeeded.

SEC("kprobe/tcp_v4_connect")
int BPF_KPROBE(trace_tcp_v4_connect, struct sock *sk) {
    __u64 pid_tgid = bpf_get_current_pid_tgid();
    __u64 skp = (__u64)sk;
    bpf_map_update_elem(&sock_by_thread, &pid_tgid, &skp, BPF_ANY);
    return 0;
}

SEC("kretprobe/tcp_v4_connect")
int BPF_KRETPROBE(trace_tcp_v4_connect_ret, int ret) {
    __u64 pid_tgid = bpf_get_current_pid_tgid();
    __u64 *skp = bpf_map_lookup_elem(&sock_by_thread, &pid_tgid);
    if (!skp) return 0;

    struct sock *sk = (struct sock *)(*skp);
    bpf_map_delete_elem(&sock_by_thread, &pid_tgid);
    if (ret != 0) return 0;  // connect() failed synchronously — nothing to report

    struct event *ev = bpf_ringbuf_reserve(&events, sizeof(*ev), 0);
    if (!ev) return 0;

    ev->pid = pid_tgid >> 32;
    ev->uid = (__u32)bpf_get_current_uid_gid();
    ev->daddr = BPF_CORE_READ(sk, __sk_common.skc_daddr);
    ev->dport = BPF_CORE_READ(sk, __sk_common.skc_dport);
    bpf_get_current_comm(&ev->comm, sizeof(ev->comm));
    bpf_ringbuf_submit(ev, 0);
    return 0;
}

The BPF_CORE_READ macro (rather than a raw pointer dereference) is what makes this portable across kernel versions without recompiling per-target — it's the standard CO-RE (Compile Once, Run Everywhere) pattern for eBPF, relying on the kernel's own BTF type information to resolve struct field offsets at load time instead of baking them in at compile time.

On the Go side, a read loop drains the ring buffer and batches events (50 per batch, or every 5 seconds, whichever comes first) before sending them onward — the same pattern used elsewhere in the agent, avoiding a network round-trip per connection.

Where this breaks

Two things have to be true for this collector to run at all:

If either check fails, the agent logs it once and falls back to the existing periodic ss-based snapshot collector for that host — same host, same detection pipeline downstream, just without per-connection pid/uid attribution or the ability to see anything shorter than the poll interval.

The fallback isn't a "lesser" data source bolted on as an afterthought — it's the collector that was already there, and the eBPF path is additive on top of it where the kernel allows it.

What this actually buys you

In practice, the difference shows up most clearly on short-lived connections — a script making one outbound request and exiting, or a scanning tool cycling through ports quickly. The polling fallback will still catch sustained or repeated behavior (which is what most of XCloak's network detectors are looking for anyway — beaconing intervals, port-scan patterns), but the eBPF path catches the individual event as it happens, which matters more for exact timing correlation during incident investigation than for detection triggering itself.

What I'd do differently

If I were starting this over, I'd invest earlier in a shared test harness that runs the same detection assertions against both the eBPF and polling collectors in CI, rather than testing them somewhat independently. The event-shape parity constraint is enforced by convention right now, not by an automated check — which is the kind of thing that holds up fine until it quietly doesn't.

Code for both collectors is in the XCloak repo, under xcloak-agent-desktop/agent/ebpf/ and connect_events_linux.go. If you've hit kernel versions where the BTF/capability detection itself is unreliable, I'd genuinely like to know — that's the part of this I trust the least.

← All posts View on GitHub →