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.
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.
$ 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:
$ 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:
$ 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.
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:
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:
$ 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.
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:
$ 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.
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:
$ 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.
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:
# 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.
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
$ 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:
Identifying All 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.
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:
$ 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:
- Navigate to the
xor_keylabel in the.datasection - Select the full memory segment at that label
- Use Edit → Copy special → Byte String (No Spaces)
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:
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.
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.
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:
We extract both elf_hdr and elf_body from the .data section, then reconstruct the full ELF:
# 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:
/* 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.
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:
/* 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.
16 · Attack Chain Summary
Piecing together all the evidence, here's the complete picture of the Volnaya rootkit's architecture and attack chain:
- 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.