Skip to main content

memf_linux/
check_hooks.rs

1//! Linux kernel inline hook detector.
2//!
3//! Checks the first bytes of key kernel functions for JMP/CALL
4//! trampolines that indicate inline hooking. Reads the function
5//! prologue and checks for x86_64 patterns like:
6//!   - `0xE9` (relative JMP)
7//!   - `0xFF 0x25` (absolute indirect JMP)
8//!   - `0x48 0xB8 ... 0xFF 0xE0` (MOV RAX, imm64; JMP RAX)
9
10use memf_core::object_reader::ObjectReader;
11use memf_format::PhysicalMemoryProvider;
12
13use crate::{Error, KernelHookInfo, Result};
14
15/// Number of prologue bytes to read from each function.
16const PROLOGUE_SIZE: usize = 16;
17
18/// Well-known kernel functions to check for inline hooks.
19const FUNCTIONS_TO_CHECK: &[&str] = &[
20    "sys_read",
21    "sys_write",
22    "sys_open",
23    "sys_close",
24    "vfs_read",
25    "vfs_write",
26    "tcp4_seq_show",
27    "filldir",
28    "filldir64",
29];
30
31/// Check key kernel functions for inline hooks.
32///
33/// Reads the first [`PROLOGUE_SIZE`] bytes of each function in
34/// [`FUNCTIONS_TO_CHECK`] and looks for JMP/CALL trampoline patterns.
35pub fn check_inline_hooks<P: PhysicalMemoryProvider>(
36    reader: &ObjectReader<P>,
37) -> Result<Vec<KernelHookInfo>> {
38    let stext =
39        reader
40            .symbols()
41            .symbol_address("_stext")
42            .ok_or_else(|| Error::MissingKernelSymbol {
43                name: "_stext".into(),
44            })?;
45    let etext =
46        reader
47            .symbols()
48            .symbol_address("_etext")
49            .ok_or_else(|| Error::MissingKernelSymbol {
50                name: "_etext".into(),
51            })?;
52
53    let mut results = Vec::new();
54
55    for &func_name in FUNCTIONS_TO_CHECK {
56        let Some(func_addr) = reader.symbols().symbol_address(func_name) else {
57            continue; // Symbol not present, skip
58        };
59
60        let Ok(prologue) = reader.read_bytes(func_addr, PROLOGUE_SIZE) else {
61            continue;
62        };
63
64        let (hook_type, target) = analyze_prologue(&prologue, func_addr);
65        // Suspicious only when a hook IS present AND the target is outside kernel text.
66        // A jmp into a legitimate kernel function is not suspicious.
67        let suspicious = hook_type != "none" && target.map_or(true, |t| t < stext || t > etext);
68
69        results.push(KernelHookInfo {
70            symbol: func_name.to_string(),
71            address: func_addr,
72            hook_type,
73            target,
74            suspicious,
75        });
76    }
77
78    Ok(results)
79}
80
81/// Analyze a function prologue for hook patterns.
82///
83/// Returns `(hook_type, target)` if a hook is detected, or `("none", None)`.
84#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
85fn analyze_prologue(bytes: &[u8], func_addr: u64) -> (String, Option<u64>) {
86    if bytes.len() < PROLOGUE_SIZE {
87        return ("none".to_string(), None);
88    }
89
90    // Pattern 1: E9 xx xx xx xx — relative JMP (5 bytes)
91    if bytes[0] == 0xE9 {
92        let offset = bytes[1..5].try_into().map_or(0, i32::from_le_bytes);
93        let target = (func_addr as i64 + 5 + i64::from(offset)) as u64;
94        return ("jmp_rel32".to_string(), Some(target));
95    }
96
97    // Pattern 2: FF 25 xx xx xx xx — absolute indirect JMP [rip+disp32]
98    if bytes[0] == 0xFF && bytes[1] == 0x25 {
99        let offset = bytes[2..6].try_into().map_or(0, i32::from_le_bytes);
100        let target = (func_addr as i64 + 6 + i64::from(offset)) as u64;
101        return ("jmp_indirect".to_string(), Some(target));
102    }
103
104    // Pattern 3: 48 B8 <imm64> FF E0 — MOV RAX, imm64; JMP RAX (12 bytes)
105    if bytes.len() >= 12
106        && bytes[0] == 0x48
107        && bytes[1] == 0xB8
108        && bytes[10] == 0xFF
109        && bytes[11] == 0xE0
110    {
111        let target = bytes[2..10].try_into().map_or(0, u64::from_le_bytes);
112        return ("mov_rax_jmp".to_string(), Some(target));
113    }
114
115    ("none".to_string(), None)
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use memf_core::test_builders::{flags as ptflags, PageTableBuilder, SyntheticPhysMem};
122    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
123    use memf_symbols::isf::IsfResolver;
124    use memf_symbols::test_builders::IsfBuilder;
125
126    fn make_test_reader(
127        data: &[u8],
128        func_vaddr: u64,
129        func_paddr: u64,
130        stext: u64,
131        etext: u64,
132        func_symbols: &[(&str, u64)],
133    ) -> ObjectReader<SyntheticPhysMem> {
134        let mut builder = IsfBuilder::new()
135            .add_struct("task_struct", 64)
136            .add_field("task_struct", "pid", 0, "int")
137            .add_symbol("_stext", stext)
138            .add_symbol("_etext", etext);
139
140        for &(name, addr) in func_symbols {
141            builder = builder.add_symbol(name, addr);
142        }
143
144        let isf = builder.build_json();
145        let resolver = IsfResolver::from_value(&isf).unwrap();
146        let (cr3, mem) = PageTableBuilder::new()
147            .map_4k(func_vaddr, func_paddr, ptflags::WRITABLE)
148            .write_phys(func_paddr, data)
149            .build();
150        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
151        ObjectReader::new(vas, Box::new(resolver))
152    }
153
154    #[test]
155    fn clean_function_no_hook() {
156        // Normal function prologue: push rbp; mov rbp, rsp; sub rsp, 0x20
157        let mut prologue = vec![0u8; 4096];
158        prologue[0] = 0x55; // push rbp
159        prologue[1] = 0x48; // REX.W
160        prologue[2] = 0x89; // mov
161        prologue[3] = 0xE5; // rbp, rsp
162
163        let func_vaddr: u64 = 0xFFFF_8000_0001_0000;
164        let func_paddr: u64 = 0x0080_0000;
165        let stext: u64 = 0xFFFF_8000_0000_0000;
166        let etext: u64 = 0xFFFF_8000_00FF_FFFF;
167
168        let reader = make_test_reader(
169            &prologue,
170            func_vaddr,
171            func_paddr,
172            stext,
173            etext,
174            &[("sys_read", func_vaddr)],
175        );
176        let results = check_inline_hooks(&reader).unwrap();
177
178        assert_eq!(results.len(), 1);
179        assert!(!results[0].suspicious);
180        assert_eq!(results[0].symbol, "sys_read");
181        assert_eq!(results[0].hook_type, "none");
182    }
183
184    #[test]
185    fn detects_relative_jmp_hook() {
186        // Hooked: E9 xx xx xx xx (relative JMP)
187        // Target lands inside kernel text → hook detected but NOT suspicious
188        // (jmp to a legitimate kernel function should not be flagged).
189        let mut prologue = vec![0u8; 4096];
190        prologue[0] = 0xE9; // JMP rel32
191        prologue[1..5].copy_from_slice(&0x1000i32.to_le_bytes());
192
193        let func_vaddr: u64 = 0xFFFF_8000_0001_0000;
194        let func_paddr: u64 = 0x0080_0000;
195        let stext: u64 = 0xFFFF_8000_0000_0000;
196        let etext: u64 = 0xFFFF_8000_00FF_FFFF;
197
198        let reader = make_test_reader(
199            &prologue,
200            func_vaddr,
201            func_paddr,
202            stext,
203            etext,
204            &[("sys_read", func_vaddr)],
205        );
206        let results = check_inline_hooks(&reader).unwrap();
207
208        // target = func_vaddr + 5 + 0x1000 = 0xFFFF_8000_0002_1005, inside [stext, etext)
209        assert_eq!(results.len(), 1);
210        assert_eq!(results[0].hook_type, "jmp_rel32");
211        assert!(results[0].target.is_some());
212        // Hook detected but target inside kernel text → not suspicious
213        assert!(
214            !results[0].suspicious,
215            "jmp into kernel text should not be suspicious"
216        );
217    }
218
219    #[test]
220    fn detects_movabs_jmp_rax_hook() {
221        // Hooked: 48 B8 <8 bytes> FF E0 (MOV RAX, imm64; JMP RAX)
222        let mut prologue = vec![0u8; 4096];
223        prologue[0] = 0x48; // REX.W
224        prologue[1] = 0xB8; // MOV RAX, imm64
225        let target: u64 = 0xFFFF_C900_DEAD_BEEF;
226        prologue[2..10].copy_from_slice(&target.to_le_bytes());
227        prologue[10] = 0xFF; // JMP
228        prologue[11] = 0xE0; // RAX
229
230        let func_vaddr: u64 = 0xFFFF_8000_0001_0000;
231        let func_paddr: u64 = 0x0080_0000;
232        let stext: u64 = 0xFFFF_8000_0000_0000;
233        let etext: u64 = 0xFFFF_8000_00FF_FFFF;
234
235        let reader = make_test_reader(
236            &prologue,
237            func_vaddr,
238            func_paddr,
239            stext,
240            etext,
241            &[("sys_read", func_vaddr)],
242        );
243        let results = check_inline_hooks(&reader).unwrap();
244
245        assert_eq!(results.len(), 1);
246        assert!(results[0].suspicious);
247        assert_eq!(results[0].hook_type, "mov_rax_jmp");
248        assert_eq!(results[0].target, Some(target));
249    }
250
251    #[test]
252    fn analyze_prologue_normal() {
253        let bytes = [
254            0x55, 0x48, 0x89, 0xE5, 0x48, 0x83, 0xEC, 0x20, 0, 0, 0, 0, 0, 0, 0, 0,
255        ];
256        let (hook_type, target) = analyze_prologue(&bytes, 0xFFFF_8000_0001_0000);
257        assert_eq!(hook_type, "none");
258        assert_eq!(target, None);
259    }
260
261    #[test]
262    fn detects_indirect_jmp_hook() {
263        // Covers lines 91-93: FF 25 xx xx xx xx (absolute indirect JMP [rip+disp32])
264        // Target = func_addr + 6 (inside kernel text) → hook detected, not suspicious.
265        let mut prologue = vec![0u8; 4096];
266        prologue[0] = 0xFF;
267        prologue[1] = 0x25;
268        // offset = 0 → target = func_addr + 6 + 0 = func_addr + 6
269        prologue[2..6].copy_from_slice(&0i32.to_le_bytes());
270
271        let func_vaddr: u64 = 0xFFFF_8000_0002_0000;
272        let func_paddr: u64 = 0x0081_0000;
273        let stext: u64 = 0xFFFF_8000_0000_0000;
274        let etext: u64 = 0xFFFF_8000_00FF_FFFF;
275
276        let reader = make_test_reader(
277            &prologue,
278            func_vaddr,
279            func_paddr,
280            stext,
281            etext,
282            &[("sys_write", func_vaddr)],
283        );
284        let results = check_inline_hooks(&reader).unwrap();
285
286        assert_eq!(results.len(), 1);
287        assert_eq!(results[0].hook_type, "jmp_indirect");
288        // target = func_addr + 6 + 0 (offset) = 0xFFFF_8000_0002_0006 (inside kernel text)
289        assert_eq!(results[0].target, Some(func_vaddr + 6));
290        // Hook detected but target is inside kernel text → not suspicious
291        assert!(
292            !results[0].suspicious,
293            "jmp_indirect targeting kernel text must not be suspicious"
294        );
295    }
296
297    #[test]
298    fn skips_symbol_with_unreadable_prologue() {
299        // Covers line 55: symbol present but read_bytes fails → skip
300        // We add a function symbol that points to an unmapped address.
301        let isf = IsfBuilder::new()
302            .add_struct("task_struct", 64)
303            .add_field("task_struct", "pid", 0, "int")
304            .add_symbol("_stext", 0xFFFF_8000_0000_0000)
305            .add_symbol("_etext", 0xFFFF_8000_00FF_FFFF)
306            // sys_read points to unmapped memory → read_bytes fails
307            .add_symbol("sys_read", 0xFFFF_DEAD_0000_0000)
308            .build_json();
309
310        let resolver = IsfResolver::from_value(&isf).unwrap();
311        let (cr3, mem) = PageTableBuilder::new().build();
312        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
313        let reader = ObjectReader::new(vas, Box::new(resolver));
314
315        let results = check_inline_hooks(&reader).unwrap();
316        // Symbol is present but page is not mapped → read_bytes fails → entry skipped
317        assert!(results.is_empty(), "unreadable prologue should be skipped");
318    }
319
320    #[test]
321    fn detects_rel_jmp_hook_outside_text_region() {
322        // Cover: hook_type == "none" but target outside text range = suspicious
323        // Use a JMP that lands outside [stext, etext]
324        let mut prologue = vec![0u8; 4096];
325        prologue[0] = 0xE9;
326        // A large positive offset that lands outside etext
327        prologue[1..5].copy_from_slice(&0x0FFF_0000i32.to_le_bytes());
328
329        let func_vaddr: u64 = 0xFFFF_8000_0003_0000;
330        let func_paddr: u64 = 0x0082_0000;
331        let stext: u64 = 0xFFFF_8000_0000_0000;
332        let etext: u64 = 0xFFFF_8000_0005_0000; // small range
333
334        let reader = make_test_reader(
335            &prologue,
336            func_vaddr,
337            func_paddr,
338            stext,
339            etext,
340            &[("vfs_read", func_vaddr)],
341        );
342        let results = check_inline_hooks(&reader).unwrap();
343        assert_eq!(results.len(), 1);
344        assert_eq!(results[0].hook_type, "jmp_rel32");
345        assert!(
346            results[0].suspicious,
347            "JMP to outside text region must be suspicious"
348        );
349    }
350
351    #[test]
352    fn analyze_prologue_short_bytes_returns_none() {
353        // Covers line 79: bytes.len() < PROLOGUE_SIZE → ("none", None)
354        let short = [0x55u8; 4]; // only 4 bytes, need 16
355        let (hook_type, target) = analyze_prologue(&short, 0xFFFF_8000_0001_0000);
356        assert_eq!(hook_type, "none");
357        assert_eq!(target, None);
358    }
359
360    #[test]
361    fn skips_missing_symbols() {
362        // If sys_read symbol is missing, just skip it (no error)
363        let isf = IsfBuilder::new()
364            .add_struct("task_struct", 64)
365            .add_field("task_struct", "pid", 0, "int")
366            .add_symbol("_stext", 0xFFFF_8000_0000_0000)
367            .add_symbol("_etext", 0xFFFF_8000_00FF_FFFF)
368            // No function symbols
369            .build_json();
370
371        let resolver = IsfResolver::from_value(&isf).unwrap();
372        let (cr3, mem) = PageTableBuilder::new().build();
373        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
374        let reader = ObjectReader::new(vas, Box::new(resolver));
375
376        let results = check_inline_hooks(&reader).unwrap();
377        assert!(results.is_empty());
378    }
379
380    #[test]
381    fn missing_stext_returns_missing_kernel_symbol() {
382        let isf = IsfBuilder::new().build_json();
383        let resolver = IsfResolver::from_value(&isf).unwrap();
384        let (cr3, mem) = PageTableBuilder::new().build();
385        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
386        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
387        let result = check_inline_hooks(&reader);
388        assert!(
389            matches!(result, Err(crate::Error::MissingKernelSymbol { ref name }) if name == "_stext"),
390            "expected MissingKernelSymbol {{name: \"_stext\"}}, got {result:?}"
391        );
392    }
393
394    #[test]
395    fn missing_etext_returns_missing_kernel_symbol() {
396        let isf = IsfBuilder::new()
397            .add_symbol("_stext", 0xFFFF_8000_0000_0000)
398            .build_json();
399        let resolver = IsfResolver::from_value(&isf).unwrap();
400        let (cr3, mem) = PageTableBuilder::new().build();
401        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
402        let reader: ObjectReader<SyntheticPhysMem> = ObjectReader::new(vas, Box::new(resolver));
403        let result = check_inline_hooks(&reader);
404        assert!(
405            matches!(result, Err(crate::Error::MissingKernelSymbol { ref name }) if name == "_etext"),
406            "expected MissingKernelSymbol {{name: \"_etext\"}}, got {result:?}"
407        );
408    }
409}