HTB Business CTF Forensics Operation Blackout 2025

Driver's Shadow — Unmasking the Kernel Intruder

Deep dive into Linux memory forensics with Volatility3. Hunting a kernel rootkit hiding in plain sight — ftrace hooks, XOR-obfuscated payloads, and stealth persistence through udev rules.

Memory ForensicsVolatility3RootkitftraceKernel ModuleGhidraHard

01 · Challenge Overview

A critical Linux server was captured exhibiting anomalous behavior under suspected Volnaya interference. A full memory snapshot was taken for analysis. Stealthy components embedded in the dump are altering system behavior and hiding their tracks — our job is to sift through the snapshot, uncover these concealed elements, and extract any hidden payloads.

Challenge Details
Category: Forensics • Difficulty: Hard • Event: HTB Business CTF 2025 — Operation Blackout • Author: c4n0pus
Provided: mem.elf (2.17 GB ELF 64-bit core dump, x86-64)

The challenge is structured as a series of questions that progressively guide you deeper into the rootkit's architecture — from surface-level indicators like rogue udev rules, all the way down to extracting and reverse-engineering the kernel module itself. Each answer builds on the previous, creating a complete picture of the intruder's foothold on the system.

02 · Initial Reconnaissance

As with any memory dump analysis, the first step is identifying what we're working with. Volatility3 is the tool of choice here — but before we can run any plugins, we need to know the kernel version to determine whether we have matching symbols.

BashIdentify kernel version
$ vol -f mem.elf banners.Banners
Linux version 6.1.0-34-amd64 (debian-kernel@lists.debian.org)
  (gcc-12 (Debian 12.2.0-14+deb12u1) 12.2.0,
   GNU ld (GNU Binutils for Debian) 2.40)
  #1 SMP PREEMPT_DYNAMIC Debian 6.1.135-1 (2025-04-25)

This is a fairly recent Debian 12 kernel — 6.1.0-34-amd64. Running basic plugins like linux.pslist.PsList immediately returns empty results, which confirms our suspicion: we don't have a matching ISF (Intermediate Symbols File). Without symbols, Volatility cannot resolve kernel structures, so we need to generate them ourselves.

03 · Generating the ISF Symbols File

An ISF is a structured representation of a kernel's debug symbols — it tells Volatility how to interpret memory structures like task structs, file descriptors, and module lists. Without it, Volatility is effectively blind. The process involves spinning up a VM running the exact same kernel, then extracting symbols with dwarf2json.

Setting Up the Environment

Download Debian 12 from the official site, install it in a VM, then match the kernel version exactly:

BashMatch kernel in Debian VM
$ sudo apt install linux-image-6.1.0-34-amd64
$ sudo shutdown -r now
$ uname -r
6.1.0-34-amd64

Extracting Symbols with dwarf2json

With the matching kernel booted, we build and run dwarf2json to produce the ISF:

BashGenerate ISF symbols
$ cd /opt
$ git clone https://github.com/volatilityfoundation/dwarf2json.git
$ sudo apt install golang
$ cd dwarf2json && go build -buildvcs=false

$ sudo ./dwarf2json linux \
    --elf /usr/lib/debug/boot/vmlinux-6.1.0-34-amd64 \
    --system-map /boot/System.map-6.1.0-34-amd64 \
    > Debian12-6.1.0-34-amd64.json

Copy the resulting JSON to volatility3/volatility3/symbols/ and Volatility will now be able to resolve all kernel structures. This is a critical step that many CTF players skip or struggle with — without symbols, you're stuck at the starting line.

Pro Tip
Always verify the ISF works by running a simple plugin like linux.pslist after placing it. If you get process listings, you're good to go. If not, double-check the kernel version matches exactly — even a minor revision difference can cause symbol mismatches.

04 · Harvesting Runtime Artifacts

With symbols in place, I run a battery of Volatility plugins to capture a broad picture of the system state. Rather than running them one at a time, I automate this with a script that iterates through the most useful plugins and saves output for offline analysis:

Pythonvol_harvest.py
import subprocess, os, sys

def main():
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} ")
        sys.exit(1)

    image = sys.argv[1]
    outdir = "./vol_out"
    os.makedirs(outdir, exist_ok=True)

    plugins = [
        "linux.bash.Bash",
        "linux.elfs.Elfs",
        "linux.envars.Envars",
        "linux.ip.Link",
        "linux.ip.Addr",
        "linux.lsof.Lsof",
        "linux.malfind.Malfind",
        "linux.pagecache.Files",
        "linux.psaux.PsAux",
        "linux.pslist.PsList",
        "linux.psscan.PsScan",
        "linux.pstree.PsTree",
        "linux.sockstat.Sockstat",
        "linux.lsmod.Lsmod",
    ]

    for plugin in plugins:
        name = plugin.split(".")[-1]
        print(f"[*] Running {plugin}...")
        result = subprocess.run(
            ["vol", "-f", image, plugin],
            capture_output=True, text=True
        )
        with open(f"{outdir}/{name}.txt", "w") as f:
            f.write(result.stdout)

