A single Linux binary that audits a live system for the most common ways an EDR product (or a rootkit) can intercept activity:
| Layer | What's checked | Source of truth |
|---|---|---|
| Userspace inline patches | A curated set of hot functions in libc, libssl, libcrypto, libpthread, libdl, libpam, libaudit |
on-disk .so bytes vs. /proc/PID/mem |
| GOT / PLT hijacks | The same curated functions, but imported via the GOT of every loaded module — caught even when the function body is untouched | ELF JUMP_SLOT/GLOB_DAT relocations vs. live slot value in /proc/PID/mem |
| Env preload | Each selected process's initial LD_PRELOAD and target-root /etc/ld.so.preload |
/proc/PID/environ, /proc/PID/root/etc/ld.so.preload |
| eBPF programs | Every hook-capable BPF program (KPROBE / TRACING / LSM / TRACEPOINT / PERF_EVENT / SYSCALL) | bpf() syscall, BTF, BPF_TASK_FD_QUERY, BPF_LINK_GET_NEXT_ID |
| Non-BPF kprobes | Every kprobe/kretprobe, including those from SystemTap or custom LKMs | /sys/kernel/debug/kprobes/list |
| uprobes | Tracefs-registered uprobe events; other attachment methods explicitly remain incomplete | tracefs/uprobe_events |
| ftrace hooks | IPMODIFY / direct-call redirection signals, separate trampoline metadata, and global current_tracer state |
tracefs/enabled_functions, tracefs/current_tracer |
| VDSO tampering | Per-process [vdso] pages whose contents diverge from the same-architecture majority (in-place vdso patching has no on-disk baseline) |
cross-process compare of [vdso] bytes from /proc/PID/mem |
| Active LSMs | The list and order of loaded LSMs; unknown names get flagged | /sys/kernel/security/lsm |
| Tainted kernel modules | Loaded modules with O (out-of-tree), E (unsigned), or F (force-loaded) flags |
/proc/modules |
Supports ARM64 (AArch64), x86-64 and i386 target ELF images. The architecture
is detected per-library from each ELF's e_machine, so a single scanner binary
handles mixed environments. The CLI emits either human-readable text or a
single valid JSON document for machine consumption.
make
sudo ./edr_hooks_check # full system scan
./edr_hooks_check --self --scope userspace # own process; VDSO still uses system-wide peers
sudo ./edr_hooks_check --json # machine-readable output| Flag | Meaning |
|---|---|
-p, --pid <PID> |
Select a process for inline, GOT, and preload checks |
-l, --lib <PATH> |
Match a path/name substring for inline libraries and GOT importing modules; preload and VDSO checks retain their scope |
-s, --self |
Select the current PID for inline, GOT, and preload checks |
--scope <NAME> |
all (default), userspace, or kernel |
-v, --verbose |
Per-source listings (kprobe addresses, function names, …). Use twice for extra detail |
-x, --hexdump |
Show on-disk vs. in-memory instruction bytes for each detected userspace hook; enables verbose output |
-j, --json |
Emit the entire report as a single JSON object |
-h, --help |
Show usage and exit |
userspace runs preload, inline, GOT, and VDSO checks. VDSO comparison always
uses system-wide peers, including when a PID or library is selected. kernel
runs system-wide eBPF, kprobe, uprobe, ftrace, LSM, and module checks. PID/library
selectors are rejected with --scope kernel; --self and --pid cannot be
combined. An unprivileged all-process userspace request falls back to the
scanner's PID, which is recorded in the JSON scope.
-
0— no userspace hooks and no kernel-side hook signals detected -
1— at least one of: userspace patched function, GOT/PLT hijack, eBPF hook program, kprobe, uprobe, ftrace trampoline, or VDSO anomaly found(Unknown LSMs and tainted modules are reported but do not by themselves set exit
1— they are informational on most real systems.)
For each monitored library loaded by each scanned process, the scanner:
- Opens the mapped library through
/proc/PID/map_files, falling back to the target-root path only when its device/inode matches the maps record. The descriptor stays open through ELF parsing and baseline byte reads. Selects monitored symbol names independently of the file-access path. This works through/proc/PID/map_fileshandles and includes pthread/libdl functions exported by libc since glibc 2.34. - Reads a fixed-size window from both the file (
pread) and/proc/PID/mematbase_addr + (vaddr − preferred_base).- ARM64: 8 fixed-width instructions (32 bytes).
- x86-64 / i386: 64 bytes (≈10–15 variable-length instructions), decoded by a built-in length decoder.
- If the bytes differ, runs arch-specific scoring heuristics that filter known benign patterns — PLT stubs, syscall trampolines, tail calls, function epilogues, IFUNC dispatch, thin wrappers ≤ 32 bytes — and classifies the remainder as LOW / MEDIUM / HIGH confidence. The scoring also recognises full-range trampolines that begin with no relative branch:
push imm; retandmov r64, imm64; jmp r64on x86-64, andmovz/movk…; br Xnon ARM64.
Inline patching is not the only way to intercept a call: overwriting a Global Offset Table entry reroutes every call site through the PLT without touching a single byte of the target function, so the inline check above can't see it. For each loaded module instance (the main executable and every .so), restricted to the importing module path when --lib is set, the scanner:
- Parses the module's
JUMP_SLOT/GLOB_DATrelocations (RELA on x86-64/ARM64, REL on i386) and keeps those whose symbol is a monitored function. - Computes each GOT slot's runtime address (
base + (r_offset − preferred_base)) and reads the live pointer from/proc/PID/mem. - Flags the entry when that pointer does not land in a legitimate executable mapping. Lazy-bound slots point into the module's own PLT and real interposers (including
LD_PRELOAD) resolve into on-disk.sofiles — neither trips the check. The signal is specifically a pointer into anonymous / injected memory (or an unmapped address), which is the signature of a ptrace-injected trampoline.
Runs system-wide, talks to bpf() directly. For each loaded BPF program:
BPF_PROG_GET_NEXT_IDloops untilENOENT.BPF_PROG_GET_FD_BY_ID+BPF_OBJ_GET_INFO_BY_FDretrieves type, name, UID,attach_btf_id.- Attach target is resolved via three independent paths:
- Attach BTF — uses
attach_btf_idandattach_btf_obj_id; discovers vmlinux by kernel metadata and resolves module split-BTF type/string offsets against that base. BPF_TASK_FD_QUERYwalking/proc/*/fd— catches BCC-style perf_event attachments where the program is reachable via an open file descriptor but has nobpf_linkobject.BPF_LINK_GET_NEXT_ID+BPF_OBJ_GET_INFO_BY_FD— catches pinned links (incl. raw_tracepoint / perf_event variants with their concrete attach point).
- Attach BTF — uses
Only hook-capable types are reported: KPROBE, TRACEPOINT, RAW_TRACEPOINT, RAW_TP_WRITABLE, PERF_EVENT, TRACING, LSM, SYSCALL. Pure-networking types (XDP, SOCKET_FILTER, SCHED_CLS, …) are intentionally ignored.
/sys/kernel/debug/kprobes/list enumerates every active kprobe/kretprobe in the kernel — including those registered by non-BPF tools like SystemTap or by custom kernel modules. Each line is parsed into {address, type (k/r/p), symbol+offset, active, optimized, ftrace_based}. Requires root + debugfs mounted.
tracefs/uprobe_events lists every uprobe regardless of how it was attached (BPF, perf, manual write). Tried under both /sys/kernel/tracing/ (modern) and /sys/kernel/debug/tracing/ (legacy debugfs mount).
tracefs/enabled_functions lists every kernel function currently hooked via ftrace. Lines containing tramp: indicate a real code redirection (rootkit ftrace abuse is a popular LKM hooking technique because it bypasses direct function patching); lines without are passive tracers. Only trampoline-bearing entries count toward the hook total.
tracefs/current_tracer is read separately — if it's anything other than nop, the scanner emits a warning entry because kernel-wide function tracing being on is unusual on a production system.
The kernel maps the same position-independent [vdso] image (gettimeofday, clock_gettime, getcpu) into every process, so its bytes are identical across all processes of a given architecture. A rootkit that patches one process's vdso breaks copy-on-write and leaves that process with a private, modified page that has no on-disk baseline to diff against. The scanner reads every readable [vdso] via /proc/PID/mem, groups them by (length, FNV-1a hash), and flags any variant that is a strict minority among vdsos of the same length (same length ⇒ same architecture, so differing content is the tampering signal; a different length is just a 32- vs 64-bit process and is expected).
Limitation: a global patch applied before COW would alter every process identically, leaving no minority to flag — detecting that needs a trusted baseline and is out of scope. CRIU-restored processes can legitimately carry a divergent vdso proxy, so treat a hit as "investigate", not proof.
/sys/kernel/security/lsm is a comma-separated list of activated LSMs (in load order). Known good names are accepted silently; anything else is flagged. The full list is always emitted in JSON mode so machine consumers can verify ordering.
/proc/modules is parsed for the trailing parenthesised taint flags. The scanner classifies each module as some combination of:
O= out-of-treeE= unsignedF= force-loadedP= proprietary
Out-of-tree + unsigned + forced modules are flagged as worth investigating (a malicious LKM is almost always all three). Proprietary alone is informational (covers legit vendor drivers).
========================================================
Multi-Arch EDR Hook Detector (ARM64 + x86 / x86-64)
========================================================
[+] No /etc/ld.so.preload
[*] Scanning eBPF kernel hooks...
security_file_open [TRACING] prog=falco_filopen
do_unlinkat [KPROBE] prog=kp_unlink
2 kernel hook(s) found (out of 38 eBPF program(s) seen)
[*] Scanning kprobes...
ffffffff8108a2c0 k __x64_sys_open+0x0 [FTRACE]
ffffffff810b5450 r __x64_sys_kill+0x0
2 kprobe(s) registered (2 active)
[*] Scanning uprobes...
[+] No uprobes registered
[*] Scanning ftrace hooks...
14 ftrace hook(s), 0 with custom trampoline — run with -v for the list
[*] Active LSMs...
capability
yama
apparmor
bpf
[*] Scanning kernel modules...
nvidia taint=[OE] out-of-tree UNSIGNED
vboxdrv taint=[OE] out-of-tree UNSIGNED
187 module(s) loaded, 2 flagged (out-of-tree / unsigned / forced)
[*] Checking VDSO consistency...
[+] VDSO consistent across 42 process(es) (1 variant)
[*] Scanning GOT/PLT for hijacks...
[+] No GOT/PLT hijacks detected
Scanning processes...
[!] PID 1234 (sshd): 2 hook(s)
========================================================
SUMMARY
========================================================
Processes scanned: 42
Processes w/ userland hooks: 1
Userspace hooks: 2
GOT/PLT hijacks: 0
eBPF kernel hooks: 2
Active kprobes: 2
uprobes: 0
ftrace redirection signals: 0
Unknown LSMs: 0
Out-of-tree/unsigned mods: 2
VDSO anomalies: 0
--------------------------------------------------------
Total signals: 8
[!] Hook signals detected; investigate the findings above.
[?] Coverage incomplete; additional findings cannot be ruled out. Sources: uprobes
========================================================