· 7 min read ·

LittleSnitch Comes to Linux, and the Hard Parts Were Always in the Kernel

Source: hackernews

Objective Development announced LittleSnitch for Linux, bringing their macOS staple to a platform where the equivalent tooling has existed for years in various states of incompleteness. The Hacker News thread drew over 400 comments, most of them cycling between enthusiasm and skepticism rooted in the same question: how do you actually build this on Linux?

The answer involves several layers of the kernel that are routinely underappreciated, a process-identity problem that macOS solved by fiat, and a Wayland limitation that nobody has cleanly resolved.

What LittleSnitch Does on macOS, and Why It Was Straightforward There

On macOS, LittleSnitch runs as a Network Extension. Since Apple Silicon, third-party kernel extensions (kexts) are gone for good, so Objective Development uses the NEFilterDataProvider API from Apple’s NetworkExtension framework. When a process calls connect(), the kernel intercepts the new flow before the TCP SYN leaves the machine and calls handleNewFlow() in the LittleSnitch process. The NEFilterSocketFlow object handed to that method carries a sourceAppSigningIdentifier: a stable, cryptographically-verified string derived from the application’s code signature and team ID. LittleSnitch returns an NEFilterNewFlowVerdict — allow, drop, or need-more-rules — before the packet goes anywhere.

The critical thing here is the identifier. LittleSnitch does not have to figure out which application initiated the connection. The kernel already knows, because macOS enforces code signatures before it will hand the flow to the Network Extension at all. The identifier is stable across restarts, updates within the same team, and different machines. It is unforgeable by userspace.

Linux has no equivalent to this. That gap shapes every decision downstream.

The Process-Identity Problem on Linux

On Linux, the standard approach to “which process owns this socket” involves two steps: get the socket’s inode, then find which process has that inode open.

The NETLINK_SOCK_DIAG interface (kernel 3.3) is the clean way to get the inode. You open a SOCK_RAW socket with family AF_NETLINK and protocol NETLINK_SOCK_DIAG, send an inet_diag_req_v2 struct describing the connection you care about (source address, destination address, ports, protocol), and the kernel responds with an inet_diag_msg that includes the owning UID and inode number. This is faster and less racy than parsing /proc/net/tcp.

But you still need to map that inode to a PID. The kernel does not return the PID in inet_diag responses. So you walk /proc/<PID>/fd/, checking each symlink for socket:[inode], until you find a match. Across a system with hundreds of processes and thousands of file descriptors, this takes single-digit milliseconds on fast hardware and longer on a loaded system. It is also a race: the process can exec into a different binary, pass the socket to a child via SCM_RIGHTS, or simply exit between when you observe the connection and when you read /proc/<PID>/exe.

This is the fundamental limitation of Linux’s traditional approach to per-application firewalling. The kernel gives you a snapshot of a dynamic system, not a stable identity tied to connection initiation.

The eBPF Path That Changes the Equation

The right kernel hook in 2026 is BPF_PROG_TYPE_CGROUP_SOCK_ADDR, introduced in kernel 4.17. You attach an eBPF program to a cgroupv2 hierarchy node; the program fires at connect(), bind(), and sendmsg() syscalls for every socket owned by processes in that cgroup. Because it fires synchronously inside the calling process’s context, bpf_get_current_pid_tgid() gives you the exact PID and thread ID at the moment of connection initiation. bpf_get_current_task() lets you walk the task_struct to read cgroup membership. Return 0 to deny the connection, return 1 to allow.

The challenge is deferral. LittleSnitch’s model requires showing a dialog and waiting for user input. In an eBPF program, you cannot block: the program must return immediately. The pattern is: deny the connection, write an event into a BPF_MAP_TYPE_RINGBUF (kernel 5.8) for userspace to consume, show the dialog, and if the user approves, the application must retry the connect() call. For applications that handle connection errors gracefully and retry, this works well. For applications that bail on ECONNREFUSED without any retry logic, the first connection attempt is lost.

The more principled hook is BPF_PROG_TYPE_LSM at the socket_connect LSM hook point (kernel 5.7, requires CONFIG_BPF_LSM=y and lsm=bpf in the kernel command line). LSM eBPF fires at the same point that SELinux and AppArmor evaluate network policy. Returning -EPERM refuses the connection. PID and task context are fully available. Combined with bpf_task_storage (kernel 5.11) for per-task state, this gives a clean interception layer that does not depend on the cgroupv2 hierarchy being pre-configured correctly.

There is also bpf_get_socket_cookie(), a kernel helper returning a stable 64-bit integer identifying a socket, usable across different eBPF program types. The canonical pattern: store a {cookie -> pid} mapping in a BPF_MAP_TYPE_HASH from a CGROUP_SOCK_ADDR program, which runs in process context and has the PID available, then look up that cookie later from a CGROUP_SKB program, which operates on individual packets and may not have PID context. This bridges connection-time identity to packet-time enforcement without the proc-walking race.

What the Existing Tools Built