if __name__ == "__main__":
    main()

This gives us a comprehensive snapshot of running processes, open sockets, loaded modules, cached files, and potential code injection artifacts. Now we can start hunting for anomalies across all outputs systematically.

05 · Backdoor udev Rule

The first indicator of compromise surfaces in the page cache output. Scanning through Files.txt, most udev rules live under /usr/lib/udev/rules.d/ and have standard names. But one entry immediately stands out:

BashSearching pagecache for udev rules
$ rg "udev" vol_out/Files.txt | grep -v "standard"
/usr/lib/udev/rules.d/99-volnaya.rules

99-volnaya.rules — this is not a standard Debian udev rule. The 99- prefix ensures it runs last in the rule processing order, and the "volnaya" naming convention ties it to the threat actor. This rule likely triggers the rootkit loader whenever a specific device event occurs, providing a persistence mechanism that blends in with the normal device-handling workflow.

🚩
Flag — Backdoor udev Rule
99-volnaya.rules

06 · Attacker IP Address

Moving to network artifacts, the socket statistics output reveals suspicious outbound connections. A bash process initiating TCP connections is always a red flag — shells don't typically make direct network connections unless they're running a reverse shell or command-and-control client:

BashHunting suspicious connections in sockstat
$ rg "bash" vol_out/Sockstat.txt
bash  2957  TCP  ESTABLISHED  10.10.10.5:42312 -> 16.171.55.6:443

A bash process (PID 2957) has an established TCP connection to an external IP over port 443 — classic C2 behavior disguised as HTTPS traffic. The destination IP is our attacker's command infrastructure.

🚩
Flag — Attacker IP
16.171.55.6

07 · Kernel Module Name

Since we've already identified the "volnaya" naming convention from the udev rule, we can search across all Volatility outputs for that string. The loaded kernel modules output confirms the rootkit:

BashSearching for volnaya in module list
$ rg "volnaya" vol_out/Lsmod.txt
volnaya_xb127  114688  1  Live  0x00000000fffffffa

The module volnaya_xb127 is loaded and active — the 1 in the use count indicates it's currently in use by at least one component. At 114688 bytes, this is a substantial kernel module with significant functionality, consistent with a full-featured rootkit that hooks multiple syscalls and hides files/processes.

🚩
Flag — Kernel Module Name
volnaya_xb127

08 · Hidden Userspace Bash Process

Cross-referencing the process scan output with the socket statistics reveals a hidden bash process. While linux.pslist (which walks the linked list) may not show it due to rootkit DKOM (Direct Kernel Object Manipulation), linux.psscan (which scans memory pools) finds all processes:

BashCorrelating psscan with sockstat
# Find bash processes that are direct children of systemd
$ rg "bash" vol_out/PsScan.txt | rg "systemd"
2957  bash  systemd  0  ...  sh -c id

# This same PID appears in sockstat as the reverse shell

PID 2957 stands out for two reasons: first, it's a bash process spawned directly by systemd (unusual — systemd typically launches service binaries, not interactive shells). Second, it runs sh -c id — a command commonly used by reverse shells to verify execution context. This is the rootkit's hidden C2 client, concealed from the standard process list but discoverable through memory pool scanning.

🚩
Flag — Hidden Bash PID
2957

09 · ftrace Hooks & Hooked Syscalls

This is where the investigation gets particularly interesting. The rootkit doesn't modify the syscall table directly — instead, it uses ftrace, the kernel's built-in tracing framework, to hook syscall handlers. This is a stealthier technique because traditional rootkit detectors only check the syscall table for modifications, not ftrace hooks.

Syscall Addresses

BashChecking syscall table integrity
$ vol -f mem.elf linux.check_syscall.Check_syscall

Among all syscalls, two have addresses that don't match the expected kernel text region — __x64_sys_kill and __x64_sys_getdents64. These are the two hooks the rootkit has installed:

🚩
Flag — Hooked Syscall Addresses
0xffffb88b6bf0:0xffffb8b7c770

Identifying All ftrace Hooks

BashEnumerating ftrace hooks
$ vol -f mem.elf linux.tracing.ftrace.CheckFtrace

The ftrace output shows all registered hooks. Most are legitimate kernel functions, but two correspond to syscalls. The rootkit hooks kill (syscall 62) and getdents64 (syscall 217). The kill hook serves as a covert IPC channel — the rootkit intercepts signals with specific numbers to trigger actions. The getdents64 hook is used for file hiding — it filters directory listings to exclude files matching certain criteria.

