wraith/manipulation/syscall/
enumerator.rs

1//! Syscall Service Number enumeration from ntdll
2//!
3//! Extracts SSNs by parsing ntdll export directory and reading
4//! the syscall stub prologues to find the mov eax, imm32 instruction.
5
6#[cfg(all(not(feature = "std"), feature = "alloc"))]
7use alloc::{string::{String, ToString}, vec::Vec};
8
9#[cfg(feature = "std")]
10use std::{string::{String, ToString}, vec::Vec};
11
12use crate::error::{Result, WraithError};
13use crate::navigation::{Module, ModuleQuery};
14use crate::structures::pe::{DataDirectoryType, ExportDirectory};
15use crate::structures::Peb;
16use crate::util::hash::djb2_hash;
17
18/// syscall stub patterns for detection
19mod patterns {
20    // x64 syscall stub pattern:
21    // 4C 8B D1        mov r10, rcx
22    // B8 XX XX 00 00  mov eax, <ssn>
23    // ...
24    // 0F 05           syscall
25    // C3              ret
26    #[cfg(target_arch = "x86_64")]
27    pub const MOV_R10_RCX: [u8; 3] = [0x4C, 0x8B, 0xD1];
28
29    pub const MOV_EAX: u8 = 0xB8;
30
31    #[cfg(target_arch = "x86_64")]
32    pub const SYSCALL: [u8; 2] = [0x0F, 0x05];
33
34    // x86 syscall stub patterns
35    #[cfg(target_arch = "x86")]
36    pub const INT_2E: [u8; 2] = [0xCD, 0x2E];
37
38    #[cfg(target_arch = "x86")]
39    pub const SYSENTER: [u8; 2] = [0x0F, 0x34];
40}
41
42/// enumerates syscalls from ntdll exports
43pub struct SyscallEnumerator<'a> {
44    ntdll: Module<'a>,
45}
46
47impl<'a> SyscallEnumerator<'a> {
48    /// create enumerator for ntdll
49    pub fn new(ntdll: Module<'a>) -> Self {
50        Self { ntdll }
51    }
52
53    /// enumerate all syscalls and their SSNs
54    pub fn enumerate(&self) -> Result<Vec<EnumeratedSyscall>> {
55        let mut syscalls = Vec::new();
56
57        let nt = self.ntdll.nt_headers()?;
58        let export_dir = nt
59            .data_directory(DataDirectoryType::Export.index())
60            .ok_or(WraithError::SyscallEnumerationFailed {
61                reason: "no export directory".into(),
62            })?;
63
64        if !export_dir.is_present() {
65            return Err(WraithError::SyscallEnumerationFailed {
66                reason: "export directory not present".into(),
67            });
68        }
69
70        let base = self.ntdll.base();
71        // SAFETY: export directory RVA points to valid memory in loaded ntdll
72        let exports = unsafe {
73            &*((base + export_dir.virtual_address as usize) as *const ExportDirectory)
74        };
75
76        let num_names = exports.number_of_names as usize;
77        let names = base + exports.address_of_names as usize;
78        let ordinals = base + exports.address_of_name_ordinals as usize;
79        let functions = base + exports.address_of_functions as usize;
80
81        for i in 0..num_names {
82            // SAFETY: iterating within bounds of export arrays
83            let name_rva = unsafe { *((names + i * 4) as *const u32) };
84            let name_ptr = (base + name_rva as usize) as *const u8;
85
86            // read function name with bounds checking
87            let name = unsafe {
88                let mut len = 0;
89                while *name_ptr.add(len) != 0 && len < 256 {
90                    len += 1;
91                }
92                let bytes = core::slice::from_raw_parts(name_ptr, len);
93                match core::str::from_utf8(bytes) {
94                    Ok(s) => s,
95                    Err(_) => continue, // skip invalid UTF-8
96                }
97            };
98
99            // only process Nt/Zw functions (syscalls)
100            if !name.starts_with("Nt") && !name.starts_with("Zw") {
101                continue;
102            }
103
104            // skip Nt functions that aren't syscalls (they're just accessors)
105            if matches!(
106                name,
107                "NtCurrentTeb"
108                    | "NtCurrentPeb"
109                    | "NtGetTickCount"
110                    | "NtdllDefWindowProc_A"
111                    | "NtdllDefWindowProc_W"
112                    | "NtdllDialogWndProc_A"
113                    | "NtdllDialogWndProc_W"
114            ) {
115                continue;
116            }
117
118            let ordinal = unsafe { *((ordinals + i * 2) as *const u16) };
119            let func_rva = unsafe { *((functions + ordinal as usize * 4) as *const u32) };
120            let func_addr = base + func_rva as usize;
121
122            // check for forwarded export
123            if func_rva >= export_dir.virtual_address
124                && func_rva < export_dir.virtual_address + export_dir.size
125            {
126                continue;
127            }
128
129            // try to extract SSN from the stub
130            if let Some(ssn) = self.extract_ssn(func_addr) {
131                syscalls.push(EnumeratedSyscall {
132                    name: name.to_string(),
133                    name_hash: djb2_hash(name.as_bytes()),
134                    ssn,
135                    address: func_addr,
136                    syscall_address: self.find_syscall_instruction(func_addr),
137                });
138            }
139        }
140
141        // sort by SSN (they should be sequential)
142        syscalls.sort_by_key(|s| s.ssn);
143
144        Ok(syscalls)
145    }
146
147    /// extract SSN from syscall stub (x64)
148    #[cfg(target_arch = "x86_64")]
149    fn extract_ssn(&self, addr: usize) -> Option<u16> {
150        // SAFETY: reading from function address in loaded ntdll
151        let bytes = unsafe { core::slice::from_raw_parts(addr as *const u8, 32) };
152
153        // standard pattern: 4C 8B D1 B8 XX XX 00 00
154        if bytes.len() >= 8
155            && bytes[0..3] == patterns::MOV_R10_RCX
156            && bytes[3] == patterns::MOV_EAX
157        {
158            let ssn = u16::from_le_bytes([bytes[4], bytes[5]]);
159            return Some(ssn);
160        }
161
162        // hooked stub might have different prologue - scan for mov eax pattern
163        for i in 0..20 {
164            if i + 2 < bytes.len() && bytes[i] == patterns::MOV_EAX {
165                let ssn = u16::from_le_bytes([bytes[i + 1], bytes[i + 2]]);
166                if ssn < 0x1000 {
167                    return Some(ssn);
168                }
169            }
170        }
171
172        None
173    }
174
175    /// extract SSN from syscall stub (x86)
176    #[cfg(target_arch = "x86")]
177    fn extract_ssn(&self, addr: usize) -> Option<u16> {
178        // SAFETY: reading from function address in loaded ntdll
179        let bytes = unsafe { core::slice::from_raw_parts(addr as *const u8, 32) };
180
181        // pattern: B8 XX XX 00 00
182        if bytes.len() >= 5 && bytes[0] == patterns::MOV_EAX {
183            let ssn = u16::from_le_bytes([bytes[1], bytes[2]]);
184            return Some(ssn);
185        }
186
187        None
188    }
189
190    /// find syscall/sysenter instruction address in stub (x64)
191    #[cfg(target_arch = "x86_64")]
192    fn find_syscall_instruction(&self, func_addr: usize) -> Option<usize> {
193        // SAFETY: reading from function in loaded ntdll
194        let bytes = unsafe { core::slice::from_raw_parts(func_addr as *const u8, 32) };
195
196        // look for syscall (0F 05)
197        for i in 0..30 {
198            if i + 1 < bytes.len() && bytes[i..].starts_with(&patterns::SYSCALL) {
199                return Some(func_addr + i);
200            }
201        }
202
203        None
204    }
205
206    /// find syscall/sysenter instruction address in stub (x86)
207    #[cfg(target_arch = "x86")]
208    fn find_syscall_instruction(&self, func_addr: usize) -> Option<usize> {
209        // SAFETY: reading from function in loaded ntdll
210        let bytes = unsafe { core::slice::from_raw_parts(func_addr as *const u8, 64) };
211
212        // look for int 0x2e or sysenter
213        for i in 0..60 {
214            if i + 1 < bytes.len()
215                && (bytes[i..].starts_with(&patterns::INT_2E)
216                    || bytes[i..].starts_with(&patterns::SYSENTER))
217            {
218                return Some(func_addr + i);
219            }
220        }
221
222        None
223    }
224
225    /// resolve SSN using "Halo's Gate" technique
226    ///
227    /// if a syscall is hooked, look at neighboring syscalls
228    /// (SSNs are sequential, so Nt* functions nearby have SSN +/- N)
229    #[allow(dead_code)]
230    pub fn resolve_hooked_ssn(&self, target_addr: usize) -> Option<u16> {
231        // search upward (earlier functions have lower SSNs)
232        for offset in 1..=20u16 {
233            // typical syscall stub size is ~32 bytes
234            let check_addr = target_addr.wrapping_sub(offset as usize * 32);
235            if let Some(ssn) = self.extract_ssn(check_addr) {
236                return Some(ssn.wrapping_add(offset));
237            }
238        }
239
240        // search downward (later functions have higher SSNs)
241        for offset in 1..=20u16 {
242            let check_addr = target_addr + (offset as usize * 32);
243            if let Some(ssn) = self.extract_ssn(check_addr) {
244                return ssn.checked_sub(offset);
245            }
246        }
247
248        None
249    }
250}
251
252/// enumerated syscall information
253#[derive(Debug, Clone)]
254pub struct EnumeratedSyscall {
255    /// function name (e.g., "NtOpenProcess")
256    pub name: String,
257    /// hash of function name for fast lookup
258    pub name_hash: u32,
259    /// syscall service number
260    pub ssn: u16,
261    /// address in ntdll
262    pub address: usize,
263    /// address of syscall instruction (for indirect calls)
264    pub syscall_address: Option<usize>,
265}
266
267/// enumerate syscalls from current process's ntdll
268pub fn enumerate_syscalls() -> Result<Vec<EnumeratedSyscall>> {
269    let peb = Peb::current()?;
270    let query = ModuleQuery::new(&peb);
271    let ntdll = query.ntdll().map_err(|_| WraithError::NtdllNotFound)?;
272
273    let enumerator = SyscallEnumerator::new(ntdll);
274    enumerator.enumerate()
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_enumerate_syscalls() {
283        let syscalls = enumerate_syscalls().expect("should enumerate syscalls");
284        assert!(!syscalls.is_empty(), "should find at least some syscalls");
285
286        // should have NtClose
287        let nt_close = syscalls.iter().find(|s| s.name == "NtClose");
288        assert!(nt_close.is_some(), "should find NtClose");
289
290        // SSN should be reasonable (< 0x500 on most Windows versions)
291        let close = nt_close.unwrap();
292        assert!(close.ssn < 0x500, "NtClose SSN should be reasonable");
293    }
294
295    #[test]
296    fn test_ssn_ordering() {
297        let syscalls = enumerate_syscalls().expect("should enumerate syscalls");
298
299        // SSNs should be sorted after enumeration
300        for i in 1..syscalls.len() {
301            assert!(
302                syscalls[i].ssn >= syscalls[i - 1].ssn,
303                "SSNs should be sorted"
304            );
305        }
306    }
307}