OpenSnitch is the most visible prior art. It uses a split-process design: a daemon in Go that intercepts connections using Linux Netfilter Queue (libnetfilter_queue), and a Python + Qt GUI communicating over gRPC with protobuf. The daemon installs iptables or nftables rules to redirect outbound packets to an NFQUEUE target, then for each queued packet performs the SOCK_DIAG netlink query plus the /proc/<PID>/fd/ walk to identify the owning process. Rules are stored as JSON files in /etc/opensnitchd/rules/ and evaluated in memory. The IPC channel uses google.golang.org/grpc on the Go side and grpcio on the Python side.

The NFQUEUE approach has real tradeoffs. Interception happens at the Netfilter output or postrouting hook, which means the TCP SYN is already constructed and moving through the stack by the time the daemon sees it. Blocking at this point requires dropping the packet and letting the remote end time out, or injecting a RST. More importantly, by the time the packet reaches NFQUEUE, the kernel has often already detached the socket from the sending process’s context, making the inode-to-PID walk slower and more prone to the race.

Portmaster by Safing.io went further, using eBPF for interception on Linux and shipping an Electron-based UI. The Electron choice sidesteps native desktop integration entirely by bringing its own rendering engine, at the cost of roughly 200MB baseline RAM and all the usual Electron overhead.

Douane predates both and used a traditional loadable kernel module rather than eBPF. Kernel modules must be recompiled against each kernel version, break on kernel API changes, and require signing for Secure Boot. Douane is largely unmaintained, which is the predictable outcome for an out-of-tree kernel module without sustained engineering support.

The Application Identity Gap

None of these tools can approach the macOS code-signing model. On Linux, an “application” might be a binary at /usr/bin/curl, indistinguishable at the kernel level from a malicious binary placed at the same path; a Flatpak running inside a bubblewrap sandbox with its own PID namespace, identifiable via /proc/<PID>/environ for FLATPAK_ID=org.mozilla.Firefox or the cgroup path app.slice/app-org.mozilla.Firefox.scope; a Snap mounted from a squashfs image with an executable path like /snap/firefox/4457/usr/lib/firefox/firefox; or a Go binary exec’d from a shell script, where /proc/<PID>/exe points to the binary but comm reflects the parent.

The cgroupv2 path is the most reliable identity source for systemd-managed desktop processes, because systemd names cgroup scopes after the unit, which often corresponds to the .desktop file name. app.slice/app-firefox-12345.scope is meaningfully different from app.slice/app-curl-99999.scope. But this falls apart for processes that systemd does not manage, for session-less daemons, and for anything running inside a container with its own cgroup namespace.

Without mandatory code signing, any identifier derived from the filesystem path or process name can be spoofed by a sufficiently motivated process. The best LittleSnitch or any Linux tool can offer is heuristic identification, not cryptographic certainty.

The Wayland Problem

LittleSnitch’s most recognizable UI feature is the connection prompt: a dialog that appears above everything, demands attention, and requires a decision before the connection proceeds. On X11, this is achievable; a window can request focus with _NET_ACTIVE_WINDOW, set _NET_WM_WINDOW_TYPE_DIALOG, and generally force its way to the front under most window managers. On Wayland, this is architecturally blocked.

Wayland’s security model explicitly prevents arbitrary applications from raising windows above others or stealing keyboard focus. The xdg-activation protocol, which handles intentional focus transfer, requires the requesting application to already have compositor-granted focus and hold an activation token. A security daemon waiting in the background, wanting to interrupt whatever the user is doing and demand input, does not have this token and has no standard way to obtain one.

Each compositor, Mutter for GNOME, KWin for KDE, the various wlroots-based compositors, would need to implement a privileged extension that LittleSnitch could use. No such cross-compositor standard exists. On X11, even a root process can generally force its window to the top. On Wayland, root is irrelevant to the compositor’s window management policy. This is correct from a security perspective and a genuine design constraint for interactive permission tooling.

What LittleSnitch Brings

Brand recognition and a decade of UX iteration count for something real. Objective Development has spent years refining the permission dialog, the traffic map, the rule management interface, and the connection history viewer into a polished product. The open-source alternatives have functional cores, but their UIs show the friction of being designed primarily by people who understood the kernel engineering and treated UX as secondary.

The deeper contribution, if Objective Development publishes anything about their implementation, may be in the concrete architectural choices forced by shipping a real product. A tool that needs to work across Ubuntu 22.04, Fedora 41, and Arch without recompiling kernel modules has to commit to a minimum kernel version, make specific assumptions about the cgroup topology, and decide how to handle the Wayland prompt problem. Those choices, made under real product constraints and shipping deadlines, are more useful as prior art than an academic prototype.

The Linux per-application firewall space has had the necessary kernel primitives since roughly kernel 5.7 for LSM eBPF and 5.8 for BPF_MAP_TYPE_RINGBUF. The gap has not been in kernel capabilities. It has been in the combination of kernel knowledge, UX investment, and the organizational stamina to maintain a security tool across a fragmented ecosystem. Whether LittleSnitch closes that gap remains to be seen, but the announcement signals that someone with all three is making the attempt.

Was this interesting?