This post is written only for education and research purposes. Do not attempt any unauthorized operation on systems you do not own or have explicit permission. Be careful and keep in mind that you are responsible for your actions. You have a choice.
Hidden Loadable Kernel Modules, or hidden LKMs, are not magic. They usually abuse normal kernel data structures, normal kernel visibility paths, and normal assumptions made by user-space tools. Once defenders understand those pieces, hidden modules become much less mysterious.
This post explains three things:
- What kernel modules are and why attackers care about them.
- How kernel modules hide from common visibility surfaces.
- How defenders can detect suspicious modules from kernel-space.
About Kernel Modules
What is a kernel module?
A kernel module is code that can be loaded into the Linux kernel at runtime. Instead of compiling every driver, filesystem, network feature, or hardware-specific component directly into the kernel image, Linux can load some functionality only when it is needed.
Common examples include:
- hardware drivers
- filesystem drivers
- Netfilter or firewall-related modules
- virtualization modules
- tracing and observability modules
- security tooling modules
A normal module can be loaded with tools such as insmod or modprobe:
sudo insmod example.ko
sudo modprobe br_netfilter
Loaded modules can usually be inspected with:
lsmod
cat /proc/modules
ls /sys/module
cat /proc/kallsyms | grep '\[module_name\]'
At source level, the kernel tracks loaded modules with struct module. Modern Linux kernels store important fields such as the module state, module list entry, module name, sysfs-facing kobject, kallsyms metadata, and module memory ranges inside that structure.
A simplified view looks like this:
struct module {
enum module_state state;
struct list_head list;
char name[MODULE_NAME_LEN];
struct module_kobject mkobj;
struct module_attribute *modinfo_attrs;
struct kobject *holders_dir;
#ifdef CONFIG_KALLSYMS
struct mod_kallsyms __rcu *kallsyms;
struct mod_kallsyms core_kallsyms;
struct module_sect_attrs *sect_attrs;
#endif
struct module_memory mem[MOD_MEM_NUM_TYPES];
};
The exact layout depends on kernel version and configuration, but the important idea stays the same: a loaded module is not just code. It is also metadata, list links, sysfs objects, symbol tables, state values, reference counters, dependency information, and memory ranges.
User-space vs kernel-space
Linux separates normal programs from the kernel.
User-space is where regular processes run. Your shell, browser, SSH daemon, package manager, and monitoring agents run there. User-space programs cannot normally read or modify arbitrary kernel memory.
Kernel-space is where the kernel runs. It manages memory, processes, filesystems, network packets, devices, credentials, and system calls. Code running in kernel-space has much more power than normal user-space code.
The boundary matters because tools like lsmod, ps, ss, find, and cat /proc/... are user-space tools. They ask the kernel for information. If the kernel, or malicious kernel code, lies to them, they can display false information.
That is why hidden LKMs are dangerous: they do not merely hide a file or a process from one tool. They can influence the source of truth.
Why kernel modules are powerful
Kernel modules are powerful because they run with kernel privileges.
A legitimate module may need this power to drive hardware, inspect packets, or implement kernel features. A malicious module wants the same power for different reasons:
- hide processes, files, sockets, or other modules
- intercept system calls or kernel functions
- change credentials
- disable security controls
- tamper with logs or monitoring
- persist below user-space visibility
- manipulate what forensic tools see
This is why attackers care about LKMs. Once malicious code runs in kernel-space, it can attack defenders from a privileged position.
Normal module visibility
A loaded module usually has several visibility surfaces:
lsmod
cat /proc/modules
ls /sys/module/<name>
cat /proc/kallsyms | grep '\[<name>\]'
modinfo <name>
These surfaces do not all work the same way.
lsmod and /proc/modules are based on the kernel's module list. /proc/modules is generated by kernel code that walks the module list and prints module information.
/sys/module/<name> is backed by sysfs and kobjects. Sysfs exposes selected kernel objects and attributes to user-space. For modules, /sys/module/<name> may expose parameters, reference counts, version information, section addresses, notes, dependencies, and other module metadata depending on kernel version and configuration.
/proc/kallsyms exposes kernel symbols, including module symbols when kallsyms support and permissions allow it. This is useful for debugging, tracing, stack traces, and forensic inspection.
A hidden module tries to break these visibility paths.
Deep Dive Into Shadows
List removal
The most common hiding method is removing the module from the global module list.
Linux commonly uses embedded linked lists. A structure contains a struct list_head field, and the kernel uses that field to link many objects together. For modules, the relevant field is:
struct module {
enum module_state state;
struct list_head list;
char name[MODULE_NAME_LEN];
...
};
A very small hiding example looks like this:
static void hide_module(void)
{
list_del(&THIS_MODULE->list);
}
That one line removes the current module from the linked list used by normal module walkers.
At a lower level, list deletion connects the previous and next entries to each other:
static inline void __list_del(struct list_head *prev,
struct list_head *next)
{
next->prev = prev;
WRITE_ONCE(prev->next, next);
}
Imagine the visible module list like this:
[module_a] <-> [rootkit] <-> [module_b]
After deletion:
[module_a] <------------> [module_b]
[rootkit] is still in memory, but no longer linked
The module is not gone. Its code and struct module can still exist in memory. It is simply removed from one important index.
That matters because /proc/modules is produced by walking the module list. If a module is no longer linked, list-based enumeration will not find it. The same idea affects many other kernel paths that expect the official module list to be trustworthy.
Public rootkit projects have used this general pattern. Diamorphine, Singularity, KoviD, CARAXES, and brokepkg are examples of public LKM rootkits or proof-of-concept projects that demonstrate module hiding or related kernel stealth techniques.
Smells like poison
There is a useful defensive detail: list_del() leaves traces.
The Linux linked-list API documents that list_del() removes an entry and poisons the entry's prev and next pointers so unintended use after removal does not go unnoticed. Simplified:
static inline void list_del(struct list_head *entry)
{
__list_del_entry(entry);
entry->next = LIST_POISON1;
entry->prev = LIST_POISON2;
}
With list_del(), a removed module may have poisoned list pointers:
module->list.next = LIST_POISON1
module->list.prev = LIST_POISON2
list_del_init() behaves differently:
static inline void list_del_init(struct list_head *entry)
{
__list_del_entry(entry);
INIT_LIST_HEAD(entry);
}
INIT_LIST_HEAD() makes the list head point back to itself:
static inline void INIT_LIST_HEAD(struct list_head *list)
{
WRITE_ONCE(list->next, list);
WRITE_ONCE(list->prev, list);
}
So a hidden module may look like this:
module->list.next = &module->list
module->list.prev = &module->list
Both patterns are useful detection signals.
There is also list_del_init_careful(). Internally it is similar to list_del_init(), but it is designed to be paired with list_empty_careful() so memory operations before deletion are visible after the careful empty-list test. For hidden-module detection, it commonly leaves the same self-pointing list-head pattern defenders can look for.
A more careful rootkit can overwrite those pointers with arbitrary-looking values instead of leaving poison or self-pointers. For example Singularity rootkit uses:
mod->list.prev = (struct list_head *)0x37373731;
mod->list.next = (struct list_head *)0x22373717;
That does not make the module disappear from memory. It only tries to defeat simple checks like "does the list point to itself?" or "does the list contain poison?"
A better detector should not rely on only one list-pointer pattern. It should combine several signals.
Kallsyms manipulation
kallsyms helps map kernel addresses to symbol names. It is useful for debugging, tracing, stack traces, and forensic inspection. For modules, kallsyms metadata is stored through module-related structures.
The module kallsyms structure contains a symbol table pointer and a symbol count:
struct mod_kallsyms {
Elf_Sym *symtab;
unsigned int num_symtab;
char *strtab;
char *typetab;
};
The module kallsyms code walks module symbols using num_symtab. A hiding routine may therefore do this:
static void remove_symbols_from_kallsyms(struct module *mod)
{
if (mod->kallsyms)
mod->kallsyms->num_symtab = 0;
}
This does not remove the module from memory. It does not necessarily remove every reference to the module. It only makes kallsyms-based enumeration see zero symbols for that module.
Defensively, this is interesting because a real loaded module with suspiciously broken or empty kallsyms metadata may deserve attention, especially if other fields still look like a valid struct module.
Module state manipulation
Modules have states. Modern Linux kernels define states similar to this:
enum module_state {
MODULE_STATE_LIVE, /* Normal state. */
MODULE_STATE_COMING, /* Fully formed, running module_init. */
MODULE_STATE_GOING, /* Going away. */
MODULE_STATE_UNFORMED, /* Still setting it up. */
};
MODULE_STATE_UNFORMED is meant for a module that is still being set up. It should not be a long-term state for a normal loaded module.
Some kernel paths intentionally skip unformed modules. /proc/modules, for example, ignores modules in this state:
/* We always ignore unformed modules. */
if (mod->state == MODULE_STATE_UNFORMED)
return 0;
Module kallsyms lookup paths also skip modules in MODULE_STATE_UNFORMED state.
A malicious module can abuse that behavior:
mod->state = MODULE_STATE_UNFORMED;
This can hide the module from views that still walk the module list but intentionally skip unformed entries.
From a defensive perspective, a module that appears to be permanently stuck in MODULE_STATE_UNFORMED is suspicious. It may be a failed load, a race, a kernel bug, or manipulation. It should not be ignored.
Sysfs removal
After list hiding and kallsyms manipulation, /sys/module/<name> can still reveal that a module exists.
Sysfs is a virtual filesystem mounted at /sys. It exposes selected kernel objects and attributes to user-space. Kobjects are the kernel's generic object model for this kind of representation: they are named, reference-counted objects that can appear as directories in sysfs. Ksets group related kobjects together.
For modules, the kernel has a module-specific kobject wrapper:
struct module_kobject {
struct kobject kobj;
struct module *mod;
struct kobject *drivers_dir;
struct module_param_attrs *mp;
struct completion *kobj_completion;
};
And inside struct module:
struct module {
...
struct module_kobject mkobj;
struct module_attribute *modinfo_attrs;
struct kobject *holders_dir;
...
};
The normal module sysfs setup path initializes the module kobject, creates /sys/module/<name>, creates the holders directory, sets up parameters, adds modinfo attributes, adds usage links, and creates section and note attributes when available.
A rootkit may try to undo part of that setup:
static void remove_from_sysfs(struct module *mod)
{
struct kobject *kobj = &mod->mkobj.kobj;
if (kobj && kobj->parent) {
kobject_del(kobj);
kobj->parent = NULL;
kobj->kset = NULL;
if (mod->holders_dir) {
kobject_put(mod->holders_dir);
mod->holders_dir = NULL;
}
}
}
Line by line:
struct kobject *kobj = &mod->mkobj.kobj;
This takes the sysfs-facing kobject embedded in the module. The module is the real object. The kobject is the part used by the kobject/sysfs infrastructure.
kobject_del(kobj);
This unregisters the kobject from sysfs. In practical terms, the /sys/module/<name> directory can disappear even though the module still exists in memory.
kobj->parent = NULL;
The parent is the object above this kobject in the sysfs hierarchy. Clearing it breaks the visible parent relationship.
kobj->kset = NULL;
The kset groups related kobjects. For modules, the module kobject normally belongs to the module kset. Clearing this makes the object less connected to the normal module sysfs structure.
if (mod->holders_dir) {
kobject_put(mod->holders_dir);
mod->holders_dir = NULL;
}
holders_dir represents /sys/module/<name>/holders. That directory is used to expose dependency relationships between modules. Releasing it and nulling the pointer removes another sysfs artifact.
Some hiding code may also damage section attributes:
mod->sect_attrs = NULL;
sect_attrs backs /sys/module/<name>/sections on kernels that expose module section information. Those section files can reveal where the module's text, data, and other sections are mapped. Setting sect_attrs to NULL can hide section information or prevent later cleanup code from seeing it. It can also leave inconsistencies: other module fields may still prove that the module exists.
Hiding summary
Most hidden LKM techniques are not mysterious. They usually tamper with one or more of these:
struct module.liststruct module.statestruct module.kallsymsstruct module.mkobj.kobjstruct module.holders_dirstruct module.sect_attrs- sysfs files and links
- hooks that lie to user-space tools
A simple rootkit may only remove itself from the module list.
A more careful rootkit may combine list removal, state manipulation, kallsyms tampering, and sysfs removal.
A more advanced rootkit may also hook kernel functions so that even defensive code receives manipulated answers. That is outside the main scope of this post, but defenders should remember it: hidden modules are one layer of the problem. Self-defense and anti-forensics are another.
Detection
Make the fight fair
User-space tools ask the kernel for information. A kernel rootkit can lie to user-space tools. It means user-space evidence is incomplete. If lsmod, /proc/modules, /sys/module, and /proc/kallsyms disagree with each other, that disagreement is valuable. But if a rootkit controls the answer path, user-space may still be fooled.
For stronger detection, defenders can use a trusted kernel-space detector, offline memory forensics, a hypervisor-based monitor, or a trusted boot chain with measured integrity. A detector running as a kernel module is not magically unbeatable, but it has one major advantage compared to user-space: it can inspect kernel memory and kernel structures more directly.
Modules live in memory
Hiding from a list does not erase memory.
A hidden module may still have:
- a valid-looking
struct module - a module name
- a sane module state
- memory ranges in module memory
- kobject remnants
- kallsyms remnants
- list pointers that look poisoned, self-pointing, fake, or inconsistent
One practical detection strategy is:
- Snapshot the visible module memory ranges.
- Sort those ranges by address.
- Scan the memory gaps between visible modules.
- Treat each aligned address as a possible embedded
module->list. - Use
container_of()to calculate the possiblestruct module. - Validate the candidate with several independent checks.
- Report or carefully re-link the candidate for inspection.
This is a heuristic. It depends on kernel version, module allocator behavior, configuration, and rootkit behavior. It is still useful because many hidden LKMs remain resident near other module allocations.
A compact region snapshot can look like this:
struct module_region {
unsigned long start;
unsigned long end;
};
static struct module_region *visible_regions;
static int visible_count;
static struct list_head *visible_anchor;
The detector first collects visible modules:
static int collect_visible_module_regions(void)
{
struct module *mod;
/*
* The global module-list head is not normally exported to modules.
* A detector may use a known linked module entry as an iteration anchor.
* The exact implementation must be adjusted to the target kernel.
*/
visible_anchor = THIS_MODULE->list.prev;
list_for_each_entry(mod, visible_anchor, list) {
unsigned long start = MODULE_START(mod);
unsigned long end = MODULE_END(mod);
if (!start || end <= start)
continue;
save_region(start, end);
}
sort(visible_regions, visible_count,
sizeof(*visible_regions), compare_regions, NULL);
return visible_count ? 0 : -ENOENT;
}
The important idea is not the exact helper names. The idea is the snapshot:
visible module A: 0xffffffffc0200000 - 0xffffffffc021a000
visible module B: 0xffffffffc0240000 - 0xffffffffc0259000
visible module C: 0xffffffffc0280000 - 0xffffffffc0293000
That creates gaps:
gap 1: A.end -> B.start
gap 2: B.end -> C.start
A hidden module removed from the list may still live inside one of those gaps.
Scanning memory gaps
The scanner can walk through each gap pointer-by-pointer:
for (i = 0; i < visible_count - 1; i++) {
unsigned long gap_start = visible_regions[i].end;
unsigned long gap_end = visible_regions[i + 1].start;
unsigned long addr;
for (addr = gap_start;
addr + sizeof(struct list_head) <= gap_end;
addr += sizeof(void *)) {
struct module *candidate;
candidate = container_of((struct list_head *)addr,
struct module,
list);
inspect_candidate(candidate, gap_start, gap_end);
}
}
Why pretend each address is module->list?
Because struct list_head list is embedded inside struct module. If we guess the address of the embedded list field, container_of() gives us the possible base address of the surrounding struct module.
Most guesses will be wrong. That is expected. The scanner must filter aggressively.
Validating candidates
A good detector should avoid trusting one signal. Random memory can accidentally look interesting. A hidden module candidate should satisfy multiple checks.
First, make sure the candidate lives inside the gap:
if ((unsigned long)candidate < gap_start ||
(unsigned long)candidate >= gap_end)
return;
Then read carefully. Kernel memory scanning must avoid crashing the system on bad addresses:
if (copy_from_kernel_nofault(&state,
&candidate->state,
sizeof(state)))
return;
Check whether the state is inside the expected enum range:
if (state < MODULE_STATE_LIVE ||
state > MODULE_STATE_UNFORMED)
return;
Read and validate the name:
if (copy_from_kernel_nofault(name,
candidate->name,
sizeof(name) - 1))
return;
if (!module_name_looks_valid(name))
return;
Check the module kobject back-pointer:
if (copy_from_kernel_nofault(&self,
&candidate->mkobj.mod,
sizeof(self)))
return;
if (self != candidate)
return;
That is a strong signal. In normal module sysfs setup, the module kobject points back to the module. Random memory is unlikely to satisfy that cleanly.
Then check the list pointers:
next = (unsigned long)candidate->list.next;
prev = (unsigned long)candidate->list.prev;
list = (unsigned long)&candidate->list;
A properly linked doubly linked list should satisfy:
candidate->list.next->prev == &candidate->list;
candidate->list.prev->next == &candidate->list;
So the detector can test whether the candidate is still properly linked:
if (next != list &&
prev != list &&
!copy_from_kernel_nofault(&next_prev,
(void *)(next + sizeof(void *)),
sizeof(next_prev)) &&
!copy_from_kernel_nofault(&prev_next,
(void *)prev,
sizeof(prev_next)) &&
next_prev == list &&
prev_next == list) {
already_visible = true;
}
If the candidate looks like a real module but is not properly linked into the visible list, it becomes suspicious.
Strong hidden-module indicators include:
- valid-looking module name
- valid module state
mkobj.modpoints back to the same module- module memory range looks sane
- list pointers are poisoned, self-pointing, fake, or broken
- module is missing from
/proc/modules - sysfs state is missing or damaged
- kallsyms metadata is empty or inconsistent
No single check is perfect. The strength comes from combining them.
Re-adding a hidden module
After detection, a defender may want to make the module visible again.
The simplest case is list-only hiding. Re-adding the module to the visible list may make it appear again in lsmod and /proc/modules:
list_add(&hidden->list, visible_anchor);
hidden->state = MODULE_STATE_LIVE;
But careful rootkits may also damage sysfs state. A more complete repair may need to restore the module kobject relationship:
hidden->mkobj.mod = hidden;
kobj = &hidden->mkobj.kobj;
if (!kobj->state_in_sysfs) {
kobj->parent = NULL;
kobj->kset = THIS_MODULE->mkobj.kobj.kset;
err = kobject_add(kobj, NULL, "%s", hidden->name);
}
If the holders directory was removed, the detector may try to recreate it:
if (!hidden->holders_dir) {
hidden->holders_dir =
kobject_create_and_add("holders", &hidden->mkobj.kobj);
}
If modinfo attributes were removed, they may need to be recreated:
for (attr = hidden->modinfo_attrs; attr->attr.name; attr++) {
err = sysfs_create_file(&hidden->mkobj.kobj, &attr->attr);
if (err && err != -EEXIST)
modinfo_ok = false;
}
This kind of repair can be useful for investigation, but it is not automatically safe.
Re-adding a malicious module is not the same as neutralizing it. Do not assume rmmod will be safe. The module may have corrupted its exit path, reference counts, dependency data, memory, or active hooks.
In production incidents, prefer evidence preservation, memory capture, isolation, and trusted recovery over "quick cleanup."
Defensive workflow
A practical blue-team workflow can look like this:
1) Compare normal visibility surfaces and look for disagreement.
lsmod
cat /proc/modules
ls /sys/module
cat /proc/kallsyms | grep '\[.*\]'
dmesg | grep -i module
Examples:
- module has a sysfs directory but is missing from
/proc/modules - module symbols exist but module is missing from
lsmod - module has suspicious state
- module appears and disappears unexpectedly
- module load logs exist but no module is visible
- kernel taint flags changed unexpectedly
2) Use a trusted kernel-space detector or offline memory analysis.
User-space checks are useful triage. Kernel-space or offline memory inspection is stronger.
3) Treat cleanup carefully.
A hidden LKM means the kernel is no longer fully trusted. On important systems, the safest response is usually:
- isolate the host
- preserve volatile evidence if possible
- collect memory and disk images
- rebuild from trusted media
- rotate credentials
- investigate initial access
- review module loading policy
Prevention matters
Detection is important, but prevention is better.
Useful hardening controls include:
- enable Secure Boot where appropriate
- require signed kernel modules
- disable unnecessary module loading
- restrict access to
CAP_SYS_MODULE - monitor
init_module,finit_module, anddelete_module - monitor changes to
/lib/modules - keep kernels updated
- use endpoint telemetry that can detect kernel tampering
- collect kernel logs centrally
- prefer immutable or measured boot approaches for high-value systems
Module signing is especially important because it moves the fight earlier: instead of only detecting a malicious module after it loads, the kernel can reject untrusted modules at load time when configured restrictively.
Detection conclusion
Detecting hidden kernel modules is not impossible. Many hiding techniques are simple manipulations of normal kernel structures:
- unlink from
struct module.list - poison or fake list pointers
- set
MODULE_STATE_UNFORMED - zero kallsyms symbol counts
- remove the sysfs kobject
- destroy or null sysfs-related fields
The defender's advantage comes from understanding that hiding from one view does not erase all evidence. A hidden module may be gone from lsmod, but it still needs memory, metadata, code, state, and relationships to function.
A good detector does not ask only one question. It asks many:
- Is this module in the official list?
- Does it still exist in memory?
- Does it have a valid name?
- Does its state make sense?
- Does its kobject point back correctly?
- Are its list pointers sane?
- Does sysfs agree with
/proc/modules? - Does kallsyms agree with the module list?
- Are there signs of active hook-based self-defense?
Answering these questions helps bring hidden kernel modules out of the shadows.