wraith/manipulation/hooks/
unhook.rs

1//! Surgical function unhooking
2//!
3//! Restores hooked functions to their original state by copying
4//! original bytes from a clean copy of the module (loaded from disk).
5
6#[cfg(all(not(feature = "std"), feature = "alloc"))]
7use alloc::{format, string::String, vec::Vec};
8
9#[cfg(feature = "std")]
10use std::{format, string::String, vec::Vec};
11
12use super::detector::{HookDetector, HookInfo};
13use crate::error::{Result, WraithError};
14use crate::manipulation::manual_map::ParsedPe;
15use crate::navigation::Module;
16use crate::util::memory::ProtectionGuard;
17
18/// protection constant for RWX memory
19const PAGE_EXECUTE_READWRITE: u32 = 0x40;
20
21/// result of unhook operation
22#[derive(Debug)]
23pub struct UnhookResult {
24    /// number of functions successfully unhooked
25    pub unhooked_count: usize,
26    /// functions that were unhooked
27    pub unhooked_functions: Vec<String>,
28    /// functions that failed to unhook (name, reason)
29    pub failed_functions: Vec<(String, String)>,
30}
31
32impl UnhookResult {
33    /// check if all hooks were removed
34    pub fn all_successful(&self) -> bool {
35        self.failed_functions.is_empty()
36    }
37
38    /// total number of hooks processed
39    pub fn total(&self) -> usize {
40        self.unhooked_count + self.failed_functions.len()
41    }
42}
43
44/// module unhooker
45pub struct Unhooker<'a> {
46    module: &'a Module<'a>,
47    clean_copy: Vec<u8>,
48    parsed_pe: ParsedPe,
49}
50
51impl<'a> Unhooker<'a> {
52    /// create unhooker for module (loads clean copy from disk)
53    #[cfg(feature = "std")]
54    pub fn new(module: &'a Module<'a>) -> Result<Self> {
55        let path = module.full_path();
56        let clean_copy = std::fs::read(&path).map_err(|_| WraithError::CleanCopyUnavailable)?;
57
58        let parsed_pe = ParsedPe::parse(&clean_copy)?;
59
60        Ok(Self {
61            module,
62            clean_copy,
63            parsed_pe,
64        })
65    }
66
67    /// create unhooker for module (no_std - requires explicit clean copy)
68    #[cfg(not(feature = "std"))]
69    pub fn new(_module: &'a Module<'a>) -> Result<Self> {
70        Err(WraithError::CleanCopyUnavailable)
71    }
72
73    /// create unhooker with explicit clean copy
74    pub fn with_clean_copy(module: &'a Module<'a>, clean_copy: Vec<u8>) -> Result<Self> {
75        let parsed_pe = ParsedPe::parse(&clean_copy)?;
76
77        Ok(Self {
78            module,
79            clean_copy,
80            parsed_pe,
81        })
82    }
83
84    /// unhook a single function by restoring original bytes
85    pub fn unhook_function(&self, hook: &HookInfo) -> Result<()> {
86        if hook.original_bytes.is_empty() {
87            return Err(WraithError::UnhookFailed {
88                function: hook.function_name.clone(),
89                reason: "no original bytes available".into(),
90            });
91        }
92
93        let addr = hook.function_address;
94        let size = hook.original_bytes.len();
95
96        // change protection to RWX
97        let _guard = ProtectionGuard::new(addr, size, PAGE_EXECUTE_READWRITE)?;
98
99        // restore original bytes
100        // SAFETY: protection changed to RWX, original_bytes length is correct
101        unsafe {
102            core::ptr::copy_nonoverlapping(hook.original_bytes.as_ptr(), addr as *mut u8, size);
103        }
104
105        Ok(())
106    }
107
108    /// unhook all detected hooks
109    pub fn unhook_all(&self) -> Result<UnhookResult> {
110        let detector = HookDetector::with_clean_copy(self.module, self.clean_copy.clone());
111        let hooks = detector.scan_exports()?;
112
113        let mut result = UnhookResult {
114            unhooked_count: 0,
115            unhooked_functions: Vec::new(),
116            failed_functions: Vec::new(),
117        };
118
119        for hook in hooks {
120            match self.unhook_function(&hook) {
121                Ok(()) => {
122                    result.unhooked_count += 1;
123                    result.unhooked_functions.push(hook.function_name);
124                }
125                Err(e) => {
126                    result
127                        .failed_functions
128                        .push((hook.function_name, e.to_string()));
129                }
130            }
131        }
132
133        Ok(result)
134    }
135
136    /// unhook entire .text section by copying from clean copy
137    pub fn unhook_text_section(&self) -> Result<()> {
138        let text_section = self
139            .parsed_pe
140            .sections()
141            .iter()
142            .find(|s| s.name_str() == ".text")
143            .ok_or_else(|| WraithError::UnhookFailed {
144                function: ".text".into(),
145                reason: "no .text section found".into(),
146            })?;
147
148        let text_rva = text_section.virtual_address as usize;
149        let text_size = text_section.virtual_size as usize;
150        let text_file_offset = text_section.pointer_to_raw_data as usize;
151        let text_raw_size = text_section.size_of_raw_data as usize;
152
153        let target_addr = self.module.base() + text_rva;
154
155        // change protection
156        let _guard = ProtectionGuard::new(target_addr, text_size, PAGE_EXECUTE_READWRITE)?;
157
158        // get clean .text section data
159        let copy_size = text_raw_size.min(text_size);
160        if text_file_offset + copy_size > self.clean_copy.len() {
161            return Err(WraithError::UnhookFailed {
162                function: ".text".into(),
163                reason: "clean copy too small".into(),
164            });
165        }
166
167        let clean_text = &self.clean_copy[text_file_offset..text_file_offset + copy_size];
168
169        // copy clean .text section
170        // SAFETY: protection changed, bounds checked
171        unsafe {
172            core::ptr::copy_nonoverlapping(clean_text.as_ptr(), target_addr as *mut u8, copy_size);
173        }
174
175        Ok(())
176    }
177
178    /// unhook specific function by name
179    pub fn unhook_by_name(&self, function_name: &str) -> Result<()> {
180        let addr = self.module.get_export(function_name)?;
181
182        // get RVA
183        let rva = self.module.va_to_rva(addr).ok_or_else(|| WraithError::UnhookFailed {
184            function: function_name.into(),
185            reason: "address not in module".into(),
186        })?;
187
188        // get original bytes from clean copy
189        let original = self.get_original_bytes(rva as usize, 32)?;
190
191        // change protection and restore
192        let _guard = ProtectionGuard::new(addr, original.len(), PAGE_EXECUTE_READWRITE)?;
193
194        // SAFETY: protection changed, bounds verified
195        unsafe {
196            core::ptr::copy_nonoverlapping(original.as_ptr(), addr as *mut u8, original.len());
197        }
198
199        Ok(())
200    }
201
202    /// unhook multiple functions by name
203    pub fn unhook_by_names(&self, names: &[&str]) -> UnhookResult {
204        let mut result = UnhookResult {
205            unhooked_count: 0,
206            unhooked_functions: Vec::new(),
207            failed_functions: Vec::new(),
208        };
209
210        for &name in names {
211            match self.unhook_by_name(name) {
212                Ok(()) => {
213                    result.unhooked_count += 1;
214                    result.unhooked_functions.push(name.to_string());
215                }
216                Err(e) => {
217                    result.failed_functions.push((name.to_string(), e.to_string()));
218                }
219            }
220        }
221
222        result
223    }
224
225    /// get original bytes at RVA from clean copy
226    fn get_original_bytes(&self, rva: usize, len: usize) -> Result<Vec<u8>> {
227        for section in self.parsed_pe.sections() {
228            let sec_rva = section.virtual_address as usize;
229            let sec_size = section.virtual_size as usize;
230
231            if rva >= sec_rva && rva < sec_rva + sec_size {
232                let offset_in_section = rva - sec_rva;
233                let file_offset = section.pointer_to_raw_data as usize + offset_in_section;
234
235                if file_offset + len <= self.clean_copy.len() {
236                    return Ok(self.clean_copy[file_offset..file_offset + len].to_vec());
237                }
238            }
239        }
240
241        Err(WraithError::UnhookFailed {
242            function: format!("RVA {rva:#x}"),
243            reason: "RVA not in any section".into(),
244        })
245    }
246}
247
248/// restore a single function to original state
249pub fn restore_function(module: &Module, function_name: &str) -> Result<()> {
250    let unhooker = Unhooker::new(module)?;
251    unhooker.unhook_by_name(function_name)
252}
253
254/// restore entire .text section of a module
255pub fn restore_text_section(module: &Module) -> Result<()> {
256    let unhooker = Unhooker::new(module)?;
257    unhooker.unhook_text_section()
258}