ETW vs eBPF: Attributing a Network Connection to a Process on Windows and Linux
The question is the same on both operating systems: which process opened this connection. The mechanics of answering it are not. On Linux, an eBPF program on the connect and accept syscalls reads the owning process directly, because the program runs in that process's context. On Windows, the process is split across ETW providers, and the layer that carries the packet does not carry the process id, so the answer has to be reconstructed. This post compares the two approaches, explains why one is direct and the other is a correlation problem, and describes what each approach does not see.
The Windows side is summarized here and covered in full in ETW Network Telemetry on Windows: Kernel-Network vs TCPIP and the Correlation Problem.
The Linux answer: process context comes for free
On Linux, network attribution is direct because of where the eBPF program runs. A program attached to the connect or accept4 syscall executes in the context of the task that made the syscall. That task is the process that owns the socket, so the process identity is available at the moment the event fires, with no correlation step.
The attach points are syscall tracepoints, one program per event source. Outbound connections come from the entry to connect(); inbound connections come from the exit of accept4(), after the kernel has filled in the peer address.
// One eBPF program per event source, attached as syscall tracepoints.
// Network is two hooks: connect (outbound) and accept4 (inbound).
SEC("tp/syscalls/sys_enter_connect")
int handle_connect(struct trace_event_raw_sys_enter *ctx) { /* ... */ }
SEC("tp/syscalls/sys_exit_accept4")
int handle_accept4(struct trace_event_raw_sys_exit *ctx) { /* ... */ }
Inside the handler, the process identity is read from the current task. bpf_get_current_pid_tgid() returns a 64-bit value whose high 32 bits are the TGID, the userspace process id, and whose low 32 bits are the kernel task id, the thread. The user id, control group, command name, and parent are read the same way.
// Runs in the calling process's syscall context, so the current
// task is the owning process. No packet-to-process correlation needed.
__u64 pid_tgid = bpf_get_current_pid_tgid();
e->pid = (__u32)pid_tgid; // kernel task id (thread)
e->tgid = (__u32)(pid_tgid >> 32); // userspace process id
e->uid = (__u32)bpf_get_current_uid_gid();
e->cgroup_id = bpf_get_current_cgroup_id();
bpf_get_current_comm(&e->comm, sizeof(e->comm));
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
The event carries the process fields alongside the address family, source and destination address, and ports, and is delivered to userspace through a BPF ring buffer. The whole record is populated in place, at the hook, in one pass.
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 20); // 1 MiB
} events SEC(".maps");
The Windows answer: process context is split
On Windows there is no single provider that gives both the packet and the process. Socket-level events from Microsoft-Windows-Kernel-Network carry a process id, but the connection-failure event does not. Raw packets from the NDIS packet-capture provider carry no reliable process id at all, because that capture happens in DPC or interrupt context, where the running thread is not the socket owner. Recovering the process for a captured packet requires correlating the packet up to a connection event, which is what the Microsoft-Windows-Networking-Correlation provider exists to enable through its transfer events.
The result is that the straightforward Linux pattern, read the owning process at the hook, has no direct equivalent for packet-level data on Windows. The process either travels on a higher-layer event or has to be joined in after the fact.
Why the two differ: execution context
The difference is not that one platform tracks processes and the other does not. It is where the instrumentation runs. A syscall tracepoint or kprobe on the connect and accept paths runs in process context, so the current task is the owner and the identity is a helper call away. Packet capture, whether Linux XDP and tc at the driver layer or Windows NDIS, runs in softirq, DPC, or interrupt context, where there is no owning user process to read. ETW compounds this by design: it is a logging framework in which each provider decides what to stamp, and in kernel mode the Activity ID used for correlation must be passed explicitly rather than being carried per-thread as it is in user mode.
Put simply, eBPF answers the attribution question at the syscall boundary, where the process is present. The Windows packet path answers it below the process, where it is absent, which is why the process has to be correlated back in.
Side by side
| Dimension | Linux, eBPF connect and accept hooks | Windows, ETW network providers |
|---|---|---|
| Process attribution | Direct. Current task in syscall context via bpf_get_current_pid_tgid(). | Split. Socket events carry a PID; the failure event and NDIS packets do not; packets need the correlation provider. |
| Execution context | Process context at the syscall boundary. | Socket events in kernel context; packet capture in DPC or interrupt context. |
| Raw packet bytes | Not from connect and accept hooks. Bytes require XDP or tc. | Available from NDIS-PacketCapture. |
| Deployment constraints | Modern kernel. Ring buffer needs 5.8, CO-RE needs BTF. Every program passes the verifier. | Works on older Windows with no program load or verifier. |
| Completeness | Best-effort. A per-process rate limiter can drop events under load. | Bounded by the number of ETW sessions and buffer sizes. |
| UDP datagrams | A connect() on a UDP socket is seen; sendto and sendmsg are not, without more hooks. | Kernel-Network emits UDP send and receive events. |
What the eBPF connect and accept hooks do not see
Connectionless traffic. A connect-path hook covers outbound connection setup and an accept-path hook covers inbound acceptance. UDP is connectionless, so datagrams sent with sendto or sendmsg do not pass through connect() and are not captured unless the application happens to call connect() on the socket. Capturing datagram traffic requires additional hooks on the send path.
The bytes on the wire. Syscall hooks see that a connection was made and by whom, not the packet contents. Packet-level capture on Linux is a different attach type, XDP or tc, which runs below process context and gives up the free attribution described above.
A modern kernel. The ring buffer used for delivery arrived in Linux 5.8, and portability across kernels depends on CO-RE, which relies on BTF type information. Where the running kernel lacks embedded BTF, an external BTF archive has to be supplied for the same object to load.
Guaranteed completeness. High-volume event sources need a throttle to bound overhead. A per-process token-bucket rate limiter can drop connect and accept events during a burst, which means this is best-effort telemetry rather than a complete packet record.
What ETW gives that the eBPF hooks do not
The comparison is not one-sided. ETW requires no custom program, no verifier, and no recent-kernel equivalent, so the same providers work across a wide range of Windows versions without loading code into the kernel. It also exposes the raw packet layer through NDIS and a rich set of manifest providers for DNS, TLS, and the HTTP stack, which give the context around a connection that a bare connect hook does not. The cost of that breadth is the correlation work needed to reunite the packet layer with the process, which is the subject of the companion post.
Stable identity across process exit
Process ids are reused, so an event's pid is only meaningful together with a record of what the process was at that instant. Both platforms need a way to keep identity stable across a process exit and a later reuse of the same id.
On Linux this is done at the hook by folding the task's start time into a derived identity, so a reused pid produces a different value even when the tgid and pid collide.
// PID-reuse-safe identity: fold the task start time into the id, so a
// reused pid gets a different id even if tgid and pid collide.
__u64 start_time = BPF_CORE_READ(task, start_time);
e->correlation_id = start_time ^ (((__u64)e->tgid << 32) | e->pid);
On Windows the same problem is solved by subscribing to the kernel process start and stop events and maintaining a table that maps a process id to its image and lifetime, so a network event's pid can be resolved to the correct process and spawn chain rather than a later reuse.
One event model across both platforms
ET Ducky runs the same agent design on Windows and Linux and normalizes both event sources into one model. On Linux, eBPF programs on the process, file, and network syscalls emit records carrying pid, tgid, ppid, uid, control group, command, and connection addresses, delivered through a ring buffer, built with CO-RE so one object runs across kernel versions. On Windows, ETW providers on the kernel network and process keywords, plus user-mode providers for DNS, TLS, and HTTP, produce the equivalent records, with the correlation and process-tracking work that the Windows model requires. Both sides carry a reuse-safe process identity, and both feed the same dashboard and the same AI live sessions. The event looks the same to the operator regardless of which operating system produced it.
Frequently asked questions
Why is process attribution for a network connection easier with eBPF than with ETW?
An eBPF program on a connect or accept syscall tracepoint runs in the calling process's own context, so bpf_get_current_pid_tgid() returns the owning process directly. On Windows, packet capture through NDIS runs in DPC or interrupt context where the current thread is not the socket owner, and process context is split across ETW providers, so a packet must be correlated back to a connection event to recover the process.
What does bpf_get_current_pid_tgid() return?
A 64-bit value for the current task. The high 32 bits are the TGID, the userspace process id, and the low 32 bits are the kernel task id, the thread id. Because connect and accept syscall hooks run in process context, the current task is the process that owns the socket.
Does an eBPF connect hook capture UDP traffic?
Only if the application calls connect() on the UDP socket. A connect-path hook covers outbound connection setup. Connectionless datagram sends through sendto or sendmsg do not pass through connect() and are not captured by that hook. Capturing them requires additional hooks on the send path.
What kernel features does eBPF network capture require?
The BPF ring buffer used to deliver events to userspace was introduced in Linux 5.8. Portability across kernels uses CO-RE, which relies on BTF type information, generally present when the kernel is built with CONFIG_DEBUG_INFO_BTF (introduced in 5.2 and enabled by mainstream distributions from around 2020). All programs pass the in-kernel verifier before they load.
References and values to verify on your build
The eBPF helper semantics, ring buffer availability (Linux 5.8), CO-RE and BTF requirements, and verifier behavior are from the kernel documentation and libbpf references. The connect and accept attach points described here are syscall tracepoints (sys_enter_connect and sys_exit_accept4), not kprobes on tcp_connect; the correct attach point depends on what a given agent uses, so confirm against the loaded programs. The Windows provider details, including the correlation provider and the connection-failure event that omits the process id, are covered in the companion post.