Why ftrace?
Traditional rootkit detectors check the syscall table for modifications. By using ftrace hooks instead, the rootkit avoids this detection method entirely. The syscall table remains "clean" — the hooks are registered through the legitimate ftrace infrastructure, making them far harder to spot without dedicated ftrace analysis.
🚩
Flag — Hooked Syscalls (sorted by number)
kill:getdents64

10 · Extracting the Rootkit Loader

Now we need to extract the actual binaries for deeper analysis. The page cache output shows that /usr/bin/volnaya_usr (the userspace loader) is fully cached in memory:

BashLocating the loader in page cache
$ rg "volnaya" vol_out/Files.txt
0x9a0974b68128  /usr/bin/volnaya_usr  REG  107/107  -rwxr-xr-x

# All 107 pages cached — we can dump the full binary
$ vol -f mem.elf linux.pagecache.InodePages \
    --inode 0x9a0974b68128 --dump

Unfortunately, the kernel module volnaya_xb127 shows 0 cached pages in the page cache — we can't dump it directly. But the loader binary contains everything we need to reconstruct it, as we'll see shortly.

Open the dumped volnaya_usr binary in Ghidra and navigate to the main function to begin reverse engineering.

11 · XOR Key Discovery

Inside main(), there's a reference to a xor_key variable in the .data section. This key is used throughout the rootkit — for decoding hostnames, decrypting the embedded kernel module, and obfuscating strings. To extract it:

  1. Navigate to the xor_key label in the .data section
  2. Select the full memory segment at that label
  3. Use Edit → Copy special → Byte String (No Spaces)
🚩
Flag — XOR Key
881ba50d42a430791ca2d9ce0630f5c9

This 16-byte key is a standard XOR cipher key used for all decryption operations within the rootkit. While XOR obfuscation is trivially reversible once you have the key, it effectively prevents static analysis tools from automatically extracting strings and configurations from the binary.

12 · Decrypting the C2 Hostname

Also in main(), the loader constructs the C2 hostname by XOR-decrypting a hardcoded byte array with the same key. The encrypted hostname bytes are stored in .data — we can extract them and decrypt with Python:

Pythondecrypt_hostname.py
enc_hostname = bytes.fromhex(
    "eb7ac96120c5531232c1b7ad3408c4f8a66dca612cc5491832caadac"
)
xor_key = bytes.fromhex("881ba50d42a430791ca2d9ce0630f5c9")

decoded = bytearray()
for i in range(len(enc_hostname)):
    decoded.append(enc_hostname[i] ^ xor_key[i % len(xor_key)])

print(decoded.decode('utf-8', errors='replace'))
# Output: callback.cnc2811.volnaya.htb

The rootkit phones home to callback.cnc2811.volnaya.htb — a Volnaya C2 infrastructure hostname following their naming convention.

🚩
Flag — C2 Hostname
callback.cnc2811.volnaya.htb

Bonus: Covert IPC via kill()

While analyzing the loader's init_revshell function, there's a peculiar call: kill(0x41, local_c). Signal 0x41 (decimal 65) is well above the maximum valid signal number (64). This makes no sense for a legitimate kill() call — until you recall that the rootkit hooks the kill syscall. The rootkit intercepts these out-of-range "signals" as a covert communication channel between the userspace loader and the kernel module. When the kernel module sees signal 65, it knows this is a command from the loader, not an actual signal — and processes it accordingly.

Rootkit IPC Pattern
Using hooked syscalls as IPC channels is a common rootkit design pattern. The kill syscall is particularly popular because: (1) it's rarely monitored, (2) the signal number field can encode commands, and (3) the PID field can carry data. This creates a bidirectional communication channel that's invisible to conventional process monitoring.

13 · Extracting the Kernel Module

The kernel module couldn't be dumped from page cache because it wasn't cached there — kernel modules live in kernel memory, not in the page cache. However, the loader binary contains the entire kernel module as an embedded, XOR-encrypted payload. By analyzing the install_module() function in Ghidra, we find the reconstruction logic:

install_module() │ Calls syscall 0xAF (175 = sys_init_module) │ Arguments: │ • local_18 → pointer to deobf() output │ • 0x666c0 → module size (419520 bytes) │ • args → module parameters │ │ local_18 = deobf(elf_hdr + elf_body) │ │ elf_hdr = 64-byte plain ELF header │ elf_body = XOR-encrypted module body │ xor_key = 881ba50d42a430791ca2d9ce0630f5c9 │ ▼ Reconstruct: write elf_hdr + decrypt(elf_body)

We extract both elf_hdr and elf_body from the .data section, then reconstruct the full ELF:

