Skip to main content

memf_linux/
ld_preload.rs

1//! LD_PRELOAD injection detection for Linux memory forensics.
2//!
3//! LD_PRELOAD is a Linux environment variable that forces shared libraries
4//! to be loaded before any others. Attackers abuse it for function hooking,
5//! credential stealing, and rootkit injection. This module detects
6//! LD_PRELOAD usage by reading each process's environment block from
7//! `mm_struct.env_start`..`env_end` and scanning for `LD_PRELOAD=`.
8//!
9//! Suspicious indicators include libraries in `/tmp`, `/dev/shm`, hidden
10//! paths (dotfiles), and other uncommon locations.
11
12use memf_core::object_reader::ObjectReader;
13use memf_format::PhysicalMemoryProvider;
14
15use crate::{ProcessInfo, Result};
16
17/// Maximum environment region size to read (64 KiB safety limit).
18const MAX_ENV_SIZE: u64 = 64 * 1024;
19
20/// Information about an LD_PRELOAD value found in a process's environment.
21#[derive(Debug, Clone, serde::Serialize)]
22pub struct LdPreloadInfo {
23    /// Process ID.
24    pub pid: u32,
25    /// Process command name.
26    pub process_name: String,
27    /// The raw LD_PRELOAD environment variable value.
28    pub ld_preload_value: String,
29    /// Individual library paths extracted from the LD_PRELOAD value.
30    pub preloaded_libraries: Vec<String>,
31    /// Whether the LD_PRELOAD value looks suspicious (tmp, devshm, hidden paths).
32    pub is_suspicious: bool,
33}
34
35/// Parse an LD_PRELOAD value into individual library paths.
36///
37/// LD_PRELOAD entries are separated by `:` or whitespace. Empty entries
38/// (from consecutive delimiters) are filtered out.
39fn parse_ld_preload(value: &str) -> Vec<String> {
40    value
41        .split(|c: char| c == ':' || c.is_ascii_whitespace())
42        .filter(|s| !s.is_empty())
43        .map(String::from)
44        .collect()
45}
46
47/// Classify an LD_PRELOAD value as suspicious or benign.
48///
49/// A value is suspicious if any library path:
50/// - Resides in `/tmp` or subdirectories
51/// - Resides in `/dev/shm` or subdirectories
52/// - Contains a hidden path component (directory or file starting with `.`)
53/// - Resides outside standard library directories (`/usr/lib`, `/lib`, etc.)
54pub use crate::heuristics::classify_ld_preload;
55
56/// Scan processes for LD_PRELOAD environment variable injection.
57///
58/// For each process in the provided list, reads the environment block from
59/// `mm_struct.env_start`..`env_end`, scans for a `LD_PRELOAD=` entry, and
60/// if found, parses the libraries and classifies the value.
61///
62/// Returns only processes that **have** LD_PRELOAD set in their environment.
63/// Kernel threads (NULL mm) and processes with unreadable environment blocks
64/// are silently skipped.
65pub fn scan_ld_preload<P: PhysicalMemoryProvider>(
66    reader: &ObjectReader<P>,
67    processes: &[ProcessInfo],
68) -> Result<Vec<LdPreloadInfo>> {
69    if processes.is_empty() {
70        return Ok(Vec::new());
71    }
72
73    let mut results = Vec::new();
74
75    for proc in processes {
76        if let Some(info) = scan_process_ld_preload(reader, proc) {
77            results.push(info);
78        }
79    }
80
81    Ok(results)
82}
83
84/// Scan a single process for LD_PRELOAD in its environment block.
85///
86/// Returns `None` if the process has no mm_struct, unreadable environment,
87/// or no LD_PRELOAD variable set.
88fn scan_process_ld_preload<P: PhysicalMemoryProvider>(
89    reader: &ObjectReader<P>,
90    proc: &ProcessInfo,
91) -> Option<LdPreloadInfo> {
92    // Read mm pointer from task_struct.
93    let mm_ptr: u64 = reader.read_field(proc.vaddr, "task_struct", "mm").ok()?;
94    if mm_ptr == 0 {
95        return None; // kernel thread
96    }
97
98    // Read env_start and env_end from mm_struct.
99    let env_start: u64 = reader.read_field(mm_ptr, "mm_struct", "env_start").ok()?;
100    let env_end: u64 = reader.read_field(mm_ptr, "mm_struct", "env_end").ok()?;
101
102    if env_start == 0 || env_end <= env_start {
103        return None;
104    }
105
106    let size = (env_end - env_start).min(MAX_ENV_SIZE);
107    let data = reader.read_bytes(env_start, size as usize).ok()?;
108
109    // Scan null-terminated strings for LD_PRELOAD=
110    let ld_preload_value = extract_ld_preload(&data)?;
111
112    let preloaded_libraries = parse_ld_preload(&ld_preload_value);
113    let is_suspicious = classify_ld_preload(&ld_preload_value);
114
115    Some(LdPreloadInfo {
116        pid: proc.pid as u32,
117        process_name: proc.comm.clone(),
118        ld_preload_value,
119        preloaded_libraries,
120        is_suspicious,
121    })
122}
123
124/// Extract the LD_PRELOAD value from a raw environment block.
125///
126/// The environment block contains null-separated `KEY=VALUE\0` strings.
127/// Returns `Some(value)` if an `LD_PRELOAD=...` entry is found.
128fn extract_ld_preload(data: &[u8]) -> Option<String> {
129    const PREFIX: &[u8] = b"LD_PRELOAD=";
130
131    for chunk in data.split(|&b| b == 0) {
132        if chunk.starts_with(PREFIX) {
133            let value = String::from_utf8_lossy(&chunk[PREFIX.len()..]);
134            let trimmed = value.trim();
135            if !trimmed.is_empty() {
136                return Some(trimmed.to_string());
137            }
138        }
139    }
140
141    None
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    /// Check whether a single library path looks suspicious (test helper).
149    fn is_suspicious_path(path: &str, safe_prefixes: &[&str]) -> bool {
150        if path.starts_with("/tmp/") || path == "/tmp" {
151            return true;
152        }
153        if path.starts_with("/dev/shm/") || path == "/dev/shm" {
154            return true;
155        }
156        if path
157            .split('/')
158            .any(|component| !component.is_empty() && component.starts_with('.'))
159        {
160            return true;
161        }
162        if !safe_prefixes.iter().any(|prefix| path.starts_with(prefix)) {
163            return true;
164        }
165        false
166    }
167
168    // ---------------------------------------------------------------
169    // parse_ld_preload tests
170    // ---------------------------------------------------------------
171
172    #[test]
173    fn parse_ld_preload_single() {
174        let result = parse_ld_preload("/usr/lib/libfoo.so");
175        assert_eq!(result, vec!["/usr/lib/libfoo.so"]);
176    }
177
178    #[test]
179    fn parse_ld_preload_multiple_colon() {
180        let result = parse_ld_preload("/lib/a.so:/lib/b.so");
181        assert_eq!(result, vec!["/lib/a.so", "/lib/b.so"]);
182    }
183
184    #[test]
185    fn parse_ld_preload_multiple_space() {
186        let result = parse_ld_preload("/lib/a.so /lib/b.so");
187        assert_eq!(result, vec!["/lib/a.so", "/lib/b.so"]);
188    }
189
190    #[test]
191    fn parse_ld_preload_mixed_delimiters() {
192        let result = parse_ld_preload("/lib/a.so:/lib/b.so /lib/c.so");
193        assert_eq!(result, vec!["/lib/a.so", "/lib/b.so", "/lib/c.so"]);
194    }
195
196    #[test]
197    fn parse_ld_preload_empty_string() {
198        let result = parse_ld_preload("");
199        assert!(result.is_empty());
200    }
201
202    // ---------------------------------------------------------------
203    // classify_ld_preload tests
204    // ---------------------------------------------------------------
205
206    #[test]
207    fn classify_benign_preload() {
208        // Address sanitizer in standard library path — not suspicious.
209        assert!(
210            !classify_ld_preload("/usr/lib/libasan.so"),
211            "standard library path should not be suspicious"
212        );
213    }
214
215    #[test]
216    fn classify_benign_lib64() {
217        assert!(
218            !classify_ld_preload("/usr/lib64/libjemalloc.so"),
219            "/usr/lib64 should not be suspicious"
220        );
221    }
222
223    #[test]
224    fn classify_suspicious_tmp() {
225        assert!(
226            classify_ld_preload("/tmp/.hidden/rootkit.so"),
227            "/tmp path should be suspicious"
228        );
229    }
230
231    #[test]
232    fn classify_suspicious_devshm() {
233        assert!(
234            classify_ld_preload("/dev/shm/inject.so"),
235            "/dev/shm path should be suspicious"
236        );
237    }
238
239    #[test]
240    fn classify_suspicious_hidden_path() {
241        assert!(
242            classify_ld_preload("/home/user/.config/.evil/hook.so"),
243            "hidden path component should be suspicious"
244        );
245    }
246
247    #[test]
248    fn classify_suspicious_uncommon_location() {
249        assert!(
250            classify_ld_preload("/var/run/payload.so"),
251            "uncommon location should be suspicious"
252        );
253    }
254
255    #[test]
256    fn classify_multiple_with_one_suspicious() {
257        // If any library in the value is suspicious, the whole value is suspicious.
258        assert!(
259            classify_ld_preload("/usr/lib/libasan.so:/tmp/evil.so"),
260            "one suspicious library should flag the whole value"
261        );
262    }
263
264    // ---------------------------------------------------------------
265    // scan_ld_preload tests
266    // ---------------------------------------------------------------
267
268    #[test]
269    fn scan_ld_preload_empty() {
270        // Empty process list should return empty Vec.
271        use memf_core::test_builders::PageTableBuilder;
272        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
273        use memf_symbols::isf::IsfResolver;
274        use memf_symbols::test_builders::IsfBuilder;
275
276        let json = IsfBuilder::new().build_json();
277        let resolver = IsfResolver::from_value(&json).unwrap();
278        let ptb = PageTableBuilder::new();
279        let (cr3, mem) = ptb.build();
280        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
281        let reader = ObjectReader::new(vas, Box::new(resolver));
282
283        let result = scan_ld_preload(&reader, &[]).unwrap();
284        assert!(
285            result.is_empty(),
286            "expected empty vec for empty process list"
287        );
288    }
289
290    // ---------------------------------------------------------------
291    // extract_ld_preload unit tests
292    // ---------------------------------------------------------------
293
294    #[test]
295    fn extract_ld_preload_finds_value() {
296        let env = b"PATH=/usr/bin\0LD_PRELOAD=/tmp/evil.so\0HOME=/root\0";
297        let result = extract_ld_preload(env);
298        assert_eq!(result.unwrap(), "/tmp/evil.so");
299    }
300
301    #[test]
302    fn extract_ld_preload_not_present_returns_none() {
303        let env = b"PATH=/usr/bin\0HOME=/root\0";
304        assert!(extract_ld_preload(env).is_none());
305    }
306
307    #[test]
308    fn extract_ld_preload_empty_value_returns_none() {
309        // LD_PRELOAD= with empty value (whitespace only) → None
310        let env = b"LD_PRELOAD=   \0OTHER=val\0";
311        assert!(
312            extract_ld_preload(env).is_none(),
313            "whitespace-only value must return None"
314        );
315    }
316
317    #[test]
318    fn extract_ld_preload_trims_whitespace() {
319        let env = b"LD_PRELOAD=  /usr/lib/lib.so  \0";
320        let result = extract_ld_preload(env);
321        assert_eq!(result.unwrap(), "/usr/lib/lib.so");
322    }
323
324    // ---------------------------------------------------------------
325    // is_suspicious_path boundary tests
326    // ---------------------------------------------------------------
327
328    #[test]
329    fn is_suspicious_path_tmp_exact_is_suspicious() {
330        const SAFE: &[&str] = &["/usr/lib/"];
331        assert!(
332            is_suspicious_path("/tmp", SAFE),
333            "/tmp itself must be suspicious"
334        );
335    }
336
337    #[test]
338    fn is_suspicious_path_devshm_exact_is_suspicious() {
339        const SAFE: &[&str] = &["/usr/lib/"];
340        assert!(
341            is_suspicious_path("/dev/shm", SAFE),
342            "/dev/shm itself must be suspicious"
343        );
344    }
345
346    #[test]
347    fn is_suspicious_path_hidden_dotfile_is_suspicious() {
348        const SAFE: &[&str] = &["/usr/lib/"];
349        assert!(
350            is_suspicious_path("/home/user/.hidden.so", SAFE),
351            "dotfile must be suspicious"
352        );
353    }
354
355    #[test]
356    fn is_suspicious_path_safe_prefix_not_suspicious() {
357        const SAFE: &[&str] = &["/usr/lib/"];
358        assert!(!is_suspicious_path("/usr/lib/libasan.so", SAFE));
359    }
360
361    #[test]
362    fn is_suspicious_path_non_safe_non_tmp_non_hidden_is_suspicious() {
363        const SAFE: &[&str] = &["/usr/lib/"];
364        // /var/run does not match any safe prefix and is not /tmp or /dev/shm
365        assert!(is_suspicious_path("/var/run/payload.so", SAFE));
366    }
367
368    // ---------------------------------------------------------------
369    // classify_ld_preload additional paths
370    // ---------------------------------------------------------------
371
372    #[test]
373    fn classify_lib_not_suspicious() {
374        assert!(!classify_ld_preload("/lib/libasan.so"));
375    }
376
377    #[test]
378    fn classify_lib64_not_suspicious() {
379        assert!(!classify_ld_preload("/lib64/libasan.so"));
380    }
381
382    #[test]
383    fn classify_lib32_not_suspicious() {
384        assert!(!classify_ld_preload("/lib32/libasan.so"));
385    }
386
387    #[test]
388    fn classify_usr_local_lib_not_suspicious() {
389        assert!(!classify_ld_preload("/usr/local/lib/libfoo.so"));
390    }
391
392    #[test]
393    fn classify_usr_local_lib64_not_suspicious() {
394        assert!(!classify_ld_preload("/usr/local/lib64/libfoo.so"));
395    }
396
397    #[test]
398    fn classify_usr_lib32_not_suspicious() {
399        assert!(!classify_ld_preload("/usr/lib32/libfoo.so"));
400    }
401
402    // ---------------------------------------------------------------
403    // scan_ld_preload with an unreadable task_struct → silently skipped
404    // ---------------------------------------------------------------
405
406    #[test]
407    fn scan_ld_preload_unreadable_task_skips_silently() {
408        use memf_core::test_builders::PageTableBuilder;
409        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
410        use memf_symbols::isf::IsfResolver;
411        use memf_symbols::test_builders::IsfBuilder;
412
413        let isf = IsfBuilder::new()
414            .add_struct("task_struct", 256)
415            .add_field("task_struct", "pid", 0, "int")
416            .add_field("task_struct", "mm", 8, "pointer")
417            .add_struct("mm_struct", 128)
418            .add_field("mm_struct", "env_start", 0, "unsigned long")
419            .add_field("mm_struct", "env_end", 8, "unsigned long")
420            .build_json();
421
422        let resolver = IsfResolver::from_value(&isf).unwrap();
423        let (cr3, mem) = PageTableBuilder::new().build();
424        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
425        let reader = ObjectReader::new(vas, Box::new(resolver));
426
427        // vaddr not mapped → read_field("mm") fails → scan_process_ld_preload returns None
428        let proc = ProcessInfo {
429            pid: 500,
430            ppid: 1,
431            comm: "bash".to_string(),
432            state: crate::types::ProcessState::Running,
433            vaddr: 0xDEAD_0000_0000_0000,
434            cr3: None,
435            start_time: 0,
436        };
437
438        let result = scan_ld_preload(&reader, &[proc]).unwrap();
439        assert!(
440            result.is_empty(),
441            "unreadable process must be silently skipped"
442        );
443    }
444
445    #[test]
446    fn ld_preload_info_serializes() {
447        let info = LdPreloadInfo {
448            pid: 42,
449            process_name: "bash".to_string(),
450            ld_preload_value: "/tmp/evil.so".to_string(),
451            preloaded_libraries: vec!["/tmp/evil.so".to_string()],
452            is_suspicious: true,
453        };
454        let json = serde_json::to_string(&info).unwrap();
455        assert!(json.contains("\"pid\":42"));
456        assert!(json.contains("\"is_suspicious\":true"));
457    }
458
459    // ---------------------------------------------------------------
460    // parse_ld_preload edge cases
461    // ---------------------------------------------------------------
462
463    #[test]
464    fn parse_ld_preload_consecutive_delimiters_filtered() {
465        // Consecutive delimiters produce empty entries which should be filtered
466        let result = parse_ld_preload("/lib/a.so::/lib/b.so");
467        assert_eq!(result, vec!["/lib/a.so", "/lib/b.so"]);
468    }
469
470    #[test]
471    fn parse_ld_preload_tab_delimiter() {
472        let result = parse_ld_preload("/lib/a.so\t/lib/b.so");
473        assert_eq!(result, vec!["/lib/a.so", "/lib/b.so"]);
474    }
475
476    // ---------------------------------------------------------------
477    // scan_ld_preload: process with mm=0 (kernel thread) → skipped
478    // ---------------------------------------------------------------
479
480    #[test]
481    fn scan_ld_preload_mm_null_skipped() {
482        use memf_core::test_builders::{flags as ptf, PageTableBuilder};
483        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
484        use memf_symbols::isf::IsfResolver;
485        use memf_symbols::test_builders::IsfBuilder;
486
487        let task_vaddr: u64 = 0xFFFF_8800_00D0_0000;
488        let task_paddr: u64 = 0x00D0_0000;
489
490        let isf = IsfBuilder::new()
491            .add_struct("task_struct", 0x200)
492            .add_field("task_struct", "pid", 0x00, "unsigned int")
493            .add_field("task_struct", "mm", 0x08, "pointer")
494            .add_struct("mm_struct", 0x100)
495            .add_field("mm_struct", "env_start", 0x00, "unsigned long")
496            .add_field("mm_struct", "env_end", 0x08, "unsigned long")
497            .build_json();
498        let resolver = IsfResolver::from_value(&isf).unwrap();
499
500        // task page: mm at 0x08 = 0 (kernel thread)
501        let mut task_page = [0u8; 4096];
502        task_page[0..4].copy_from_slice(&77u32.to_le_bytes()); // pid=77
503                                                               // mm stays 0
504
505        let (cr3, mem) = PageTableBuilder::new()
506            .map_4k(task_vaddr, task_paddr, ptf::WRITABLE)
507            .write_phys(task_paddr, &task_page)
508            .build();
509        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
510        let reader = ObjectReader::new(vas, Box::new(resolver));
511
512        let proc = ProcessInfo {
513            pid: 77,
514            ppid: 1,
515            comm: "kworker".to_string(),
516            state: crate::types::ProcessState::Running,
517            vaddr: task_vaddr,
518            cr3: None,
519            start_time: 0,
520        };
521
522        let result = scan_ld_preload(&reader, &[proc]).unwrap();
523        assert!(result.is_empty(), "kernel thread with mm=0 must be skipped");
524    }
525
526    // ---------------------------------------------------------------
527    // scan_ld_preload: env block readable, LD_PRELOAD present → LdPreloadInfo produced
528    // ---------------------------------------------------------------
529
530    #[test]
531    fn scan_ld_preload_env_block_with_ld_preload_produces_entry() {
532        use memf_core::test_builders::{flags as ptf, PageTableBuilder};
533        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
534        use memf_symbols::isf::IsfResolver;
535        use memf_symbols::test_builders::IsfBuilder;
536
537        // Layout:
538        //   task_vaddr: task_struct (mm at 0x08)
539        //   mm_vaddr:   mm_struct   (env_start at 0x00, env_end at 0x08)
540        //   env_vaddr:  env block containing "LD_PRELOAD=/tmp/evil.so\0"
541
542        let task_vaddr: u64 = 0xFFFF_8800_00D1_0000;
543        let task_paddr: u64 = 0x00D1_0000;
544        let mm_vaddr: u64 = 0xFFFF_8800_00D2_0000;
545        let mm_paddr: u64 = 0x00D2_0000;
546        let env_vaddr: u64 = 0xFFFF_8800_00D3_0000;
547        let env_paddr: u64 = 0x00D3_0000;
548
549        let env_data: &[u8] = b"PATH=/usr/bin\0LD_PRELOAD=/tmp/evil.so\0HOME=/root\0";
550        let env_end_vaddr = env_vaddr + env_data.len() as u64;
551
552        let isf = IsfBuilder::new()
553            .add_struct("task_struct", 0x200)
554            .add_field("task_struct", "pid", 0x00, "unsigned int")
555            .add_field("task_struct", "mm", 0x08, "pointer")
556            .add_struct("mm_struct", 0x100)
557            .add_field("mm_struct", "env_start", 0x00, "unsigned long")
558            .add_field("mm_struct", "env_end", 0x08, "unsigned long")
559            .build_json();
560        let resolver = IsfResolver::from_value(&isf).unwrap();
561
562        // task page: mm at 0x08 → mm_vaddr
563        let mut task_page = [0u8; 4096];
564        task_page[0..4].copy_from_slice(&123u32.to_le_bytes()); // pid=123
565        task_page[8..16].copy_from_slice(&mm_vaddr.to_le_bytes());
566
567        // mm page: env_start, env_end
568        let mut mm_page = [0u8; 4096];
569        mm_page[0..8].copy_from_slice(&env_vaddr.to_le_bytes());
570        mm_page[8..16].copy_from_slice(&env_end_vaddr.to_le_bytes());
571
572        // env page
573        let mut env_page = [0u8; 4096];
574        env_page[..env_data.len()].copy_from_slice(env_data);
575
576        let (cr3, mem) = PageTableBuilder::new()
577            .map_4k(task_vaddr, task_paddr, ptf::WRITABLE)
578            .write_phys(task_paddr, &task_page)
579            .map_4k(mm_vaddr, mm_paddr, ptf::WRITABLE)
580            .write_phys(mm_paddr, &mm_page)
581            .map_4k(env_vaddr, env_paddr, ptf::WRITABLE)
582            .write_phys(env_paddr, &env_page)
583            .build();
584        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
585        let reader = ObjectReader::new(vas, Box::new(resolver));
586
587        let proc = ProcessInfo {
588            pid: 123,
589            ppid: 1,
590            comm: "evil_proc".to_string(),
591            state: crate::types::ProcessState::Running,
592            vaddr: task_vaddr,
593            cr3: None,
594            start_time: 0,
595        };
596
597        let result = scan_ld_preload(&reader, &[proc]).unwrap();
598        assert_eq!(result.len(), 1, "one LD_PRELOAD entry should be produced");
599        assert_eq!(result[0].ld_preload_value, "/tmp/evil.so");
600        assert_eq!(result[0].preloaded_libraries, vec!["/tmp/evil.so"]);
601        assert!(result[0].is_suspicious, "/tmp/ path must be suspicious");
602        assert_eq!(result[0].pid, 123);
603    }
604
605    // ---------------------------------------------------------------
606    // scan_ld_preload: env_start == env_end → None (empty env)
607    // ---------------------------------------------------------------
608
609    #[test]
610    fn scan_ld_preload_empty_env_region_skipped() {
611        use memf_core::test_builders::{flags as ptf, PageTableBuilder};
612        use memf_core::vas::{TranslationMode, VirtualAddressSpace};
613        use memf_symbols::isf::IsfResolver;
614        use memf_symbols::test_builders::IsfBuilder;
615
616        let task_vaddr: u64 = 0xFFFF_8800_00D4_0000;
617        let task_paddr: u64 = 0x00D4_0000;
618        let mm_vaddr: u64 = 0xFFFF_8800_00D5_0000;
619        let mm_paddr: u64 = 0x00D5_0000;
620
621        let isf = IsfBuilder::new()
622            .add_struct("task_struct", 0x200)
623            .add_field("task_struct", "pid", 0x00, "unsigned int")
624            .add_field("task_struct", "mm", 0x08, "pointer")
625            .add_struct("mm_struct", 0x100)
626            .add_field("mm_struct", "env_start", 0x00, "unsigned long")
627            .add_field("mm_struct", "env_end", 0x08, "unsigned long")
628            .build_json();
629        let resolver = IsfResolver::from_value(&isf).unwrap();
630
631        let mut task_page = [0u8; 4096];
632        task_page[8..16].copy_from_slice(&mm_vaddr.to_le_bytes());
633
634        // mm: env_start = env_end = 0x1000 → size=0 → None
635        let mut mm_page = [0u8; 4096];
636        let same_addr: u64 = 0xFFFF_8800_00D6_0000;
637        mm_page[0..8].copy_from_slice(&same_addr.to_le_bytes()); // env_start
638        mm_page[8..16].copy_from_slice(&same_addr.to_le_bytes()); // env_end (equal → skip)
639
640        let (cr3, mem) = PageTableBuilder::new()
641            .map_4k(task_vaddr, task_paddr, ptf::WRITABLE)
642            .write_phys(task_paddr, &task_page)
643            .map_4k(mm_vaddr, mm_paddr, ptf::WRITABLE)
644            .write_phys(mm_paddr, &mm_page)
645            .build();
646        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
647        let reader = ObjectReader::new(vas, Box::new(resolver));
648
649        let proc = ProcessInfo {
650            pid: 88,
651            ppid: 1,
652            comm: "proc88".to_string(),
653            state: crate::types::ProcessState::Running,
654            vaddr: task_vaddr,
655            cr3: None,
656            start_time: 0,
657        };
658
659        let result = scan_ld_preload(&reader, &[proc]).unwrap();
660        assert!(
661            result.is_empty(),
662            "env_start == env_end → empty env region → no entry"
663        );
664    }
665}