VoidLink is Linux malware written in Zig. Check Point Research found it in December 2025. It is modular and cloud-native: it goes after containers in a Kubernetes cluster and the VMs those clusters run on, and it is built to persist there quietly, for a long time.
I take no credit for finding this. I dug into it and figured it was worth writing up. Shoutout to Isovalent and Check Point Research for sharing their VoidLink research.
the malware
The chain runs in three stages, and each stage does one job: pull the next piece off the network and run it without leaving a file behind. The names follow the layering. Stage 0 is the dropper, the small first binary. It pulls down stage 1, which pulls down the implant, the component that sticks around and does the work.
Stage 0 starts by disguising itself. It calls prctl(PR_SET_NAME) to rename its
process to [kworker/0:0], which is what a real kernel worker thread looks like
in ps, so anyone skimming a process list scrolls right past it.
Then it downloads stage 1 over HTTP from the attacker’s command-and-control (C2)
server, the machine the malware phones home to for payloads and commands. Rather
than save the download to disk, it writes it into an anonymous file created with
memfd_create. The
file has no name anywhere on the filesystem; the only handle to it is a file
descriptor, and its contents never leave memory. That is the point: a defender
scanning disk has nothing to find. No path to scan, no inode on disk, nothing for
file-integrity monitoring to hash, and nothing left for forensics after a reboot.
To run that in-memory file, it calls
execveat on the
descriptor with AT_EMPTY_PATH, which executes the file descriptor directly.
With no filename to use, the kernel labels the process
/dev/fd/4. A
process whose executable shows up as /dev/fd/N instead of a real path is the
tell for fileless execution. Stage 1 does the same to launch the implant, which
appears as /dev/fd/5. All three run with CAP_SYS_ADMIN, the capability that
lets a process load code into the kernel.
That load is the implant’s next move. It asks the C2 for a kernel module
compiled for the host’s exact kernel and loads it with finit_module. The
module, voidlinkmalware.ko, is the rootkit: code running inside the kernel,
beneath the tools an operator uses to inspect the system. It is out-of-tree and
unsigned, so loading it taints the kernel and logs a warning to dmesg, the one
place the chain makes noise.
The rootkit installs two hooks. They are separate backdoors for separate situations, not one mechanism.
The first is on icmp_rcv, the function every incoming ping passes through.
When it sees an echo request with id 0xC0DE and sequence 1337, the module
spawns /bin/bash and connects it to a TCP socket back to the sender on port
1337. Because the kernel is what launches that shell, it runs as root, so one
crafted ping hands the attacker a root shell from off the box. On the wire it is
a normal ping, and there is no listening socket for a scan to find until the
packet lands.
The second hook is on prctl, and it covers the other case: an attacker already
on the host as an unprivileged user. Any process that calls prctl with option
0x564C is promoted straight to uid 0. That constant is not random: the bytes
0x56 0x4C are ASCII VL, the author signing the backdoor with the malware’s
own initials. The whole privilege escalation is that one syscall with the right
option, no exploit and no new connection.
Both hooks live in kernel space, so nothing in userspace can see or remove them.
/dev/fd/N, and the rootkit lives in kernel space.closing it down
After the module loads, the rootkit is in the kernel and can hide from the tools
you would use to catch it, so the load is the last moment the system still
reports the truth. Two steps before that go through kernel hooks I can attach to
and reject: the fileless exec, and the module load itself. Kill the load and the
icmp_rcv and prctl hooks never get installed, since the module that installs
them never runs.
The fileless exec has no path on disk, but the memfd still has one inside the
kernel. It is /memfd: followed by the name the dropper gave it, and
security_bprm_check
sees it before the exec proceeds. Stage 1 and the implant both match on that
prefix.
The module load is finit_module or init_module. Both go through
security_kernel_read_file or security_kernel_load_data with the
READING_MODULE id, the same hooks as Tetragon’s own
modules policy.
On a cluster that never loads out-of-tree modules this fires for nothing else.
The prctl backdoor is a prctl with option 0x564C, and nothing legitimate
passes that value. Treat it as a backstop. Once the rootkit is resident it can
patch the syscall entry itself, so the rule that actually neuters it is the
module block above.
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: voidlink
spec:
kprobes:
# Stage 1 and implant: exec of a memfd-backed binary
- call: security_bprm_check
syscall: false
args:
- index: 0
type: linux_binprm
selectors:
- matchArgs:
- index: 0
operator: Prefix
values:
- "/memfd:"
matchActions:
- action: Override
argError: -1
- action: Sigkill
# finit_module: module read from a file descriptor
- call: security_kernel_read_file
syscall: false
args:
- index: 0
type: file
- index: 1
type: int
selectors:
- matchArgs:
- index: 1
operator: Equal
values:
- "2" # READING_MODULE
matchActions:
- action: Override
argError: -1
- action: Sigkill
# init_module: module passed in from a buffer
- call: security_kernel_load_data
syscall: false
args:
- index: 0
type: int
selectors:
- matchArgs:
- index: 0
operator: Equal
values:
- "2" # READING_MODULE
matchActions:
- action: Override
argError: -1
- action: Sigkill
# prctl backstop: the VoidLink magic option
- call: sys_prctl
syscall: true
args:
- index: 0
type: int
selectors:
- matchArgs:
- index: 0
operator: Equal
values:
- "22092" # 0x564C
matchActions:
- action: Override
argError: -1
- action: Sigkill
Both actions are there on purpose.
Override
fails the call at the hook with -1, atomically.
Sigkill
removes the process so it cannot retry. Override needs
CONFIG_BPF_KPROBE_OVERRIDE.
One caveat before you turn the first rule on: runc 1.1 clones itself into a
memfd named
runc_cloned:/proc/self/exe
to enter a container, so on nodes still on that version the rule fires on every
container start. Scope it with matchBinaries there. runc 1.2 dropped the clone.