Introduction
when you have root on a Linux box and you want to intercept someone else's TCP connections, the options are all kind of annoying.
you can iptables REDIRECT or DNAT, but those touch every packet and leave a trail in the ruleset. you can ARP-spoof if you're on the same L2, but that's noisy and doesn't work for local traffic. you can LD_PRELOAD a shared library into the target process, but that needs the process to restart or you need ptrace, and ptrace is the noisiest thing you can do on a Linux box after running rm -rf /.
then there's the eBPF approach, which is more elegant but still annoying in its own ways. XDP sucks for this because it runs before routing and you need to manually fix up everything. TC is fine but you're still mangling packets and recalculating checksums. both require attaching to a specific interface and don't work on loopback without gymnastics.
there's a better way and nobody in the offensive space seems to have noticed. it's been in the kernel since 2020.
what SK_LOOKUP actually is
Linux 5.6 introduced BPF_PROG_TYPE_SK_LOOKUP. it was written by Jakub Sitnicki from Cloudflare, who needed it for their Spectrum product, a reverse proxy that needed to steer connections from a huge range of IPs to individual sockets without binding thousands of sockets. the kernel docs describe it as "introducing programmability into the socket lookup performed by the transport layer when a packet is to be delivered locally."
what does that mean in practice?
normally when a TCP SYN arrives, the kernel does a hash table lookup in the listening socket table. it hashes the destination port and walks a linked list until it finds a socket with a matching IP. if nothing matches, the connection gets RST'd.
SK_LOOKUP inserts itself between that SYN arriving and the socket table lookup. you attach a BPF program to the whole network namespace (not to a specific interface, which is the first hint that this is different from XDP/TC). every time the transport layer needs to find a listening socket for an incoming connection, your BPF program runs first.
your program can:
- return SK_PASS and do nothing (normal lookup continues)
- return SK_DROP (connection gets killed)
- call bpf_sk_assign(some_socket) and return SK_PASS (your socket gets the connection instead)
option three is the interesting one.
the thing that makes it special
all the existing eBPF backdoors use XDP or TC. TripleCross, ebpfkit, Boopkit, LinkPro, all of them. these run on packets as they cross a network interface. you get to see the packet, maybe mangle it, maybe forward it somewhere else. but you're always working with raw packets and always attached to a specific interface.
SK_LOOKUP is different. it runs at the socket layer. the packet has already been received, already been through routing, already been delivered to the local stack. the kernel is saying "who gets this?" and your BPF program says "that guy, actually."
the difference matters because:
- you're working with sockets, not packets. you don't need to parse headers, recalculate checksums, or worry about TCP sequence numbers. you just say "this socket gets it."
- it works on loopback. XDP doesn't by default. TC does but it's awkward.
- it's per-network-namespace, not per-interface. one attach and it covers everything on that host.
- the packet never gets modified. tcpdump on the destination shows the original destination IP and port. there's nothing at the network level to catch.
- it's transparent to the connecting client too. they sent a SYN to port 80, they got a SYN-ACK back, they think they're talking to nginx. they have no idea their data is going to your backdoor socket instead.
building it
the PoC is simple. embarrassingly simple, that's the whole point.
here's the kernel-side BPF program in full:
struct {
__uint(type, BPF_MAP_TYPE_SOCKHASH);
__uint(max_entries, 1);
__type(key, u32);
__type(value, u64);
} sock_map SEC(".maps");
SEC("sk_lookup")
int sk_redirect(struct bpf_sk_lookup *ctx) {
if (ctx->local_port != TARGET_PORT)
return SK_PASS;
u32 key = 0;
struct bpf_sock *sk = bpf_map_lookup_elem(&sock_map, &key);
if (!sk)
return SK_PASS;
bpf_sk_assign(ctx, (void *)sk, 0);
bpf_sk_release(sk);
return SK_PASS;
}
that's 28 lines. 28 lines of BPF that hijacks every TCP connection to port 9999 and hands them to whatever socket you put in the map.
it reads ctx->local_port which is the destination port (in host byte order, which caught me out, more on that later). if it doesn't match, it passes, the normal socket lookup continues. if it does match, it looks up a socket from the SOCKHASH map, calls bpf_sk_assign to hand the connection to that socket, and releases its reference.
the userspace side is not much more complicated:
static void *backdoor_thread(void *arg) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
/* bind to 127.0.0.1:31337, listen */
/* store fd in arg for the main thread */
/* accept() loops until connection arrives */
}
int main(void) {
/* spin up backdoor listener thread */
/* load BPF object via libbpf */
/* store backdoor socket fd in sock_map */
/* attach BPF program to netns */
/* connect to 127.0.0.1:9999 as a test client */
/* read the response, should be "BACKDOOR CONNECTION" */
}
the thread creates a normal TCP listening socket on 127.0.0.1:31337. the BPF program is loaded, the socket fd is stored in the SOCKHASH map, and the program is attached to the network namespace. then when you connect to port 9999, the kernel's socket lookup runs the BPF program, which redirects your connection to the socket on port 31337. you get "BACKDOOR CONNECTION" back. the client thinks it connected to whatever was supposed to be on 9999.
and that's it. that's the whole thing.
what broke and why
i'm not going to pretend this worked on the first try.
the first version used bpf_sk_lookup_tcp, which is helper #84. that's not the same as bpf_sk_assign. bpf_sk_lookup_tcp does a kernel socket table lookup and returns a socket. the idea was: intercept connections to port 9999, then look up my backdoor socket from the kernel's socket table by connecting to it via bpf_sk_lookup_tcp, then assign that.
the verifier said no:
program of this type cannot use helper bpf_sk_lookup_tcp#84
that was the first surprise. bpf_sk_lookup_tcp exists but isn't allowed in SK_LOOKUP programs. this makes some sense if you think about it, allowing a program that runs during socket lookup to recursively do another socket lookup creates weird questions about reentrancy and locking. but the kernel docs show examples using it, so i expected it to work. either this kernel compiled SK_LOOKUP without that helper (config option not set), or the helper was removed at some point. i didn't dig into which.
the fix was to use a SOCKHASH map instead. the userspace program stores the backdoor socket fd in the map. the BPF program looks it up with bpf_map_lookup_elem. no kernel socket table lookup needed.
but that introduced the second issue. bpf_map_lookup_elem on a SOCKHASH returns a struct bpf_sock * with a reference held. the verifier is strict about this, you must release the reference before the program exits. if you don't:
Unreleased reference id=2 alloc_insn=9
BPF_EXIT instruction in main prog would lead to reference leak
so bpf_sk_release(sk) is required after bpf_sk_assign. the reference from the map lookup is yours. bpf_sk_assign takes its own. you need to release yours.
the third issue was byte order.
if you look at struct bpf_sk_lookup in vmlinux.h, local_port is __u32 with no __be annotation. compare with remote_port which is __be16. the kernel-internal struct bpf_sk_lookup_kern has dport as plain u16 too. this is intentional: the kernel converts the destination port from network to host byte order before the BPF program ever sees it (bpf_sk_lookup_kern.dport = ntohs(wire_dport)).
i initially wrote:
if (ctx->local_port != bpf_htons(TARGET_PORT))
which is wrong because the conversion already happened. comparing a host-order field against a host-order value with an extra ntohs/htons layer would give you 0x0f27 != 9999 on x86 and the redirect would silently never fire. the fix:
if (ctx->local_port != TARGET_PORT)
on a big-endian architecture ntohs is a no-op, so the behavior would be the same. the field is always in host order regardless of platform. the annotation absence in the struct is the tell, and it's easy to miss if you're used to network header fields being always in network byte order.
why nobody seems to have done this
i searched pretty thoroughly before writing this up. here's what i found:
- TripleCross (2021, 2k stars on GitHub): uses XDP + TC for the network backdoor. C2 via raw sockets. no SK_LOOKUP.
- ebpfkit (2020): XDP-based. no SK_LOOKUP.
- Boopkit: XDP-based TCP reverse shell. no SK_LOOKUP.
- LinkPro (Synacktiv analysis, 2025): XDP + TC, magic packet activation via TCP window size field. no SK_LOOKUP.
- BPFDoor (2021-present, real APT malware): uses classic BPF (not eBPF) filters on raw sockets. completely different approach. no SK_LOOKUP.
the closest thing to this technique in published offensive research is... the kernel documentation. the kernel docs literally show this exact pattern (SOCKHASH + bpf_map_lookup_elem + bpf_sk_assign) as the canonical example of how to use SK_LOOKUP. it's in the kernel tree. it's been there since 2020.
so the technique isn't new. what's new is the observation that the entire offensive eBPF ecosystem has missed this program type. everyone jumped on XDP and TC because those are the obvious places to intercept network traffic. SK_LOOKUP is more obscure. it's in a different part of the stack. you find it by reading the BPF program type list and wondering what "sk_lookup" does.
there are tutorial blog posts about using SK_LOOKUP for load balancing (arthurchiao.art 2022, the eBPF Chirp blog 2024). Cloudflare open-sourced Tubular in 2024 which uses it. but nobody wrote "you can also use this to steal connections." it's one of those features that's obviously useful to attackers if you think about it for two seconds, but nobody said the quiet part out loud.
comparison with XDP/TC
XDP runs before the kernel allocates an skb. you get a raw packet buffer. you can drop it, pass it, or redirect it to another interface. for socket interception, you'd need to redirect the packet to a userspace proxy application, which then creates its own connection to the real backend. it works but it's complex.
TC runs after the kernel allocates an skb but before the socket lookup. you get an skb. you can modify it and recalculate checksums. for socket interception, you'd typically do DNAT, change the destination port to your backdoor port and let the kernel's normal socket lookup handle the rest. the packet on the wire is modified though. tcpdump shows your backdoor port, not the original.
SK_LOOKUP is simpler than both. you don't touch the packet. you don't recalculate anything. you don't need a proxy process. your backdoor socket is a regular listening socket. the BPF program just says "this connection goes there." that's it.
the trade-off:
- XDP: fast, works on raw frames, but needs proxy for TCP
- TC: flexible, packet-level control, but modifies the packet
- SK_LOOKUP: socket-level, invisible on the wire, cleanest API, but only for NEW connections to local sockets
where it works and where it doesn't
SK_LOOKUP fires for:
- TCP SYN packets creating new connections
- UDP packets to unconnected sockets
it does NOT fire for:
- established TCP connections (the routing is already done)
- connected UDP sockets
- forwarded traffic (packets not destined for this host)
- raw sockets
- packets generated by the kernel itself
this means you can intercept sshd connections but not steal an existing SSH session. you can intercept DNS queries but not siphon off an active QUIC stream.
the "new connections only" limitation is inherent. it's not a bug. the kernel doesn't need to look up a socket for established connections, it already knows which socket owns that 5-tuple. so your BPF program only fires on the first packet.
for a backdoor, this is usually fine. you're intercepting "who connects to the service" not "who's already connected." you put your backdoor socket in the map, it gets new connections, you proxy them or serve content directly or fork a shell.
detection
if you're defending against this, the honest answer is: you probably aren't looking for it.
tools like Falco, Tetragon, and Tracee can detect BPF program loads. Falco has a rule for "BPF program was loaded." but it fires on every legitimate BPF program too, Cilium, Falco itself, systemd, containers. there's a lot of noise.
bpftool prog shows all loaded BPF programs. bpftool net shows which are attached to netns for SK_LOOKUP. if you run:
$ bpftool net
you'll see something like:
xdp: (none)
tc: (none)
flow_dissector: (none)
sk_lookup:
netns 4026531992 sk_redirect id 42
that "sk_lookup" line is the backdoor. but nobody runs bpftool net unless they're debugging something. it's not in any standard monitoring pipeline.
the SOCKHASH map is visible too:
$ bpftool map show
shows all maps, including their type and pinned path. a SOCKHASH map holding a socket fd isn't normal on most systems.
the backdoor socket itself is a regular listening socket. netstat or ss will show it:
$ ss -tlnp | grep 31337
shows your backdoor port. this is the biggest forensic signal in the whole technique, so it's worth thinking about how an attacker would handle it.
the laziest approach: bind to 127.0.0.1 on a high ephemeral port. on a busy server, a LISTEN socket on port 57321 with no associated process name (or a masked one) rarely triggers alarms unless someone is specifically auditing listening ports. thousands of servers have random Java/node processes listening on weird ports. one more blends in.
a better approach: bind to a non-loopback address that isn't the primary IP. if the server has multiple IPs (container hosts, VPN endpoints, anycast setups), bind the backdoor socket to one nobody monitors. ss still shows it but it's not on 0.0.0.0 and requires knowing the right IP to reach.
the paranoid approach: don't let the listening socket outlive the connection. the BPF program can trigger a userspace helper that creates a socket on demand, accepts exactly one connection, and then tears down. the window where ss sees the socket is bounded by the lifetime of a single accept. this is what attackers who care about forensic artifacts do. BPFDoor uses a similar pattern with classic BPF filters on raw sockets, the socket only exists while the implant is actively communicating.
there's also the PID/process hiding angle. ss shows the process name and PID for sockets owned by visible processes. if your backdoor process is hidden (via LD_PRELOAD on getdents, or an eBPF kprobe that intercepts /proc scans), ss output won't show the process column for that socket, just the socket and port. on a system where some processes already show with "-" for the process name (kernel threads, dying processes), this is subtle enough to miss.
there's a deeper problem though. SK_LOOKUP programs are attached to network namespaces, not to specific sockets or interfaces. most container security tools (Falco, Tracee) monitor with kprobes at the syscall level. they catch bpf() syscalls. they can see "a BPF program was loaded." but correlating that with "this program steals connections to port 9999" requires reading the bytecode or dumping the RELO sections, which none of them do by default.
if i were building a detection for this, i'd look for:
- BPF_PROG_TYPE_SK_LOOKUP programs being loaded (unusual on any system that isn't running Cloudflare software)
- SOCKHASH maps being created and populated with socket fds
- the combination: SK_LOOKUP prog + SOCKHASH map = almost certainly malicious on any normal server
but right now, nothing ships those rules.
what an attacker actually needs
to use this, you need:
- root, or CAP_BPF + CAP_NET_ADMIN + CAP_NET_NS_ADMIN
- libbpf (to compile and load the program)
- a way to load the program on each boot (cron, systemd unit, etc.)
the capability requirement is the real gate. if you have root, you don't need this technique, you can do anything. but that's true of every eBPF backdoor. the question is: once you have root, how do you stay useful without being obvious?
this is where SK_LOOKUP shines. you load one BPF program, attach it to the netns, and your backdoor socket silently intercepts connections. no iptables rules, no SSH config changes, no modified binaries, no fileless malware that still shows up in /proc. everything is legitimate kernel API. the BPF program is 28 lines. the userspace loader is 130. you could hide it in a cron job that compiles and loads it from an encrypted blob.
Assessment
this technique is not a vulnerability. it's a feature of the Linux kernel being used as designed. the only reason it's interesting is that the offensive community has been looking at the wrong BPF hooks.
XDP and TC are for packet processing. SK_LOOKUP is for socket dispatch. if you want to steal TCP connections, you should be looking at the socket dispatch layer, not the packet processing layer. it sounds obvious in retrospect but nobody did it.
the PoC is simple because the kernel does all the hard work. 28 lines of BPF, 130 lines of C. that's it. that's the whole backdoor.
the code is at: https://github.com/klydz/sklook. run it with sudo on any Linux 5.6+ kernel.
i don't know if this will get patched or mitigated. it's not a bug so there's nothing to patch. maybe distributions will start auditing SK_LOOKUP attachments. maybe security tools will add detection rules. but the mechanism itself isn't going anywhere. it's a legitimate kernel feature with legitimate uses.
the question is just whether defenders start paying attention to it.
ed: fixed some things the verifier caught, wrote the rest between midnight and 3am while wondering why i don't use XDP like everyone else