Skip to main content

memf_linux/
futex_forensics.rs

1//! Futex forensics for Linux memory forensics.
2//!
3//! Walks the kernel `futex_queues` hash table to enumerate all pending
4//! futex wait entries. Cross-process futexes from unexpected address ranges
5//! and abnormally high waiter counts are flagged as suspicious.
6
7use memf_core::object_reader::ObjectReader;
8use memf_format::PhysicalMemoryProvider;
9
10use crate::Result;
11
12/// Information about a futex entry found in the kernel futex hash table.
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct FutexInfo {
15    /// Virtual address of the futex key.
16    pub key_address: u64,
17    /// PID of the process owning the futex.
18    pub owner_pid: u32,
19    /// Number of waiters on this futex.
20    pub waiter_count: u32,
21    /// Futex type: "private" or "shared".
22    pub futex_type: String,
23    /// True when this futex matches attack patterns (confusion attack or DoS).
24    pub is_suspicious: bool,
25}
26
27/// Classify whether a futex entry is suspicious.
28///
29/// Suspicious when:
30/// - `waiter_count > 1000` (potential DoS via futex starvation), or
31/// - `key_address > 0x7FFF_FFFF_FFFF && owner_pid > 0` (kernel-space key
32///   from userspace owner — futex confusion / privilege escalation indicator).
33pub use crate::heuristics::classify_futex;
34
35/// Walk the kernel futex hash table and return all pending futex entries.
36///
37/// Returns `Ok(Vec::new())` when the `futex_queues` symbol or required ISF
38/// offsets are absent (graceful degradation).
39pub fn walk_futex_table<P: PhysicalMemoryProvider>(
40    reader: &ObjectReader<P>,
41) -> Result<Vec<FutexInfo>> {
42    // Graceful degradation: require futex_queues symbol.
43    let fq_addr = match reader.symbols().symbol_address("futex_queues") {
44        Some(addr) => addr,
45        None => return Ok(Vec::new()),
46    };
47
48    // Require futex_hash_bucket struct offsets.
49    let chain_offset = match reader.symbols().field_offset("futex_hash_bucket", "chain") {
50        Some(off) => off,
51        None => return Ok(Vec::new()),
52    };
53
54    // futex_hash_bucket size from ISF struct_size; default to 64 bytes.
55    let bucket_size: u64 = reader
56        .symbols()
57        .struct_size("futex_hash_bucket")
58        .unwrap_or(64);
59
60    // Default to 256 buckets (a common runtime value).
61    let bucket_count: u64 = 256;
62
63    let mut results = Vec::new();
64
65    for i in 0..bucket_count.min(4096) {
66        let bucket_addr = fq_addr + i * bucket_size;
67        let chain_head = bucket_addr + chain_offset;
68
69        // Read hlist_head.first pointer.
70        let first_q: u64 = match reader.read_bytes(chain_head, 8) {
71            Ok(b) => u64::from_le_bytes(b.try_into().unwrap_or([0u8; 8])),
72            Err(_) => continue,
73        };
74
75        let mut q_ptr = first_q;
76        let mut waiter_count: u32 = 0;
77        let mut guard = 0usize;
78
79        let mut first_key: u64 = 0;
80        let mut first_pid: u32 = 0;
81        let mut first_type = "private".to_string();
82
83        while q_ptr != 0 && guard < 65536 {
84            let key_offset: u64 = reader
85                .symbols()
86                .field_offset("futex_q", "key")
87                .map_or(16, |o| o);
88
89            let task_offset: u64 = reader
90                .symbols()
91                .field_offset("futex_q", "task")
92                .map_or(8, |o| o);
93
94            if waiter_count == 0 {
95                // Read the futex key (first 8 bytes of union futex_key).
96                first_key = reader
97                    .read_bytes(q_ptr + key_offset, 8)
98                    .ok()
99                    .and_then(|b| b.try_into().ok())
100                    .map_or(0, u64::from_le_bytes);
101
102                // Determine shared vs private from key.both.offset bit 1.
103                let key_offset_field: u64 = reader
104                    .read_bytes(q_ptr + key_offset + 8, 8)
105                    .ok()
106                    .and_then(|b| b.try_into().ok())
107                    .map_or(0, u64::from_le_bytes);
108                first_type = if key_offset_field & 1 == 0 {
109                    "private".to_string()
110                } else {
111                    "shared".to_string()
112                };
113
114                // task → task_struct → pid
115                let task_ptr: u64 = reader
116                    .read_bytes(q_ptr + task_offset, 8)
117                    .ok()
118                    .and_then(|b| b.try_into().ok())
119                    .map_or(0, u64::from_le_bytes);
120                if task_ptr != 0 {
121                    first_pid = reader
122                        .read_field::<u32>(task_ptr, "task_struct", "pid")
123                        .unwrap_or(0);
124                }
125            }
126
127            waiter_count += 1;
128
129            // hlist_node.next is at offset 0.
130            q_ptr = reader
131                .read_bytes(q_ptr, 8)
132                .ok()
133                .and_then(|b| b.try_into().ok())
134                .map_or(0, u64::from_le_bytes);
135            guard += 1;
136        }
137
138        if waiter_count > 0 {
139            let is_suspicious = classify_futex(first_key, first_pid, waiter_count);
140            results.push(FutexInfo {
141                key_address: first_key,
142                owner_pid: first_pid,
143                waiter_count,
144                futex_type: first_type,
145                is_suspicious,
146            });
147        }
148    }
149
150    Ok(results)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use memf_core::object_reader::ObjectReader;
157    use memf_core::test_builders::{PageTableBuilder, SyntheticPhysMem};
158    use memf_core::vas::{TranslationMode, VirtualAddressSpace};
159    use memf_symbols::isf::IsfResolver;
160    use memf_symbols::test_builders::IsfBuilder;
161
162    fn make_no_symbol_reader() -> ObjectReader<SyntheticPhysMem> {
163        let isf = IsfBuilder::new().build_json();
164        let resolver = IsfResolver::from_value(&isf).unwrap();
165        let (cr3, mem) = PageTableBuilder::new().build();
166        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
167        ObjectReader::new(vas, Box::new(resolver))
168    }
169
170    #[test]
171    fn classify_high_waiter_count_suspicious() {
172        assert!(
173            classify_futex(0x7FFF_0000_0000, 500, 1001),
174            "high waiter count must be suspicious"
175        );
176    }
177
178    #[test]
179    fn classify_exactly_1000_waiters_not_suspicious() {
180        assert!(
181            !classify_futex(0x7FFF_0000_0000, 500, 1000),
182            "exactly 1000 waiters must not be suspicious"
183        );
184    }
185
186    #[test]
187    fn classify_kernel_space_key_from_userspace_owner_suspicious() {
188        assert!(
189            classify_futex(0x8000_0000_0000, 1234, 1),
190            "kernel-space futex key with userspace owner must be suspicious"
191        );
192    }
193
194    #[test]
195    fn classify_kernel_space_key_no_owner_not_suspicious() {
196        assert!(
197            !classify_futex(0x8000_0000_0000, 0, 1),
198            "kernel-space key with pid=0 must not be suspicious"
199        );
200    }
201
202    #[test]
203    fn classify_normal_futex_benign() {
204        assert!(
205            !classify_futex(0x7F00_0000_1000, 1234, 3),
206            "normal futex must not be suspicious"
207        );
208    }
209
210    #[test]
211    fn walk_futex_no_symbol_returns_empty() {
212        let reader = make_no_symbol_reader();
213        let result = walk_futex_table(&reader).unwrap();
214        assert!(
215            result.is_empty(),
216            "no futex_queues symbol → empty vec expected"
217        );
218    }
219
220    // --- classify_futex additional branch/boundary coverage ---
221
222    #[test]
223    fn classify_futex_waiter_count_zero_benign() {
224        assert!(
225            !classify_futex(0x7FFF_0000_0000, 0, 0),
226            "zero waiters must not be suspicious"
227        );
228    }
229
230    #[test]
231    fn classify_futex_exactly_boundary_key_not_suspicious() {
232        // key_address == 0x7FFF_FFFF_FFFF is NOT > 0x7FFF_FFFF_FFFF, so not suspicious
233        assert!(
234            !classify_futex(0x7FFF_FFFF_FFFF, 1, 1),
235            "key at exactly 0x7FFF_FFFF_FFFF must not be suspicious"
236        );
237    }
238
239    #[test]
240    fn classify_futex_key_one_above_boundary_suspicious() {
241        // key_address == 0x8000_0000_0000 IS > 0x7FFF_FFFF_FFFF and owner_pid > 0
242        assert!(
243            classify_futex(0x8000_0000_0000, 1, 1),
244            "key just above boundary with non-zero pid must be suspicious"
245        );
246    }
247
248    #[test]
249    fn classify_futex_both_conditions_true_suspicious() {
250        // Both high waiter count AND kernel-space key with userspace owner
251        assert!(
252            classify_futex(0xFFFF_8000_0000_0000, 99, 5000),
253            "both conditions true must be suspicious"
254        );
255    }
256
257    // --- walk_futex_table: symbol present but chain offset missing ---
258
259    #[test]
260    fn walk_futex_missing_chain_offset_returns_empty() {
261        // futex_queues symbol present but futex_hash_bucket.chain field missing
262        let isf = IsfBuilder::new()
263            .add_symbol("futex_queues", 0xFFFF_8000_ABCD_0000)
264            .build_json();
265        let resolver = IsfResolver::from_value(&isf).unwrap();
266        let (cr3, mem) = PageTableBuilder::new().build();
267        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
268        let reader = ObjectReader::new(vas, Box::new(resolver));
269
270        let result = walk_futex_table(&reader).unwrap();
271        assert!(
272            result.is_empty(),
273            "missing futex_hash_bucket.chain offset → empty vec expected"
274        );
275    }
276
277    // --- walk_futex_table: symbol + chain offset present but memory unreadable ---
278
279    #[test]
280    fn walk_futex_unreadable_bucket_returns_empty() {
281        // futex_queues points to an unmapped address, so read_bytes on chain_head fails
282        let isf = IsfBuilder::new()
283            .add_symbol("futex_queues", 0xDEAD_BEEF_CAFE_0000)
284            .add_struct("futex_hash_bucket", 64)
285            .add_field("futex_hash_bucket", "chain", 0, "pointer")
286            .build_json();
287        let resolver = IsfResolver::from_value(&isf).unwrap();
288        let (cr3, mem) = PageTableBuilder::new().build();
289        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
290        let reader = ObjectReader::new(vas, Box::new(resolver));
291
292        // All 256 buckets will fail to read → no waiters found → empty result
293        let result = walk_futex_table(&reader).unwrap();
294        assert!(
295            result.is_empty(),
296            "unreadable bucket memory → empty vec expected"
297        );
298    }
299
300    // --- FutexInfo: Clone + Debug + Serialize ---
301
302    #[test]
303    fn futex_info_clone_debug_serialize() {
304        let info = FutexInfo {
305            key_address: 0x7F00_0000_1000,
306            owner_pid: 42,
307            waiter_count: 3,
308            futex_type: "private".to_string(),
309            is_suspicious: false,
310        };
311        let cloned = info.clone();
312        assert_eq!(cloned.owner_pid, 42);
313        let dbg = format!("{cloned:?}");
314        assert!(dbg.contains("private"));
315        let json = serde_json::to_string(&cloned).unwrap();
316        assert!(json.contains("\"owner_pid\":42"));
317        assert!(json.contains("\"is_suspicious\":false"));
318    }
319
320    // --- walk_futex_table: symbol + chain present, mapped memory, all buckets zero → exercises loop ---
321    // Exercises the bucket scanning loop: chain_head reads succeed (memory mapped) but
322    // first_q == 0 for every bucket → waiter_count stays 0 → no entries pushed.
323    #[test]
324    fn walk_futex_symbol_present_mapped_zero_buckets_returns_empty() {
325        use memf_core::test_builders::flags as ptf;
326
327        // bucket_size=64, chain at offset 0. 256 buckets = 256*64 = 16384 bytes = 4 pages.
328        // We map 4 consecutive 4K pages of zeros.
329        let fq_vaddr: u64 = 0xFFFF_8800_00B0_0000;
330        let fq_paddr_base: u64 = 0x00B0_0000; // unique, < 16 MB; 4 pages = 0xB0_0000..0xB0_4000
331
332        let isf = IsfBuilder::new()
333            .add_symbol("futex_queues", fq_vaddr)
334            .add_struct("futex_hash_bucket", 64)
335            .add_field("futex_hash_bucket", "chain", 0, "pointer")
336            .build_json();
337        let resolver = IsfResolver::from_value(&isf).unwrap();
338
339        let zero_page = [0u8; 4096];
340        let (cr3, mem) = PageTableBuilder::new()
341            .map_4k(fq_vaddr, fq_paddr_base, ptf::WRITABLE)
342            .write_phys(fq_paddr_base, &zero_page)
343            .map_4k(fq_vaddr + 0x1000, fq_paddr_base + 0x1000, ptf::WRITABLE)
344            .write_phys(fq_paddr_base + 0x1000, &zero_page)
345            .map_4k(fq_vaddr + 0x2000, fq_paddr_base + 0x2000, ptf::WRITABLE)
346            .write_phys(fq_paddr_base + 0x2000, &zero_page)
347            .map_4k(fq_vaddr + 0x3000, fq_paddr_base + 0x3000, ptf::WRITABLE)
348            .write_phys(fq_paddr_base + 0x3000, &zero_page)
349            .build();
350
351        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
352        let reader = ObjectReader::new(vas, Box::new(resolver));
353
354        let result = walk_futex_table(&reader).unwrap();
355        assert!(
356            result.is_empty(),
357            "all-zero buckets (first_q==0) → waiter_count stays 0 → empty results"
358        );
359    }
360
361    // --- walk_futex_table: first bucket has a non-zero chain pointer → exercises while loop ---
362    // Maps a futex_hash_bucket whose chain (hlist_head.first) points to a futex_q node.
363    // The node's hlist_node.next (offset 0) is zero → loop runs once → waiter_count==1 →
364    // an entry is pushed to results.
365    #[test]
366    fn walk_futex_one_waiter_pushes_result() {
367        use memf_core::test_builders::flags as ptf;
368
369        // Layout:
370        //   bucket page (vaddr B):  [0..8]  = ptr to futex_q node (vaddr N)
371        //   node page   (vaddr N):  [0..8]  = 0 (hlist_node.next = null → loop ends)
372        //                           [8..16] = 0 (task ptr = 0 → first_pid stays 0)
373        //                           [16..24]= 0 (key = 0)
374        //                           [24..32]= 0 (key_offset_field → "private")
375        let bucket_vaddr: u64 = 0xFFFF_8800_00C0_0000;
376        let bucket_paddr: u64 = 0x00C0_0000; // < 16 MB
377        let node_vaddr: u64 = 0xFFFF_8800_00C1_0000;
378        let node_paddr: u64 = 0x00C1_0000;
379
380        let mut bucket_page = [0u8; 4096];
381        // chain at offset 0 points to node
382        bucket_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
383
384        let node_page = [0u8; 4096]; // all zeros: next=0, task=0, key=0
385
386        let isf = IsfBuilder::new()
387            .add_symbol("futex_queues", bucket_vaddr)
388            .add_struct("futex_hash_bucket", 64)
389            .add_field("futex_hash_bucket", "chain", 0, "pointer")
390            .build_json();
391        let resolver = IsfResolver::from_value(&isf).unwrap();
392
393        let (cr3, mem) = PageTableBuilder::new()
394            .map_4k(bucket_vaddr, bucket_paddr, ptf::WRITABLE)
395            .write_phys(bucket_paddr, &bucket_page)
396            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
397            .write_phys(node_paddr, &node_page)
398            .build();
399
400        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
401        let reader = ObjectReader::new(vas, Box::new(resolver));
402
403        let result = walk_futex_table(&reader).unwrap();
404        // First bucket has one waiter (node_vaddr → next=0) → one FutexInfo pushed.
405        assert_eq!(result.len(), 1, "one waiter in first bucket → one result");
406        assert_eq!(result[0].waiter_count, 1);
407        assert_eq!(result[0].futex_type, "private");
408        assert!(!result[0].is_suspicious, "key=0, pid=0, count=1 → benign");
409    }
410
411    // --- walk_futex_table: shared futex (key_offset_field bit 0 == 1) → futex_type "shared" ---
412    #[test]
413    fn walk_futex_shared_futex_type_detected() {
414        use memf_core::test_builders::flags as ptf;
415
416        let bucket_vaddr: u64 = 0xFFFF_8800_00D0_0000;
417        let bucket_paddr: u64 = 0x00D0_0000;
418        let node_vaddr: u64 = 0xFFFF_8800_00D1_0000;
419        let node_paddr: u64 = 0x00D1_0000;
420
421        let mut bucket_page = [0u8; 4096];
422        bucket_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
423
424        let mut node_page = [0u8; 4096];
425        // hlist_node.next at offset 0 = 0 (terminate loop)
426        // task ptr at offset 8 = 0
427        // futex key at offset 16 = 0
428        // key_offset_field at offset 24: bit 0 = 1 → "shared"
429        node_page[24..32].copy_from_slice(&1u64.to_le_bytes());
430
431        let isf = IsfBuilder::new()
432            .add_symbol("futex_queues", bucket_vaddr)
433            .add_struct("futex_hash_bucket", 64)
434            .add_field("futex_hash_bucket", "chain", 0, "pointer")
435            .build_json();
436        let resolver = IsfResolver::from_value(&isf).unwrap();
437
438        let (cr3, mem) = PageTableBuilder::new()
439            .map_4k(bucket_vaddr, bucket_paddr, ptf::WRITABLE)
440            .write_phys(bucket_paddr, &bucket_page)
441            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
442            .write_phys(node_paddr, &node_page)
443            .build();
444
445        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
446        let reader = ObjectReader::new(vas, Box::new(resolver));
447
448        let result = walk_futex_table(&reader).unwrap();
449        assert_eq!(result.len(), 1);
450        assert_eq!(result[0].futex_type, "shared", "bit 0 set → shared futex");
451    }
452
453    // --- walk_futex_table: task_ptr != 0 → read pid from task_struct ---
454    // Exercises lines 127-131: when waiter_count==0 and task_ptr is non-zero,
455    // the walker reads task_struct.pid to set first_pid.
456    #[test]
457    fn walk_futex_non_null_task_reads_pid() {
458        use memf_core::test_builders::flags as ptf;
459
460        // Layout:
461        //   bucket_vaddr : chain (at offset 0) → node_vaddr
462        //   node_vaddr   : [0..8]=0 (hlist_node.next), [8..16]=task_vaddr (task ptr),
463        //                  [16..24]=0 (futex key), [24..32]=0 (key_offset_field → private)
464        //   task_vaddr   : task_struct with pid at offset 0 = 1234
465        let bucket_vaddr: u64 = 0xFFFF_8800_00E0_0000;
466        let bucket_paddr: u64 = 0x00E0_0000;
467        let node_vaddr: u64 = 0xFFFF_8800_00E1_0000;
468        let node_paddr: u64 = 0x00E1_0000;
469        let task_vaddr: u64 = 0xFFFF_8800_00E2_0000;
470        let task_paddr: u64 = 0x00E2_0000;
471
472        let mut bucket_page = [0u8; 4096];
473        bucket_page[0..8].copy_from_slice(&node_vaddr.to_le_bytes());
474
475        let mut node_page = [0u8; 4096];
476        // hlist_node.next at offset 0 = 0 (one iteration)
477        node_page[0..8].copy_from_slice(&0u64.to_le_bytes());
478        // task ptr at offset 8 (default futex_q.task offset) = task_vaddr
479        node_page[8..16].copy_from_slice(&task_vaddr.to_le_bytes());
480        // futex key at offset 16 = 0 (normal userspace key)
481        // key_offset_field at offset 24 = 0 → "private"
482
483        let mut task_page = [0u8; 4096];
484        // task_struct.pid at offset 0 = 1234 (u32)
485        task_page[0..4].copy_from_slice(&1234u32.to_le_bytes());
486
487        let isf = IsfBuilder::new()
488            .add_symbol("futex_queues", bucket_vaddr)
489            .add_struct("futex_hash_bucket", 64)
490            .add_field("futex_hash_bucket", "chain", 0, "pointer")
491            .add_struct("task_struct", 128)
492            .add_field("task_struct", "pid", 0, "unsigned int")
493            .build_json();
494        let resolver = IsfResolver::from_value(&isf).unwrap();
495
496        let (cr3, mem) = PageTableBuilder::new()
497            .map_4k(bucket_vaddr, bucket_paddr, ptf::WRITABLE)
498            .write_phys(bucket_paddr, &bucket_page)
499            .map_4k(node_vaddr, node_paddr, ptf::WRITABLE)
500            .write_phys(node_paddr, &node_page)
501            .map_4k(task_vaddr, task_paddr, ptf::WRITABLE)
502            .write_phys(task_paddr, &task_page)
503            .build();
504
505        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
506        let reader = ObjectReader::new(vas, Box::new(resolver));
507
508        let result = walk_futex_table(&reader).unwrap();
509        assert_eq!(result.len(), 1, "one waiter → one entry");
510        assert_eq!(
511            result[0].owner_pid, 1234,
512            "pid should be read from task_struct"
513        );
514        assert_eq!(result[0].waiter_count, 1);
515    }
516
517    // --- walk_futex_table: suspicious futex via high waiter count ---
518    // Two nodes in a bucket (nodeA.next → nodeB, nodeB.next=0) → waiter_count=2.
519    // Key is userspace range, pid=0 → not suspicious for kernel-space key check,
520    // but count > 1000 makes it suspicious.
521    // We use a chained list to exercise the "waiter_count > 0" loop iterations > 1.
522    #[test]
523    fn walk_futex_two_waiters_in_bucket() {
524        use memf_core::test_builders::flags as ptf;
525
526        let bucket_vaddr: u64 = 0xFFFF_8800_00F0_0000;
527        let bucket_paddr: u64 = 0x00F0_0000;
528        let node_a_vaddr: u64 = 0xFFFF_8800_00F1_0000;
529        let node_a_paddr: u64 = 0x00F1_0000;
530        let node_b_vaddr: u64 = 0xFFFF_8800_00F2_0000;
531        let node_b_paddr: u64 = 0x00F2_0000;
532
533        let mut bucket_page = [0u8; 4096];
534        bucket_page[0..8].copy_from_slice(&node_a_vaddr.to_le_bytes());
535
536        let mut node_a_page = [0u8; 4096];
537        // hlist_node.next → node_b_vaddr
538        node_a_page[0..8].copy_from_slice(&node_b_vaddr.to_le_bytes());
539        // task ptr at offset 8 = 0
540        // key at offset 16 = 0 → private
541        // key_offset_field at offset 24 = 0
542
543        let mut node_b_page = [0u8; 4096];
544        // hlist_node.next = 0 (terminate)
545        node_b_page[0..8].copy_from_slice(&0u64.to_le_bytes());
546
547        let isf = IsfBuilder::new()
548            .add_symbol("futex_queues", bucket_vaddr)
549            .add_struct("futex_hash_bucket", 64)
550            .add_field("futex_hash_bucket", "chain", 0, "pointer")
551            .build_json();
552        let resolver = IsfResolver::from_value(&isf).unwrap();
553
554        let (cr3, mem) = PageTableBuilder::new()
555            .map_4k(bucket_vaddr, bucket_paddr, ptf::WRITABLE)
556            .write_phys(bucket_paddr, &bucket_page)
557            .map_4k(node_a_vaddr, node_a_paddr, ptf::WRITABLE)
558            .write_phys(node_a_paddr, &node_a_page)
559            .map_4k(node_b_vaddr, node_b_paddr, ptf::WRITABLE)
560            .write_phys(node_b_paddr, &node_b_page)
561            .build();
562
563        let vas = VirtualAddressSpace::new(mem, cr3, TranslationMode::X86_64FourLevel);
564        let reader = ObjectReader::new(vas, Box::new(resolver));
565
566        let result = walk_futex_table(&reader).unwrap();
567        assert_eq!(
568            result.len(),
569            1,
570            "one bucket with two waiters → one aggregate entry"
571        );
572        assert_eq!(result[0].waiter_count, 2, "two nodes → waiter_count = 2");
573        assert!(!result[0].is_suspicious, "count=2, key=0, pid=0 → benign");
574    }
575}