Skip to content

Latest commit

 

History

28 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Multi-Arch EDR & Rootkit Hook Detector

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.

Quickstart

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

CLI options

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.

Exit code

  • 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.)

Detection mechanisms

Userspace inline patches

For each monitored library loaded by each scanned process, the scanner:

  1. 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_files handles and includes pthread/libdl functions exported by libc since glibc 2.34.
  2. Reads a fixed-size window from both the file (pread) and /proc/PID/mem at base_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.
  3. 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; ret and mov r64, imm64; jmp r64 on x86-64, and movz/movk…; br Xn on ARM64.

GOT / PLT hijacks

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:

  1. Parses the module's JUMP_SLOT / GLOB_DAT relocations (RELA on x86-64/ARM64, REL on i386) and keeps those whose symbol is a monitored function.
  2. Computes each GOT slot's runtime address (base + (r_offset − preferred_base)) and reads the live pointer from /proc/PID/mem.
  3. 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 .so files — 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.

eBPF kernel hooks

Runs system-wide, talks to bpf() directly. For each loaded BPF program:

  1. BPF_PROG_GET_NEXT_ID loops until ENOENT.
  2. BPF_PROG_GET_FD_BY_ID + BPF_OBJ_GET_INFO_BY_FD retrieves type, name, UID, attach_btf_id.
  3. Attach target is resolved via three independent paths:
    • Attach BTF — uses attach_btf_id and attach_btf_obj_id; discovers vmlinux by kernel metadata and resolves module split-BTF type/string offsets against that base.
    • BPF_TASK_FD_QUERY walking /proc/*/fd — catches BCC-style perf_event attachments where the program is reachable via an open file descriptor but has no bpf_link object.
    • 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).

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.

Non-BPF kprobes

/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.

uprobes

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).

ftrace hooks

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.

VDSO tampering

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.

Active LSMs

/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.

Tainted kernel modules

/proc/modules is parsed for the trailing parenthesised taint flags. The scanner classifies each module as some combination of:

  • O = out-of-tree
  • E = unsigned
  • F = force-loaded
  • P = 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).

Output

Text mode (with -v)

========================================================
  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
========================================================

About

A tool to detect EDR hooks on Linux - Userland and kernel level hooks

Topics

Resources

Stars

13 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages