Blocking VoidLink with Tetragon

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.

diskmemoryuserspacekernelattacker's C2 serverhosts every downloadstage 0 dropper…/voidlink/stage0stage 1/dev/fd/4 (memfd)implant/dev/fd/5 (memfd)kernel rootkitvoidlinkmalware.ko① HTTP fetch② memfd + execveat③ memfd + execveat④ finit_moduleicmp_rcv hookprctl hookICMP reverse shellping id 0xC0DE / seq 1337→ /bin/bash to :1337instant rootprctl option 0x564C→ uid 0
VoidLink stages itself in memory and hides in the kernel. Only the stage 0 dropper ever touches disk; every later stage runs from /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.