§ AU

When netstat Lies: Detecting eBPF Magic-Packet Backdoors on Linux

A clean netstat is supposed to be reassuring. On a Linux host with a packet-path backdoor it can be worthless — and on some variants, actively misleading. A few passive implants avoid a conventional listener entirely; LinkPro is different but just as awkward. It keeps an internal listener, gates access to it through the kernel’s packet path, rewrites the port on the way in and back out, and hides its artifacts from the usual enumeration. The operational problem is the same either way: your asset scanner, your perimeter firewall logs, and your host-side socket view stop agreeing — by design.

LinkPro is the cleanest 2025 example of the pattern. Synacktiv documented it in October after pulling it out of an AWS-hosted environment, and it’s worth reading their writeup in full (https://www.synacktiv.com/en/publications/linkpro-ebpf-rootkit-analysis) because the rootkit is a tidy catalog of every technique that makes this class hard to find. Initial access was an exposed Jenkins server vulnerable to CVE-2024-23897, after which a malicious Docker Hub image got deployed across several Kubernetes clusters. The interesting part starts after the foothold.

What the implant actually does to the kernel

LinkPro loads two eBPF modules. They do different jobs and you detect them differently, so keep them separate in your head.

The first one Synacktiv calls Hide. It uses tracepoint and kretprobe programs to hook getdents/getdents64 (the syscalls that back directory listing) and sys_bpf itself. Hooking getdents means the rootkit’s files and, with the -p flag, even its process entries drop out of ls, ss, and anything else that walks /proc. Hooking sys_bpf is the nasty one: it lets the implant filter the kernel’s own answer to “what BPF programs are loaded,” which means bpftool prog list can be made to lie about the very programs doing the lying. The kretprobe trick depends on the kernel being built with CONFIG_BPF_KPROBE_OVERRIDE. If that’s missing, LinkPro falls back to dropping a shared library into /etc/ld.so.preload so glibc loads its hooks into every dynamically-linked binary. Two completely different concealment mechanisms, one fallback for the other.

The second module is Knock. It attaches XDP and Traffic Control programs to the network path and watches for a trigger: per Synacktiv, a TCP SYN with a window size field of 54321. When Knock sees that, it records the source IP and opens a one-hour window for that address. The XDP ingress program then rewrites the destination port on inbound packets to the implant’s real internal listener (port 2233 in Synacktiv’s sample), and the TC egress program rewrites the source port on the way back out, so the operator can come in on any port the front-end firewall happens to allow. That last detail is the one that wrecks your correlation: the port in your perimeter firewall log will not match the port the host actually serviced. If your hunt depends on lining up edge-firewall flows against host-side connections, this breaks it by design.

This is also where the “clean netstat” instinct gets you killed, and it is not symmetric across tools. LinkPro does keep a listener, so an empty socket view is not proof of safety. netstat can be blinded outright — the ld.so.preload fallback library strips any line containing port 2233 from /proc/net/tcp. But ss reads sockets through netlink rather than /proc, and per Synacktiv it will still show port 2233 listening; what’s scrubbed there is the process attribution, via the getdents hook. A listening port that ss shows but no visible process owns is itself the tell.

So you have a backdoor whose listener is reachable only through packet-path sleight of hand, files and processes hidden at the syscall layer, BPF programs hidden from the standard tool, and a trigger that looks like one slightly-odd SYN in a sea of normal traffic.

Why your existing stack walks right past it

Linux EDR coverage is the first gap. A lot of host agents lean on the same kernel facilities the rootkit is abusing, and many were built around process-execution and file telemetry, not BPF program lifecycle. If the agent isn’t explicitly recording bpf() syscall activity and XDP/TC attachments, the implant’s installation generates nothing the agent considers interesting. And on the hosts where you’d most want coverage — older RHEL 8 boxes, appliance-style images, that one Ubuntu 20.04 build host nobody will let you reboot — agent coverage tends to be thinnest.

The second gap is that the obvious manual check is poisoned. bpftool prog show and bpftool net show read through the same sys_bpf path the Hide module hooks. Treat bpftool output as advisory on a host you already suspect, not as ground truth. The honest answer is that you need a vantage point the implant doesn’t control: comparing the kernel’s BPF inventory from more than one path, reading it from a memory image, or watching the load as it happens rather than enumerating after the fact.

That last option is the one that scales, so that’s where to put the effort.

Detect the load, not the steady state

The durable signal is the bpf() syscall at load time. The implant can hide its programs after they’re resident, but it has to call bpf(BPF_PROG_LOAD, ...) to get them into the kernel in the first place, and that call happens before the Hide hooks are in place to cover it. Catch the verb, not the noun.

On hosts with auditd, the rule is unglamorous:

-a always,exit -F arch=b64 -S bpf -F key=bpf_activity
-a always,exit -F arch=b32 -S bpf -F key=bpf_activity
-w /etc/ld.so.preload -p wa -k preload_tamper

The first two lines record every bpf() syscall (number 321 on x86_64); keep the b32 line only on hosts where 32-bit execution is actually possible, otherwise it’s just an empty rule. The ld.so.preload watch is your cheap insurance against the fallback concealment — that file should essentially never change on a server after provisioning, so any write event is worth waking someone for. One caveat that bites people: an audit watch tracks an existing inode, and plenty of servers ship without /etc/ld.so.preload at all, so a bare -w rule won’t fire when the file is created. Where it doesn’t exist yet, catch its creation through directory-level monitoring of /etc or a file-integrity tool instead. Either way it costs almost nothing in volume and catches the degraded-mode variant that doesn’t need CONFIG_BPF_KPROBE_OVERRIDE at all. Cheap, high-signal, turn it on first.

If you’re shipping auditd into Splunk through the Add-on for Unix and Linux, the load events land as sourcetype=linux:audit with syscall=321. A first-pass hunt looks like:

sourcetype=linux:audit syscall=321 success=yes
| stats count earliest(_time) as first values(comm) as comm by host, exe, auid
| search NOT exe IN (/usr/bin/cilium-agent, /usr/lib/systemd/systemd, /opt/datadog-agent/*, ...)

The point of the stats ... by host, exe is that you are hunting for an unexpected loader, not for BPF activity per se. The exe path that called bpf() is the field that matters. A program loading eBPF that isn’t on your known list of loaders is the lead.

The first week is mostly false positives, and that’s the work

Here’s where the lab demo and the production index part ways. Modern Linux loads eBPF constantly, and the volume depends entirely on what’s on the box.

On a plain EKS node running the AWS VPC CNI, baseline bpf() load volume is low — you’ll see systemd, maybe a node exporter, your EDR agent if it uses BPF. On an OpenShift 4.x cluster, or any EKS/self-managed cluster running Cilium, it is a different planet: Cilium reloads programs on pod churn, so load-and-attach volume scales with how much your pods move. Falco, Pixie, Datadog’s agent, Tetragon, recent systemd (cgroup and firewall programs) — all legitimate, all noisy. The point is only this: on a Cilium-heavy fleet the volume gets high enough that flipping the auditd rule on and routing straight to alerts is a self-DoS for your SOC. Baseline by node role and loader identity before you page on it — and if you want a real number, get it from your own index, not from a blog.

So the first round of tuning is allowlisting by loader identity, not by suppressing the rule. Build the baseline per node profile, because a flat “known loaders” list across a mixed fleet either misses the CNI on your OpenShift nodes or whitelists cilium-agent everywhere including the boxes that should never run it. The tells that survive tuning:

  • A loader that’s a shell, an interpreter, or something out of /tmp, /dev/shm, or a container writable layer, rather than a packaged binary at a known path.
  • bpf() loads from a process whose parent tree traces back to a web app, a CI runner, or a Jenkins/Tomcat process — LinkPro’s chain started at Jenkins.
  • A program type of XDP or TC showing up on a host that has no business doing kernel-bypass networking. Pair the syscall hunt with periodic bpftool net show and ip -d link show snapshots and diff them against a baseline. (Yes, bpftool can be lied to on a compromised host — but a brand-new XDP attachment on a database server that yesterday had none is still a lead worth running down, and on a not-yet-fully-hooked host you’ll catch it.)

There’s a retention tradeoff lurking here too. bpf() load events are individually cheap, but on a Cilium fleet the aggregate is not, and you probably don’t want 90 days of it in hot storage. Keep enough hot to cover your detection window and your typical dwell-time assumption; age the rest to cold. If your retention budget is tight, the ld.so.preload watch and the XDP/TC diff are far cheaper to keep long-term than the raw syscall firehose.

One more operational gotcha: time skew. Because Knock rewrites ports, your only hope of stitching the perimeter event to the host event is accurate timestamps on both ends, and host clocks drift. If chrony has been quietly failing on a subset of nodes — it happens — your correlation window has to be wide enough to absorb the skew, which in turn widens your false-positive radius. Check that your fleet is actually disciplined to NTP before you trust any cross-source timeline on this.

Control mapping

The detections and the hardening land across several 800-53 families. The mapping is less about checking boxes and more about noticing that the strongest preventive control (SC-7 boundary protection) is exactly the one the magic-packet design is built to neuter.

Control Application to this threat
SI-4 Monitor bpf() loads, XDP/TC attachments, and ld.so.preload writes as the primary detection surface
SI-7 Integrity monitoring on /etc/ld.so.preload and the dynamic-linker path catches the fallback concealment
AU-2, AU-12 The syscall audit events have to be generated and shipped before the Hide hooks land to be useful
CM-7 Least functionality: kernel.unprivileged_bpf_disabled=1, restrict who can attach XDP/TC
AC-6 CAP_BPF and CAP_SYS_ADMIN gate program loading (CAP_BPF split out in kernel 5.8); XDP/TC attachment also implicates CAP_NET_ADMIN, and tracing programs CAP_PERFMON — treat all of them as privileged in BPF-capable environments
SC-7 Boundary protection is partially defeated by the port-rewrite trick; don’t over-rely on perimeter logs
SR-3, SR-11 The intrusion entered through a malicious container image; image provenance is part of this story

A note on CM-7: setting kernel.unprivileged_bpf_disabled is good hygiene and worth doing (RHEL 9 ships it that way), but understand its limit against this specific threat. These implants run as root after the initial compromise. The sysctl stops unprivileged BPF; it does nothing against a loader that already has CAP_BPF or full root. It raises the bar for a low-priv foothold, not for the post-exploitation rootkit. Don’t file it as a mitigation and move on.

What teams get wrong before they get it right

The recurring mistake is treating this as a malware-signature problem. People reach for the YARA rule or the EDR hash list, find nothing, and conclude the host is clean. But the durable artifact here isn’t a file on disk — Hide exists to make sure of that — it’s the behavior of loading kernel programs from a process that has no legitimate reason to. Hunt the loader, baseline the BPF inventory, watch the linker config, and accept that bpftool is a witness who may have been gotten to.

BPFDoor, the China-nexus precedent that’s been kicking around since 2021 and got a refreshed controller in 2025, told us this was coming. LinkPro is the financially-motivated, container-native version. The next one will be neither — and the seam it crosses depends on which BPF it speaks. eBPF implants like LinkPro have to call bpf(BPF_PROG_LOAD, ...) to get their programs into the kernel; classic-BPF backdoors like BPFDoor instead attach a socket filter through setsockopt(..., SO_ATTACH_FILTER, ...), which never touches bpf() at all. Mature coverage audits both load paths — before the implant teaches your tools to stop seeing it.

Sources