A Linux memory dump of a compromised machine holds the evidence, but nothing in it is marked as evidence. An injected page looks like any other mapping, a hijacked process looks like any other task, and a kernel hook sits in the same lists as the legitimate ones.
mquire reads the dump directly, with no external symbols, and gives you SQL over the kernel’s own structures. I introduced it in an earlier post: it rebuilds kallsyms and BTF from the image itself, so you get named tables for tasks, memory mappings, modules, and the rest without tracking down debug packages that match the exact kernel.
Listing all the memory mappings is not a finding, but “every anonymous mapping that is also executable” is, and SQL is the perfect use case. The recent releases add views that encode those questions directly. This post takes them in order: injected code and loader hijacks, privilege and tracing, and the kernel hooks that give a rootkit away. Every result below is real output.
Injected code and hijacked loaders
The first thing to look for is code the loader never mapped from a file. mquire walks each task’s memory map into the memory_mappings table, one row per region with its permissions, its sharing, and its backing file. Anonymous memory that is both writable and executable is the classic injected-code and shellcode signature, and process_anon_wx_regions is that view: no backing file, writable and executable both set. It keeps the shared column: injected code has no reason to be visible to another process, so a private region is the more suspicious case. In every result below, mquire renders a virtual address as vaddr(page-table root, address): the page-table root it was resolved through, followed by the address itself.
The sample is a process that maps one anonymous page read/write/execute and then blocks:
// File name: wx_demo.c
// Build with `gcc -o wx_demo wx_demo.c`
#include <sys/mman.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) {
perror("mmap");
return 1;
}
// Ensure the memory is resident
((volatile char *)p)[0] = 0x90;
printf("wx region at %p (pid %d)\n", p, getpid());
fflush(stdout);
pause();
return 0;
}
SELECT pid, comm, region_start, region_end, writable, executable, shared
FROM process_anon_wx_regions
WHERE comm = 'wx_demo';
| pid | comm | region_start | region_end | writable | executable | shared |
|---|---|---|---|---|---|---|
| 2550 | wx_demo | vaddr(0x0000000101b88000, 0x00007a7ed0d57000) | vaddr(0x0000000101b88000, 0x00007a7ed0d58000) | 1 | 1 | 0 |
A quieter way to run code is to lean on the dynamic linker instead of mapping your own. mquire reconstructs each process’s environment and exposes it as a column on processes, and ld_env_override_processes flags the variables that hijack loading: LD_PRELOAD, LD_AUDIT, and LD_LIBRARY_PATH. It returns a boolean for each variable (has_ld_preload, has_ld_audit, has_ld_library_path) and the raw environment string, because those variables are also set for legitimate reasons and the string is what tells the two apart.
// File name: preload.c
// Build with `gcc -shared -fPIC -o preload.so preload.c`
__attribute__((constructor)) static void blog_init(void) { }
LD_PRELOAD=/opt/blog/preload.so sleep 9999 &
SELECT pid, comm, environment
FROM ld_env_override_processes
WHERE has_ld_preload = 1
ORDER BY pid;
pid=1653 comm=snapd-desktop-i
environment=..., LD_PRELOAD=:/snap/snapd-desktop-integration/361/gnome-platform/$LIB/bindtextdomain.so, ...
pid=2195 comm=sleep
environment=..., LD_PRELOAD=/opt/blog/preload.so, ...
pid=2684 comm=snapd-desktop-i
environment=..., LD_PRELOAD=:/snap/snapd-desktop-integration/361/gnome-platform/$LIB/bindtextdomain.so, ...
The environment column holds every variable and is trimmed here to the LD_PRELOAD assignment. The sleep process preloads /opt/blog/preload.so, while the two snapd-desktop-i processes preload snap’s own bindtextdomain.so.
Privilege and tracing
The next question is what a process is allowed to do. mquire dereferences each task’s cred and decodes the five Linux capability sets into named CAP_* bits in the task_capabilities table. The process_capabilities view collapses those into one row per process. The finding is a process holding a capability it has no reason to hold. CAP_SYS_PTRACE, the right to attach to and control any other process, on something that should never trace anything is a clear one.
// File name: capdemo.c
// Build with `gcc -o capdemo capdemo.c`
#include <unistd.h>
int main(void) { pause(); }
sudo setcap cap_sys_ptrace+ep capdemo
./capdemo &
SELECT pid, comm, effective, permitted
FROM process_capabilities
WHERE comm = 'capdemo';
| pid | comm | effective | permitted |
|---|---|---|---|
| 2248 | capdemo | SYS_PTRACE | SYS_PTRACE |
A related question is which processes are running under a tracer. mquire reads task_struct.ptrace and decodes it into named PT_* flags in the task_ptrace_flags table, and process_ptrace_flags reports them per process. A tracer on a process you did not start is either a debugger someone left running or an injection in progress.
sleep 9999 &
sudo gdb -p "$!" -ex continue
SELECT pid, comm, flags, raw
FROM process_ptrace_flags
WHERE flags != '';
| pid | comm | flags | raw |
|---|---|---|---|
| 2051 | sleep | PTRACED TRACESYSGOOD TRACE_FORK TRACE_VFORK TRACE_CLONE TRACE_EXEC TRACE_VFORK_DONE | 0x000001f9 |
Two small SQL functions
The kernel check in the next section is built on two small SQL functions I added, and they are worth knowing for your own queries. raw_vaddr converts mquire’s vaddr(...) rendering into a plain hex value you can compare and do arithmetic on:
SELECT symbol_name, virtual_address, raw_vaddr(virtual_address) AS raw
FROM kallsyms
WHERE symbol_name IN ('_stext', '_etext')
ORDER BY raw;
| symbol_name | virtual_address | raw |
|---|---|---|
| _stext | vaddr(0x0000000101b88000, 0xffff8ced46400000) | ffff8ced46400000 |
| _etext | vaddr(0x0000000101b88000, 0xffff8ced47b38e28) | ffff8ced47b38e28 |
expect returns its first argument, or aborts the whole query with a message when that argument is null:
SELECT expect(
(SELECT virtual_address FROM kallsyms WHERE symbol_name = '_stext'),
'kallsyms missing _stext'
) AS stext;
| stext |
|---|
| vaddr(0x0000000101b88000, 0xffff8ced46400000) |
The reason expect exists is what happens without it. When a symbol is missing, the subquery above does not fail: it returns null, every comparison against null is unknown, and a view built on it silently returns no rows. For the kernel check in the next section, that empty result reads as a clean system. With expect, the missing prerequisite aborts the query with your message instead:
SELECT expect(
(SELECT virtual_address FROM kallsyms WHERE symbol_name = '_nonexistent'),
'kallsyms missing _nonexistent (stripped?)'
) AS oops;
Error: Custom { kind: Other, error: "Failed to query the mquire database:
TablePlugin(\"kallsyms missing _nonexistent (stripped?)\")" }
Kernel hooks that belong to no module
A common rootkit technique is to register an ftrace callback and redirect execution from inside it, which leaves the syscall table and the module list looking untouched. mquire walks ftrace_ops_list into the ftrace_ops table, one row per registered op with its callback address and flags, and it walks each struct module’s memory regions into kernel_module_mem_entries, which gives the address range that legitimately belongs to every loaded module.
unbacked_ftrace_ops ties the two together. It flags any ftrace callback whose target lands neither in the kernel text range [_stext, _etext) (resolved from kallsyms) nor inside a known module’s text. A callback pointing outside all of that is running from memory no module in the list owns, which is what an ftrace hook from a hidden module looks like. The view uses raw_vaddr for the range checks and expect on _stext and _etext, so a dump where kallsyms did not resolve fails loudly instead of looking clean.
The detector is a single query. Against a machine infected with the Singularity rootkit it returns this:
SELECT ops_address, func_addr, flags
FROM unbacked_ftrace_ops;
| ops_address | func_addr | flags |
|---|---|---|
| vaddr(0x0000000101479000, 0xffffffffc0beb880) | ffffffffc0bdf510 | 6231 |
| vaddr(0x0000000101479000, 0xffffffffc0be2880) | ffffffffc0be03d0 | 6231 |
| vaddr(0x0000000101479000, 0xffffffffc0be2978) | ffffffffc0be03d0 | 6231 |
| … | … | … |
All 110 registered ftrace ops resolve to two callback addresses, and neither address falls inside any module in kernel_modules. Hiding the module from that list is exactly what leaves its hooks unbacked, so the evasion is what surfaces them.
What these views do not tell you
Each view is a lead, not a verdict. Writable and executable anonymous memory is also how a JIT runtime works. LD_PRELOAD is set by snap and flatpak sandboxes. A capability set can be entirely legitimate. Each view returns enough context to make the call by hand: the shared flag, the raw environment, the exact addresses and so on.
Everything here depends on kallsyms, which mquire recovers from kernel 6.4 and later. I wrote about keeping that working through Linux 7.x separately. The views live in the mquire repository and load once you run just install-views. The current release at the time of writing is 1.4.1.