Pythonreconstruct_module.py
# Copy hex from Ghidra .data sections
elf_hdr  = bytes.fromhex("7f454c46020101...")  # 64-byte ELF header
elf_body = bytes.fromhex("8c3a7b1d5e9f...")   # Encrypted body

xor_key = bytes.fromhex("881ba50d42a430791ca2d9ce0630f5c9")

# Decrypt the body
decrypted = bytearray()
for i in range(len(elf_body)):
    decrypted.append(elf_body[i] ^ xor_key[i % 16])

# Reconstruct full ELF
with open("volnaya_xb127.ko", "wb") as f:
    f.write(elf_hdr)
    f.write(decrypted)

print(f"[+] Wrote {len(elf_hdr) + len(decrypted)} bytes")

Open the resulting volnaya_xb127.ko in Ghidra, and we now have full visibility into the rootkit's kernel-space component — including all hook implementations, file-hiding logic, and process concealment mechanisms.

14 · File Hiding — Magic String

Within the kernel module, the filldir hooks are responsible for filtering directory listings. The filldir64 callback is invoked by the kernel for each directory entry — the rootkit's version checks if the filename contains a specific "magic word" before deciding whether to hide it:

Cfilldir hook logic (decompiled)
/* Simplified filldir hook */
static int hook_filldir64(struct dir_context *ctx,
                           const char *name, int namlen,
                           loff_t pos, u64 ino, unsigned d_type)
{
    if (strstr(name, MAGIC_WORD) != NULL)
        return 0;  /* Skip this entry — hide the file */

    return orig_filldir64(ctx, name, namlen, pos, ino, d_type);
}

Any file whose name contains MAGIC_WORD will be invisible to ls, find, and any other tool that reads directory entries through getdents64. This is why hooking getdents64 is so powerful — it affects every directory listing operation on the system.

🚩
Flag — Magic Word for File Hiding
volnaya

15 · File Hiding — UID & GID

The rootkit has a second layer of file hiding beyond the magic word check. In the hook_sys_getdents64() function, it performs metadata-based filtering using vfs_fstatat to retrieve file ownership information:

Cgetdents64 hook — UID/GID filtering (decompiled)
/* Simplified getdents64 hook */
static int hook_getdents64(struct pt_regs *regs)
{
    // ... iterate directory entries ...
    struct stat statbuf;
    vfs_fstatat(AT_FDCWD, entry_name, &statbuf, 0);

    uid_t file_uid = statbuf.st_uid;  // iVar8
    gid_t file_gid = statbuf.st_gid;  // iVar2

    if (file_uid == USER_HIDE && file_gid == GROUP_HIDE)
        /* Hide this entry */
        continue;

    // ... copy visible entries ...
}

The vfs_fstatat call retrieves the file's metadata structure. From this, the rootkit extracts the UID (iVar8) and GID (iVar2) through pointer offsetting into the stat structure. These values are then compared against USER_HIDE and GROUP_HIDE constants defined in the module. Converting the hexadecimal constants to decimal gives us the answer.

🚩
Flag — Hidden File UID:GID
1821:1992

16 · Attack Chain Summary

Piecing together all the evidence, here's the complete picture of the Volnaya rootkit's architecture and attack chain:

PERSISTENCE99-volnaya.rules — udev rule triggers on device event │ Executes /usr/bin/volnaya_usr (userspace loader) │ LOADER (volnaya_usr) │ Decrypts embedded kernel module (XOR key: 881ba50d42a430791ca2d9ce0630f5c9) │ Calls sys_init_module to load volnaya_xb127 │ Establishes reverse shell → callback.cnc2811.volnaya.htb │ Communicates with module via kill(0x41, ...) IPC │ KERNEL MODULE (volnaya_xb127) │ Hooks __x64_sys_kill — covert IPC channel │ Hooks __x64_sys_getdents64 — file/directory hiding │ File hiding rules: │ • Filename contains "volnaya" → hidden │ • Owner UID:GID = 1821:1992 → hidden │ Process hiding via DKOM (bash PID 2957 invisible in pslist) │ C2 COMMUNICATION │ Reverse shell over TCP/443 → 16.171.55.6 │ Covert signaling via hooked kill syscall
Key Takeaways
  • ISF generation is essential — without matching symbols, Volatility is blind. Always verify kernel versions match exactly.
  • psscan vs pslist — rootkits can unlink processes from the task list (DKOM), but memory pool scanning still finds them.
  • ftrace hooks bypass syscall table checks — modern rootkits use legitimate kernel infrastructure for stealth. Always check ftrace.
  • Page cache is a goldmine — even when binaries are deleted from disk, their pages may persist in cache, allowing full binary extraction.
  • XOR obfuscation is trivially reversible — once you find the key (often stored in .data), all strings and payloads are immediately recoverable.