Skip to main content

decant_inject/
guest.rs

1use std::collections::HashSet;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5use std::thread;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use crate::pe::{DLL_PROCESS_ATTACH, ImportSymbol, Pe, Section, u16_at, u32_at};
9use crate::{DecantConfig, InjectError, InjectionDomain, Method};
10
11const DEFAULT_HOOK_MODULE: &str = "kernel32.dll";
12const DEFAULT_HOOK_FUNCTION: &str = "Sleep";
13const STAGE_CAVE_SIZE: usize = 0x400;
14const STAGE_RESULT_OFFSET: u64 = 0x20;
15const STAGE_STUB_OFFSET: u64 = 0x100;
16const STAGE_SCRATCH_OFFSET: u64 = 0x300;
17const STAGE_UNWIND_OFFSET: u64 = 0x3E0;
18const STAGE_SCRATCH_SIZE: usize = (STAGE_UNWIND_OFFSET - STAGE_SCRATCH_OFFSET) as usize;
19const GUEST_PAGE_SIZE: usize = 0x1000;
20const SCAN_CHUNK: u64 = 0x10000;
21const SPOOFED_RETURN_SCAN_LIMIT: u64 = 0x40000;
22const SPOOFED_CALL_LANDING_DELTA: u64 = 46;
23const MEM_COMMIT_RESERVE: u64 = 0x0000_1000 | 0x0000_2000;
24const PAGE_NOACCESS: u64 = 0x01;
25const PAGE_READONLY: u64 = 0x02;
26const PAGE_READWRITE: u64 = 0x04;
27const PAGE_EXECUTE: u64 = 0x10;
28const PAGE_EXECUTE_READ: u64 = 0x20;
29const PAGE_EXECUTE_READWRITE: u64 = 0x40;
30const CFG_CALL_TARGET_VALID: u64 = 0x1;
31const IMAGE_SCN_MEM_EXECUTE: u32 = 0x2000_0000;
32const IMAGE_SCN_MEM_READ: u32 = 0x4000_0000;
33const IMAGE_SCN_MEM_WRITE: u32 = 0x8000_0000;
34const RESULT_RUNNING: u64 = 1;
35const RESULT_STATE: u64 = 2;
36const RESULT_BLOCK_SIZE: usize = 16;
37const OLD_PROTECT_RESULT_OFFSET: u64 = 12;
38const MAX_EXPORT_FORWARD_DEPTH: usize = 8;
39const EXPORT_HEADER_RETRIES: usize = 20;
40const EXPORT_HEADER_RETRY_DELAY: Duration = Duration::from_millis(25);
41const FRAMED_STUB_STACK_ALLOC: u8 = 0x68;
42
43static ACTIVE_GUEST_INJECTIONS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new();
44
45fn guest_timeout_ms() -> u32 {
46    5000
47}
48
49fn default_hook_module() -> String {
50    DEFAULT_HOOK_MODULE.into()
51}
52
53fn default_hook_function() -> String {
54    DEFAULT_HOOK_FUNCTION.into()
55}
56
57#[derive(Clone, Copy, PartialEq, Eq, Debug)]
58pub enum GuestPortability {
59    GuestPublicExports,
60    GuestLoaderInternals,
61    GuestThreadContext,
62    ExternalAgent,
63}
64
65impl GuestPortability {
66    pub fn label(self) -> &'static str {
67        match self {
68            GuestPortability::GuestPublicExports => "guest-public-exports",
69            GuestPortability::GuestLoaderInternals => "guest-loader-internals",
70            GuestPortability::GuestThreadContext => "guest-thread-context",
71            GuestPortability::ExternalAgent => "external-agent",
72        }
73    }
74}
75
76#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum GuestAllocationMethod {
79    #[default]
80    VirtualAlloc,
81    ExistingRegion,
82    PageTableMap,
83}
84
85impl GuestAllocationMethod {
86    pub fn label(self) -> &'static str {
87        match self {
88            GuestAllocationMethod::VirtualAlloc => "virtual-alloc",
89            GuestAllocationMethod::ExistingRegion => "existing-region",
90            GuestAllocationMethod::PageTableMap => "page-table-map",
91        }
92    }
93}
94
95#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum GuestExecutionMethod {
98    #[default]
99    IatHook,
100    RemoteThread,
101    ThreadHijack,
102    Apc,
103    ExternalAgent,
104    None,
105}
106
107impl GuestExecutionMethod {
108    pub fn label(self) -> &'static str {
109        match self {
110            GuestExecutionMethod::IatHook => "iat-hook",
111            GuestExecutionMethod::RemoteThread => "remote-thread",
112            GuestExecutionMethod::ThreadHijack => "thread-hijack",
113            GuestExecutionMethod::Apc => "apc",
114            GuestExecutionMethod::ExternalAgent => "external-agent",
115            GuestExecutionMethod::None => "none",
116        }
117    }
118}
119
120#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
121#[serde(rename_all = "kebab-case")]
122pub enum GuestDependencyPolicy {
123    #[default]
124    RequireLoaded,
125    LoadWithGuestLoader,
126}
127
128impl GuestDependencyPolicy {
129    pub fn label(self) -> &'static str {
130        match self {
131            GuestDependencyPolicy::RequireLoaded => "require-loaded",
132            GuestDependencyPolicy::LoadWithGuestLoader => "load-with-guest-loader",
133        }
134    }
135}
136
137#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum GuestTlsMode {
140    #[default]
141    CallbacksOnly,
142    Skip,
143    RequireStatic,
144}
145
146impl GuestTlsMode {
147    pub fn label(self) -> &'static str {
148        match self {
149            GuestTlsMode::CallbacksOnly => "callbacks-only",
150            GuestTlsMode::Skip => "skip",
151            GuestTlsMode::RequireStatic => "require-static",
152        }
153    }
154}
155
156#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
157#[serde(rename_all = "kebab-case")]
158pub enum GuestFinalProtections {
159    Rwx,
160    #[default]
161    Section,
162}
163
164impl GuestFinalProtections {
165    pub fn label(self) -> &'static str {
166        match self {
167            GuestFinalProtections::Rwx => "rwx",
168            GuestFinalProtections::Section => "section",
169        }
170    }
171}
172
173#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
174#[serde(rename_all = "kebab-case")]
175pub enum GuestLoaderMetadataPolicy {
176    #[default]
177    RejectUnsupported,
178    BestEffort,
179    AllowUnsupported,
180}
181
182impl GuestLoaderMetadataPolicy {
183    pub fn label(self) -> &'static str {
184        match self {
185            GuestLoaderMetadataPolicy::RejectUnsupported => "reject-unsupported",
186            GuestLoaderMetadataPolicy::BestEffort => "best-effort",
187            GuestLoaderMetadataPolicy::AllowUnsupported => "allow-unsupported",
188        }
189    }
190
191    fn allows_unregistered_metadata(self) -> bool {
192        matches!(
193            self,
194            GuestLoaderMetadataPolicy::BestEffort | GuestLoaderMetadataPolicy::AllowUnsupported
195        )
196    }
197
198    fn registers_public_metadata(self) -> bool {
199        matches!(self, GuestLoaderMetadataPolicy::BestEffort)
200    }
201}
202
203#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
204#[serde(rename_all = "kebab-case")]
205pub enum GuestCallStackPolicy {
206    #[default]
207    Native,
208    RegisteredUnwind,
209}
210
211impl GuestCallStackPolicy {
212    pub fn label(self) -> &'static str {
213        match self {
214            GuestCallStackPolicy::Native => "native",
215            GuestCallStackPolicy::RegisteredUnwind => "registered-unwind",
216        }
217    }
218}
219
220#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
221#[serde(rename_all = "kebab-case")]
222pub enum GuestPermissionTransitions {
223    #[default]
224    Standard,
225    WriteThroughFinal,
226}
227
228impl GuestPermissionTransitions {
229    pub fn label(self) -> &'static str {
230        match self {
231            GuestPermissionTransitions::Standard => "standard",
232            GuestPermissionTransitions::WriteThroughFinal => "write-through-final",
233        }
234    }
235}
236
237#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
238#[serde(rename_all = "kebab-case")]
239pub enum GuestThreadStartPolicy {
240    #[default]
241    ExistingThread,
242    RequireModuleBacked,
243}
244
245impl GuestThreadStartPolicy {
246    pub fn label(self) -> &'static str {
247        match self {
248            GuestThreadStartPolicy::ExistingThread => "existing-thread",
249            GuestThreadStartPolicy::RequireModuleBacked => "require-module-backed",
250        }
251    }
252}
253
254#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
255#[serde(rename_all = "kebab-case")]
256pub enum GuestImageBacking {
257    #[default]
258    Private,
259    SecImage,
260}
261
262impl GuestImageBacking {
263    pub fn label(self) -> &'static str {
264        match self {
265            GuestImageBacking::Private => "private",
266            GuestImageBacking::SecImage => "sec-image",
267        }
268    }
269}
270
271#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
272#[serde(rename_all = "kebab-case")]
273pub enum GuestBaseAddress {
274    #[default]
275    Preferred,
276    Randomized,
277}
278
279impl GuestBaseAddress {
280    pub fn label(self) -> &'static str {
281        match self {
282            GuestBaseAddress::Preferred => "preferred",
283            GuestBaseAddress::Randomized => "randomized",
284        }
285    }
286}
287
288#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
289#[serde(rename_all = "kebab-case")]
290pub enum GuestHeaderWipe {
291    #[default]
292    None,
293    AfterLoad,
294}
295
296impl GuestHeaderWipe {
297    pub fn label(self) -> &'static str {
298        match self {
299            GuestHeaderWipe::None => "none",
300            GuestHeaderWipe::AfterLoad => "after-load",
301        }
302    }
303}
304
305#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
306#[serde(rename_all = "kebab-case")]
307pub enum GuestLoaderEntries {
308    #[default]
309    Absent,
310    Synthesized,
311}
312
313impl GuestLoaderEntries {
314    pub fn label(self) -> &'static str {
315        match self {
316            GuestLoaderEntries::Absent => "absent",
317            GuestLoaderEntries::Synthesized => "synthesized",
318        }
319    }
320}
321
322#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
323#[serde(rename_all = "kebab-case")]
324pub enum GuestStackShaping {
325    #[default]
326    Native,
327    Spoofed,
328}
329
330impl GuestStackShaping {
331    pub fn label(self) -> &'static str {
332        match self {
333            GuestStackShaping::Native => "native",
334            GuestStackShaping::Spoofed => "spoofed",
335        }
336    }
337}
338
339#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
340#[serde(rename_all = "kebab-case")]
341pub enum GuestCleanup {
342    #[default]
343    Resident,
344    Tracked,
345}
346
347impl GuestCleanup {
348    pub fn label(self) -> &'static str {
349        match self {
350            GuestCleanup::Resident => "resident",
351            GuestCleanup::Tracked => "tracked",
352        }
353    }
354}
355
356#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
357#[serde(rename_all = "kebab-case")]
358pub enum GuestVadSpoof {
359    #[default]
360    Off,
361    VadImageMap,
362}
363
364impl GuestVadSpoof {
365    pub fn label(self) -> &'static str {
366        match self {
367            GuestVadSpoof::Off => "off",
368            GuestVadSpoof::VadImageMap => "vad-image-map",
369        }
370    }
371}
372
373#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
374#[serde(deny_unknown_fields)]
375pub struct GuestExecutionConfig {
376    #[serde(default)]
377    pub method: GuestExecutionMethod,
378    #[serde(default = "guest_timeout_ms")]
379    pub timeout_ms: u32,
380}
381
382impl Default for GuestExecutionConfig {
383    fn default() -> Self {
384        Self {
385            method: GuestExecutionMethod::IatHook,
386            timeout_ms: guest_timeout_ms(),
387        }
388    }
389}
390
391#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct GuestInjectionConfig {
394    #[serde(default)]
395    pub pid: Option<u32>,
396    #[serde(default)]
397    pub process: Option<String>,
398    #[serde(default)]
399    pub image_path: Option<String>,
400    #[serde(default)]
401    pub session_id: Option<u32>,
402    #[serde(default)]
403    pub package_family_name: Option<String>,
404    #[serde(default)]
405    pub appcontainer_sid: Option<String>,
406    #[serde(default)]
407    pub process_pattern: Option<String>,
408    #[serde(default)]
409    pub payload_path: Option<PathBuf>,
410    #[serde(default)]
411    pub allocation: GuestAllocationMethod,
412    #[serde(default)]
413    pub target_module: Option<String>,
414    #[serde(default)]
415    pub stage_base: Option<u64>,
416    #[serde(default)]
417    pub stage_pattern: Option<String>,
418    #[serde(default)]
419    pub result_base: Option<u64>,
420    #[serde(default)]
421    pub result_pattern: Option<String>,
422    #[serde(default = "default_hook_module")]
423    pub hook_module: String,
424    #[serde(default = "default_hook_function")]
425    pub hook_function: String,
426    #[serde(default)]
427    pub execution: GuestExecutionConfig,
428    #[serde(default)]
429    pub dependency_policy: GuestDependencyPolicy,
430    #[serde(default)]
431    pub tls: GuestTlsMode,
432    #[serde(default)]
433    pub final_protections: GuestFinalProtections,
434    #[serde(default)]
435    pub loader_metadata: GuestLoaderMetadataPolicy,
436    #[serde(default)]
437    pub call_stack: GuestCallStackPolicy,
438    #[serde(default)]
439    pub permission_transitions: GuestPermissionTransitions,
440    #[serde(default)]
441    pub thread_starts: GuestThreadStartPolicy,
442    #[serde(default)]
443    pub image_backing: GuestImageBacking,
444    #[serde(default)]
445    pub base_address: GuestBaseAddress,
446    #[serde(default)]
447    pub header_wipe: GuestHeaderWipe,
448    #[serde(default)]
449    pub loader_entries: GuestLoaderEntries,
450    #[serde(default)]
451    pub stack_shaping: GuestStackShaping,
452    #[serde(default)]
453    pub cleanup: GuestCleanup,
454    #[serde(default)]
455    pub vad_spoof: GuestVadSpoof,
456}
457
458#[derive(Clone, Debug, PartialEq, Eq)]
459pub struct GuestProcessSelector {
460    pub pid: Option<u32>,
461    pub name: Option<String>,
462    pub pattern: Option<GuestBytePattern>,
463}
464
465impl GuestProcessSelector {
466    pub fn validate(&self) -> Result<(), GuestInjectError> {
467        let name = self
468            .name
469            .as_deref()
470            .map(str::trim)
471            .filter(|s| !s.is_empty());
472        match (self.pid, name) {
473            (Some(_), None) => Ok(()),
474            (None, Some(_)) => Ok(()),
475            (Some(_), Some(_)) => Err(GuestInjectError::Config(
476                "set either guest.pid or guest.process, not both".into(),
477            )),
478            (None, None) => Err(GuestInjectError::Config(
479                "set guest.pid or guest.process for guest injection".into(),
480            )),
481        }
482    }
483
484    pub fn label(&self) -> String {
485        let base = match (self.pid, self.name.as_deref()) {
486            (Some(pid), None) => pid.to_string(),
487            (None, Some(name)) => name.to_string(),
488            (Some(pid), Some(name)) => format!("{name} ({pid})"),
489            (None, None) => "<unset>".into(),
490        };
491        match self.pattern {
492            Some(_) => format!("{base} matching process_pattern"),
493            None => base,
494        }
495    }
496}
497
498#[derive(Clone, Debug, PartialEq, Eq)]
499pub struct GuestBytePattern {
500    bytes: Vec<Option<u8>>,
501}
502
503impl GuestBytePattern {
504    fn find_in(&self, haystack: &[u8]) -> Option<usize> {
505        if self.bytes.is_empty() {
506            return Some(0);
507        }
508        haystack.windows(self.bytes.len()).position(|window| {
509            window
510                .iter()
511                .zip(&self.bytes)
512                .all(|(got, expected)| expected.is_none_or(|want| *got == want))
513        })
514    }
515
516    fn len(&self) -> usize {
517        self.bytes.len()
518    }
519}
520
521#[derive(Clone, Debug, PartialEq, Eq)]
522pub struct GuestInjectionPlan {
523    pub method: Method,
524    pub target: GuestProcessSelector,
525    pub payload_path: PathBuf,
526    pub allocation: GuestAllocationMethod,
527    pub dependency_policy: GuestDependencyPolicy,
528    pub tls: GuestTlsMode,
529    pub final_protections: GuestFinalProtections,
530    pub loader_metadata: GuestLoaderMetadataPolicy,
531    pub call_stack: GuestCallStackPolicy,
532    pub permission_transitions: GuestPermissionTransitions,
533    pub thread_starts: GuestThreadStartPolicy,
534    pub image_backing: GuestImageBacking,
535    pub base_address: GuestBaseAddress,
536    pub header_wipe: GuestHeaderWipe,
537    pub loader_entries: GuestLoaderEntries,
538    pub stack_shaping: GuestStackShaping,
539    pub cleanup: GuestCleanup,
540    pub vad_spoof: GuestVadSpoof,
541    pub target_module: Option<String>,
542    pub stage_base: Option<u64>,
543    pub stage_pattern: Option<GuestBytePattern>,
544    pub result_base: Option<u64>,
545    pub result_pattern: Option<GuestBytePattern>,
546    pub hook_module: String,
547    pub hook_function: String,
548    pub execution: GuestExecutionConfig,
549    pub timeout_ms: u32,
550}
551
552impl GuestInjectionPlan {
553    pub fn from_config(config: &DecantConfig) -> Result<Self, GuestInjectError> {
554        match config.injection.domain {
555            InjectionDomain::Guest => {}
556            InjectionDomain::Tool => {
557                return Err(GuestInjectError::Config(
558                    "set injection.domain = \"guest\" for guest injection".into(),
559                ));
560            }
561        }
562        match config.injection.method {
563            Method::ManualMap => {}
564            Method::ThreadHijack => {
565                return Err(GuestInjectError::Config(
566                    "guest domain uses method = \"manual-map\"; guest.execution.method selects the execution path".into(),
567                ));
568            }
569            Method::Standard | Method::Plugin | Method::External => {
570                return Err(GuestInjectError::Config(
571                    "guest domain uses method = \"manual-map\"".into(),
572                ));
573            }
574        }
575        let target = GuestProcessSelector {
576            pid: config.guest.pid,
577            name: config.guest.process.clone(),
578            pattern: match config.guest.process_pattern.as_deref() {
579                Some(pattern) => Some(parse_hex_pattern(pattern, "guest.process_pattern")?),
580                None => None,
581            },
582        };
583        if config.guest.image_path.is_some()
584            || config.guest.session_id.is_some()
585            || config.guest.package_family_name.is_some()
586            || config.guest.appcontainer_sid.is_some()
587        {
588            return Err(GuestInjectError::Config(
589                "guest image_path/session_id/package_family_name/appcontainer_sid selectors are parsed but not implemented by this backend".into(),
590            ));
591        }
592        target.validate()?;
593        let payload_path = match &config.guest.payload_path {
594            Some(path) => path.clone(),
595            None => {
596                return Err(GuestInjectError::Config(
597                    "set guest.payload_path to the DLL image".into(),
598                ));
599            }
600        };
601        if config.guest.image_backing == GuestImageBacking::SecImage
602            && config.guest.final_protections == GuestFinalProtections::Rwx
603        {
604            return Err(GuestInjectError::Config(
605                "guest.image_backing = \"sec-image\" requires final_protections = \"section\"; an image-file-backed mapping uses PE-derived page protections, not a single RWX region".into(),
606            ));
607        }
608        if config.guest.image_backing == GuestImageBacking::SecImage
609            && config.guest.allocation != GuestAllocationMethod::VirtualAlloc
610        {
611            return Err(GuestInjectError::Config(
612                "guest.image_backing = \"sec-image\" requires allocation = \"virtual-alloc\"; VirtualAlloc is used for helper buffers while the payload image is mapped through SEC_IMAGE".into(),
613            ));
614        }
615        if config.guest.loader_entries == GuestLoaderEntries::Synthesized
616            && config.guest.image_backing == GuestImageBacking::SecImage
617        {
618            return Err(GuestInjectError::Config(
619                "guest.loader_entries = \"synthesized\" is incompatible with image_backing = \"sec-image\"; SEC_IMAGE mappings already create real section state".into(),
620            ));
621        }
622        if config.guest.vad_spoof == GuestVadSpoof::VadImageMap
623            && config.guest.image_backing != GuestImageBacking::Private
624        {
625            return Err(GuestInjectError::Config(
626                "guest.vad_spoof = \"vad-image-map\" applies only to image_backing = \"private\""
627                    .into(),
628            ));
629        }
630        Ok(Self {
631            method: config.injection.method,
632            target,
633            payload_path,
634            allocation: config.guest.allocation,
635            dependency_policy: config.guest.dependency_policy,
636            tls: config.guest.tls,
637            final_protections: config.guest.final_protections,
638            loader_metadata: config.guest.loader_metadata,
639            call_stack: config.guest.call_stack,
640            permission_transitions: config.guest.permission_transitions,
641            thread_starts: config.guest.thread_starts,
642            image_backing: config.guest.image_backing,
643            base_address: config.guest.base_address,
644            header_wipe: config.guest.header_wipe,
645            loader_entries: config.guest.loader_entries,
646            stack_shaping: config.guest.stack_shaping,
647            cleanup: config.guest.cleanup,
648            vad_spoof: config.guest.vad_spoof,
649            target_module: config.guest.target_module.clone(),
650            stage_base: config.guest.stage_base,
651            stage_pattern: match config.guest.stage_pattern.as_deref() {
652                Some(pattern) => Some(parse_hex_pattern(pattern, "guest.stage_pattern")?),
653                None => None,
654            },
655            result_base: config.guest.result_base,
656            result_pattern: match config.guest.result_pattern.as_deref() {
657                Some(pattern) => Some(parse_hex_pattern(pattern, "guest.result_pattern")?),
658                None => None,
659            },
660            hook_module: config.guest.hook_module.clone(),
661            hook_function: config.guest.hook_function.clone(),
662            execution: config.guest.execution.clone(),
663            timeout_ms: config.injection.timeout_ms,
664        })
665    }
666}
667
668#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
669pub struct GuestCapabilities {
670    pub list_processes: bool,
671    pub module_list: bool,
672    pub module_exports: bool,
673    pub read_memory: bool,
674    pub write_memory: bool,
675    pub write_verify: bool,
676    pub memory_map: bool,
677    pub virtual_alloc: bool,
678    pub virtual_free: bool,
679    pub protect_memory: bool,
680    pub iat_hook_execution: bool,
681    pub deterministic_execution: bool,
682    pub thread_context: bool,
683    pub queue_apc: bool,
684    pub create_thread: bool,
685    pub wait_for_result: bool,
686    pub forwarded_exports: bool,
687    pub ordinal_imports: bool,
688    pub delay_imports: bool,
689    pub static_tls: bool,
690    pub exception_registration: bool,
691    pub loader_reference: bool,
692    pub vad_spoof: bool,
693}
694
695impl GuestCapabilities {
696    pub fn memflow_guest_injection() -> Self {
697        Self {
698            list_processes: true,
699            module_list: true,
700            module_exports: true,
701            read_memory: true,
702            write_memory: true,
703            write_verify: true,
704            memory_map: true,
705            virtual_alloc: true,
706            virtual_free: false,
707            protect_memory: false,
708            iat_hook_execution: true,
709            deterministic_execution: false,
710            thread_context: false,
711            queue_apc: false,
712            create_thread: false,
713            wait_for_result: true,
714            forwarded_exports: true,
715            ordinal_imports: true,
716            delay_imports: true,
717            static_tls: false,
718            exception_registration: true,
719            loader_reference: false,
720            vad_spoof: false,
721        }
722    }
723
724    pub fn missing_manual_map(self) -> Vec<&'static str> {
725        let checks = [
726            (self.list_processes, "list-processes"),
727            (self.module_list, "module-list"),
728            (self.read_memory, "read-memory"),
729            (self.write_memory, "write-memory"),
730            (self.write_verify, "write-verify"),
731            (self.memory_map, "memory-map"),
732            (self.virtual_alloc, "virtual-alloc"),
733            (self.iat_hook_execution, "iat-hook-execution"),
734            (self.wait_for_result, "wait-for-result"),
735            (self.forwarded_exports, "forwarded-exports"),
736            (self.ordinal_imports, "ordinal-imports"),
737            (self.delay_imports, "delay-imports"),
738        ];
739        checks
740            .into_iter()
741            .filter_map(|(ok, name)| match ok {
742                true => None,
743                false => Some(name),
744            })
745            .collect()
746    }
747}
748
749#[derive(Clone, Copy, Debug, PartialEq, Eq)]
750pub struct GuestMemoryProtection {
751    pub read: bool,
752    pub write: bool,
753    pub execute: bool,
754}
755
756impl GuestMemoryProtection {
757    pub const READ_WRITE_EXECUTE: Self = Self {
758        read: true,
759        write: true,
760        execute: true,
761    };
762}
763
764#[derive(Clone, Debug, PartialEq, Eq)]
765pub struct GuestProcessInfo {
766    pub pid: u32,
767    pub name: String,
768}
769
770#[derive(Clone, Debug, PartialEq, Eq)]
771pub struct GuestModuleInfo {
772    pub name: String,
773    pub base: u64,
774    pub size: u64,
775}
776
777#[derive(Clone, Copy, Debug, PartialEq, Eq)]
778pub struct GuestMemoryRegion {
779    pub base: u64,
780    pub size: u64,
781    pub readable: bool,
782    pub writable: bool,
783    pub executable: bool,
784}
785
786pub struct GuestInjectionRequest<'a> {
787    pub plan: &'a GuestInjectionPlan,
788    pub payload_path: &'a Path,
789    pub payload_image: &'a [u8],
790}
791
792pub struct GuestLoadInfo {
793    pub method: String,
794    pub pid: u32,
795    pub remote_base: Option<u64>,
796    pub notes: Vec<String>,
797}
798
799#[derive(Debug)]
800pub enum GuestInjectError {
801    Config(String),
802    Backend(String),
803    Process(String),
804    Image(String),
805    Unsupported {
806        operation: &'static str,
807        reason: String,
808    },
809}
810
811impl fmt::Display for GuestInjectError {
812    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
813        match self {
814            GuestInjectError::Config(m) => write!(f, "guest config: {m}"),
815            GuestInjectError::Backend(m) => write!(f, "guest backend: {m}"),
816            GuestInjectError::Process(m) => write!(f, "guest process: {m}"),
817            GuestInjectError::Image(m) => write!(f, "guest image: {m}"),
818            GuestInjectError::Unsupported { operation, reason } => {
819                write!(f, "{operation} unsupported: {reason}")
820            }
821        }
822    }
823}
824
825impl std::error::Error for GuestInjectError {}
826
827impl From<InjectError> for GuestInjectError {
828    fn from(value: InjectError) -> Self {
829        GuestInjectError::Image(value.to_string())
830    }
831}
832
833pub trait GuestMemoryBackend {
834    fn capabilities(&self) -> GuestCapabilities;
835    fn list_processes(&self) -> Result<Vec<GuestProcessInfo>, GuestInjectError>;
836    fn module_list(&self, pid: u32) -> Result<Vec<GuestModuleInfo>, GuestInjectError>;
837    fn module_exports(
838        &self,
839        pid: u32,
840        module: &str,
841    ) -> Result<Vec<(String, u64)>, GuestInjectError>;
842    fn memory_map(&self, pid: u32) -> Result<Vec<GuestMemoryRegion>, GuestInjectError>;
843    fn read(&self, pid: u32, addr: u64, len: usize) -> Result<Vec<u8>, GuestInjectError>;
844    fn write(&self, pid: u32, addr: u64, data: &[u8]) -> Result<(), GuestInjectError>;
845
846    fn call_iat_hook(
847        &self,
848        pid: u32,
849        hook: &GuestIatHook,
850        function: u64,
851        args: [u64; 4],
852        timeout_ms: u32,
853    ) -> Result<u64, GuestInjectError> {
854        memory_iat_call(self, pid, hook, function, args, timeout_ms)
855    }
856
857    fn touch_iat_hook(
858        &self,
859        pid: u32,
860        hook: &GuestIatHook,
861        addr: u64,
862        len: usize,
863        timeout_ms: u32,
864    ) -> Result<(), GuestInjectError> {
865        memory_iat_touch(self, pid, hook, addr, len, timeout_ms)
866    }
867
868    fn read_touch_iat_hook(
869        &self,
870        pid: u32,
871        hook: &GuestIatHook,
872        addr: u64,
873        len: usize,
874        timeout_ms: u32,
875    ) -> Result<(), GuestInjectError> {
876        memory_iat_read_touch(self, pid, hook, addr, len, timeout_ms)
877    }
878
879    fn preserve_touch_iat_hook(
880        &self,
881        pid: u32,
882        hook: &GuestIatHook,
883        addr: u64,
884        len: usize,
885        timeout_ms: u32,
886    ) -> Result<(), GuestInjectError> {
887        memory_iat_preserve_touch(self, pid, hook, addr, len, timeout_ms)
888    }
889
890    fn spoof_vad_type(&self, _pid: u32, _base: u64, _size: u64) -> Result<(), GuestInjectError> {
891        Err(GuestInjectError::Unsupported {
892            operation: "VAD type spoofing",
893            reason: "this backend does not expose kernel memory access".into(),
894        })
895    }
896
897    fn resolve_process(
898        &self,
899        target: &GuestProcessSelector,
900    ) -> Result<GuestProcessInfo, GuestInjectError> {
901        target.validate()?;
902        let processes = self.list_processes()?;
903        tracing::debug!(
904            selector = %target.label(),
905            process_count = processes.len(),
906            "resolving guest process selector"
907        );
908        match (target.pid, target.name.as_deref()) {
909            (Some(pid), _) => processes
910                .into_iter()
911                .find(|p| p.pid == pid)
912                .ok_or_else(|| GuestInjectError::Process(format!("pid {pid} not found")))
913                .and_then(|process| match &target.pattern {
914                    Some(pattern) => match find_process_pattern(self, process.pid, pattern)? {
915                        Some(addr) => {
916                            tracing::info!(
917                                pid = process.pid,
918                                process = %process.name,
919                                pattern_addr = format_args!("{addr:#x}"),
920                                "guest process selector matched pid and pattern"
921                            );
922                            Ok(process)
923                        }
924                        None => Err(GuestInjectError::Process(format!(
925                            "pid {pid} does not contain guest.process_pattern"
926                        ))),
927                    },
928                    None => {
929                        tracing::info!(pid = process.pid, process = %process.name, "guest process selector matched pid");
930                        Ok(process)
931                    }
932                }),
933            (None, Some(name)) => {
934                let candidates = processes
935                    .into_iter()
936                    .filter(|p| p.name.eq_ignore_ascii_case(name))
937                    .collect::<Vec<_>>();
938                tracing::debug!(
939                    name,
940                    candidates = candidates.len(),
941                    pattern = target.pattern.is_some(),
942                    "guest process name candidates collected"
943                );
944                if candidates.is_empty() {
945                    return Err(GuestInjectError::Process(format!(
946                        "process {name:?} not found"
947                    )));
948                }
949                match &target.pattern {
950                    Some(pattern) => {
951                        let mut hits = Vec::new();
952                        for process in candidates {
953                            if let Some(addr) = find_process_pattern(self, process.pid, pattern)? {
954                                tracing::debug!(
955                                    pid = process.pid,
956                                    process = %process.name,
957                                    pattern_addr = format_args!("{addr:#x}"),
958                                    "guest process selector candidate matched pattern"
959                                );
960                                hits.push((process, addr));
961                            }
962                        }
963                        match hits.len() {
964                            0 => Err(GuestInjectError::Process(format!(
965                                "process {name:?} found, but none contained guest.process_pattern"
966                            ))),
967                            1 => {
968                                let (process, addr) = hits.remove(0);
969                                tracing::info!(
970                                    pid = process.pid,
971                                    process = %process.name,
972                                    pattern_addr = format_args!("{addr:#x}"),
973                                    "guest process selector matched name and pattern"
974                                );
975                                Ok(process)
976                            }
977                            _ => Err(GuestInjectError::Process(format!(
978                                "multiple processes named {name:?} contained guest.process_pattern; set guest.pid"
979                            ))),
980                        }
981                    }
982                    None => {
983                        let process = candidates.into_iter().next().unwrap();
984                        tracing::info!(pid = process.pid, process = %process.name, "guest process selector matched name");
985                        Ok(process)
986                    }
987                }
988            }
989            (None, None) => Err(GuestInjectError::Config(
990                "set guest.pid or guest.process for guest injection".into(),
991            )),
992        }
993    }
994}
995
996#[derive(Clone, Copy, Debug)]
997pub struct GuestIatHook {
998    pub iat_slot: u64,
999    pub original_target: u64,
1000    pub stub_addr: u64,
1001    pub result_addr: u64,
1002    pub call_stack: GuestCallStackPolicy,
1003    pub spoofed_return: Option<GuestSpoofedReturn>,
1004}
1005
1006#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1007pub struct GuestSpoofedReturn {
1008    pub gadget_addr: u64,
1009    pub stack_adjust: u8,
1010}
1011
1012struct GuestInjectionLock {
1013    pid: u32,
1014}
1015
1016impl GuestInjectionLock {
1017    fn acquire(pid: u32) -> Result<Self, GuestInjectError> {
1018        let active = ACTIVE_GUEST_INJECTIONS.get_or_init(|| Mutex::new(HashSet::new()));
1019        let mut active = active.lock().unwrap();
1020        if !active.insert(pid) {
1021            return Err(GuestInjectError::Unsupported {
1022                operation: "guest injection",
1023                reason: format!("another guest injection is already active for pid {pid}"),
1024            });
1025        }
1026        Ok(Self { pid })
1027    }
1028}
1029
1030impl Drop for GuestInjectionLock {
1031    fn drop(&mut self) {
1032        if let Some(active) = ACTIVE_GUEST_INJECTIONS.get() {
1033            active.lock().unwrap().remove(&self.pid);
1034        }
1035    }
1036}
1037
1038pub trait GuestInjector {
1039    fn name(&self) -> &str;
1040    fn portability(&self) -> GuestPortability;
1041    fn inject(
1042        &self,
1043        backend: &dyn GuestMemoryBackend,
1044        req: &GuestInjectionRequest<'_>,
1045    ) -> Result<GuestLoadInfo, GuestInjectError>;
1046}
1047
1048pub struct GuestManualMapInjector;
1049
1050impl GuestInjector for GuestManualMapInjector {
1051    fn name(&self) -> &str {
1052        "manual-map"
1053    }
1054
1055    fn portability(&self) -> GuestPortability {
1056        GuestPortability::GuestLoaderInternals
1057    }
1058
1059    fn inject(
1060        &self,
1061        backend: &dyn GuestMemoryBackend,
1062        req: &GuestInjectionRequest<'_>,
1063    ) -> Result<GuestLoadInfo, GuestInjectError> {
1064        tracing::info!(
1065            method = self.name(),
1066            target = %req.plan.target.label(),
1067            payload = %req.payload_path.display(),
1068            payload_bytes = req.payload_image.len(),
1069            allocation = req.plan.allocation.label(),
1070            dependency_policy = req.plan.dependency_policy.label(),
1071            execution = req.plan.execution.method.label(),
1072            tls = req.plan.tls.label(),
1073            final_protections = req.plan.final_protections.label(),
1074            loader_metadata = req.plan.loader_metadata.label(),
1075            call_stack = req.plan.call_stack.label(),
1076            permission_transitions = req.plan.permission_transitions.label(),
1077            thread_starts = req.plan.thread_starts.label(),
1078            image_backing = req.plan.image_backing.label(),
1079            vad_spoof = req.plan.vad_spoof.label(),
1080            hook_module = %req.plan.hook_module,
1081            hook_function = %req.plan.hook_function,
1082            timeout_ms = req.plan.execution.timeout_ms,
1083            "guest injection started"
1084        );
1085
1086        let result = (|| {
1087            if req.payload_image.is_empty() {
1088                return Err(GuestInjectError::Image(
1089                    "payload_image is empty; guest injection requires DLL bytes".into(),
1090                ));
1091            }
1092            let capabilities = backend.capabilities();
1093            let missing = capabilities.missing_manual_map();
1094            if !missing.is_empty() {
1095                return Err(GuestInjectError::Unsupported {
1096                    operation: "guest injection",
1097                    reason: format!("manual-map method backend missing {}", missing.join(", ")),
1098                });
1099            }
1100            if req.plan.vad_spoof == GuestVadSpoof::VadImageMap && !capabilities.vad_spoof {
1101                return Err(GuestInjectError::Unsupported {
1102                    operation: "VAD type spoofing",
1103                    reason: "this backend does not expose validated VAD mutation support".into(),
1104                });
1105            }
1106            tracing::debug!("guest injection backend requirements satisfied");
1107
1108            let process = backend.resolve_process(&req.plan.target)?;
1109            tracing::info!(pid = process.pid, process = %process.name, "guest target resolved");
1110            let _injection_lock = GuestInjectionLock::acquire(process.pid)?;
1111
1112            match req.plan.execution.method {
1113                GuestExecutionMethod::IatHook | GuestExecutionMethod::RemoteThread => {}
1114                other => {
1115                    return Err(GuestInjectError::Unsupported {
1116                        operation: "guest injection",
1117                        reason: format!(
1118                            "guest execution method {} is not implemented",
1119                            other.label()
1120                        ),
1121                    });
1122                }
1123            }
1124
1125            let stage = match req.plan.stage_base {
1126                Some(base) => {
1127                    validate_stub_region(backend, process.pid, base, STAGE_CAVE_SIZE)?;
1128                    tracing::info!(
1129                        pid = process.pid,
1130                        stage = format_args!("{base:#x}"),
1131                        "using configured guest stage"
1132                    );
1133                    base
1134                }
1135                None => {
1136                    tracing::debug!(pid = process.pid, "searching for guest execution stage");
1137                    let found = find_stage(backend, process.pid, req.plan.stage_pattern.as_ref())?;
1138                    validate_stub_region(backend, process.pid, found, STAGE_CAVE_SIZE)?;
1139                    tracing::info!(
1140                        pid = process.pid,
1141                        stage = format_args!("{found:#x}"),
1142                        "guest execution stage selected"
1143                    );
1144                    found
1145                }
1146            };
1147            let result_addr = match req.plan.result_base {
1148                Some(base) => {
1149                    validate_result_region(backend, process.pid, base)?;
1150                    tracing::info!(
1151                        pid = process.pid,
1152                        result = format_args!("{base:#x}"),
1153                        "using configured guest result block"
1154                    );
1155                    base
1156                }
1157                None => {
1158                    let found = find_result_block(
1159                        backend,
1160                        process.pid,
1161                        req.plan.result_pattern.as_ref(),
1162                        stage + STAGE_RESULT_OFFSET,
1163                    )?;
1164                    validate_result_region(backend, process.pid, found)?;
1165                    tracing::info!(
1166                        pid = process.pid,
1167                        result = format_args!("{found:#x}"),
1168                        "guest result block selected"
1169                    );
1170                    found
1171                }
1172            };
1173
1174            let hook = find_iat_hook(backend, process.pid, req.plan)?;
1175            let hook = GuestIatHook {
1176                iat_slot: hook.iat_slot,
1177                original_target: hook.original_target,
1178                stub_addr: stage + STAGE_STUB_OFFSET,
1179                result_addr,
1180                call_stack: req.plan.call_stack,
1181                spoofed_return: if req.plan.stack_shaping == GuestStackShaping::Spoofed {
1182                    Some(find_spoofed_return(backend, process.pid)?)
1183                } else {
1184                    None
1185                },
1186            };
1187            tracing::info!(
1188                pid = process.pid,
1189                iat_slot = format_args!("{:#x}", hook.iat_slot),
1190                original_target = format_args!("{:#x}", hook.original_target),
1191                stub_addr = format_args!("{:#x}", hook.stub_addr),
1192                result_addr = format_args!("{:#x}", hook.result_addr),
1193                hook_module = %req.plan.hook_module,
1194                hook_function = %req.plan.hook_function,
1195                "guest IAT hook selected"
1196            );
1197            let mut thread_start_notes =
1198                validate_guest_thread_start_policy(backend, process.pid, req.plan, stage, &hook)?;
1199            let mut call_stack_notes = Vec::new();
1200            if req.plan.call_stack == GuestCallStackPolicy::RegisteredUnwind {
1201                register_guest_stub_unwind(
1202                    backend,
1203                    process.pid,
1204                    &hook,
1205                    stage,
1206                    req.plan.execution.timeout_ms,
1207                )?;
1208                call_stack_notes
1209                    .push("call stack: registered unwind metadata for IAT-hook stub".to_string());
1210            }
1211
1212            let virtual_alloc =
1213                resolve_import_symbol(backend, process.pid, "kernel32.dll", "VirtualAlloc")?;
1214            tracing::info!(
1215                pid = process.pid,
1216                virtual_alloc = format_args!("{virtual_alloc:#x}"),
1217                "guest allocator resolved"
1218            );
1219            let virtual_protect = match req.plan.final_protections {
1220                GuestFinalProtections::Rwx => None,
1221                GuestFinalProtections::Section => {
1222                    let addr = resolve_import_symbol(
1223                        backend,
1224                        process.pid,
1225                        "kernel32.dll",
1226                        "VirtualProtect",
1227                    )?;
1228                    tracing::info!(
1229                        pid = process.pid,
1230                        virtual_protect = format_args!("{addr:#x}"),
1231                        "guest final section protector resolved"
1232                    );
1233                    Some(addr)
1234                }
1235            };
1236            let loader_apis = match req.plan.dependency_policy {
1237                GuestDependencyPolicy::RequireLoaded => None,
1238                GuestDependencyPolicy::LoadWithGuestLoader => {
1239                    let load_library = resolve_import_symbol(
1240                        backend,
1241                        process.pid,
1242                        "kernel32.dll",
1243                        "LoadLibraryA",
1244                    )?;
1245                    let get_proc_address = resolve_import_symbol(
1246                        backend,
1247                        process.pid,
1248                        "kernel32.dll",
1249                        "GetProcAddress",
1250                    )?;
1251                    tracing::info!(
1252                        pid = process.pid,
1253                        load_library = format_args!("{load_library:#x}"),
1254                        get_proc_address = format_args!("{get_proc_address:#x}"),
1255                        "guest dependency loader APIs resolved"
1256                    );
1257                    Some(GuestLoaderApis {
1258                        load_library,
1259                        get_proc_address,
1260                    })
1261                }
1262            };
1263
1264            let pe = Pe::parse(req.payload_image)?;
1265            tracing::info!(
1266                image_base = format_args!("{:#x}", pe.image_base),
1267                entry_rva = format_args!("{:#x}", pe.entry_rva),
1268                size_of_image = pe.size_of_image,
1269                sections = pe.sections.len(),
1270                "payload PE parsed"
1271            );
1272            let allocation_protection = initial_allocation_protection(req.plan, &pe);
1273            let read_touch_materialization = req.plan.permission_transitions
1274                == GuestPermissionTransitions::WriteThroughFinal
1275                && !protection_is_writable(allocation_protection);
1276
1277            let (remote_base, materialize_allocated_pages) =
1278                if req.plan.image_backing == GuestImageBacking::SecImage {
1279                    tracing::info!(
1280                        pid = process.pid,
1281                        "mapping payload through a guest SEC_IMAGE file-backed section"
1282                    );
1283                    let base = allocate_sec_image(
1284                        backend,
1285                        process.pid,
1286                        &hook,
1287                        virtual_alloc,
1288                        stage,
1289                        req.payload_image,
1290                        req.plan.execution.timeout_ms,
1291                    )?;
1292                    (base, false)
1293                } else {
1294                    match req.plan.allocation {
1295                        GuestAllocationMethod::VirtualAlloc => {
1296                            let remote = allocate_virtual(
1297                                backend,
1298                                process.pid,
1299                                &hook,
1300                                virtual_alloc,
1301                                &pe,
1302                                allocation_protection,
1303                                req.plan.base_address,
1304                                req.plan.execution.timeout_ms,
1305                            )?;
1306                            (remote, true)
1307                        }
1308                        GuestAllocationMethod::ExistingRegion => match req.plan.stage_base {
1309                            Some(base) => {
1310                                let remote = base + 0x1000;
1311                                tracing::info!(
1312                                    pid = process.pid,
1313                                    remote_base = format_args!("{remote:#x}"),
1314                                    "using configured guest existing region"
1315                                );
1316                                (remote, false)
1317                            }
1318                            None => {
1319                                return Err(GuestInjectError::Config(
1320                                "guest allocation = \"existing-region\" requires guest.stage_base"
1321                                    .into(),
1322                            ));
1323                            }
1324                        },
1325                        GuestAllocationMethod::PageTableMap => {
1326                            return Err(GuestInjectError::Unsupported {
1327                            operation: "guest page-table-map",
1328                            reason:
1329                                "use allocation = \"virtual-alloc\" for the in-process allocator"
1330                                    .into(),
1331                        });
1332                        }
1333                    }
1334                };
1335            if remote_base == 0 {
1336                return Err(GuestInjectError::Backend(
1337                    "guest image allocation returned NULL".into(),
1338                ));
1339            }
1340            tracing::info!(
1341                pid = process.pid,
1342                remote_base = format_args!("{remote_base:#x}"),
1343                "guest payload memory allocated"
1344            );
1345            if materialize_allocated_pages {
1346                match read_touch_materialization {
1347                    true => backend.read_touch_iat_hook(
1348                        process.pid,
1349                        &hook,
1350                        remote_base,
1351                        pe.size_of_image,
1352                        req.plan.execution.timeout_ms,
1353                    )?,
1354                    false => backend.touch_iat_hook(
1355                        process.pid,
1356                        &hook,
1357                        remote_base,
1358                        pe.size_of_image,
1359                        req.plan.execution.timeout_ms,
1360                    )?,
1361                }
1362                tracing::info!(
1363                    pid = process.pid,
1364                    remote_base = format_args!("{remote_base:#x}"),
1365                    bytes = pe.size_of_image,
1366                    read_touch = read_touch_materialization,
1367                    "guest payload pages materialized"
1368                );
1369            }
1370
1371            let (mut image, sec_image_snapshot) =
1372                if req.plan.image_backing == GuestImageBacking::SecImage {
1373                    let snap = backend.read(process.pid, remote_base, pe.size_of_image)?;
1374                    let local_layout = pe.mapped_image(req.payload_image)?;
1375                    tracing::debug!(
1376                        pid = process.pid,
1377                        remote_base = format_args!("{remote_base:#x}"),
1378                        mapped_bytes = snap.len(),
1379                        "SEC_IMAGE view read back from guest for patch comparison"
1380                    );
1381                    tracing::debug!(
1382                        mapped_bytes = local_layout.len(),
1383                        "payload image laid out in local buffer"
1384                    );
1385                    (local_layout, Some(snap))
1386                } else {
1387                    let view = pe.mapped_image(req.payload_image)?;
1388                    tracing::debug!(
1389                        mapped_bytes = view.len(),
1390                        "payload image laid out in local buffer"
1391                    );
1392                    (view, None)
1393                };
1394            pe.apply_relocs(&mut image, remote_base)?;
1395            tracing::debug!(
1396                remote_base = format_args!("{remote_base:#x}"),
1397                "payload relocations applied"
1398            );
1399            let mut loader_metadata_notes = Vec::new();
1400            if req.plan.loader_metadata.registers_public_metadata() {
1401                let image_len = image.len();
1402                match pe.seed_security_cookie(
1403                    &mut image,
1404                    remote_base,
1405                    loader_security_cookie(process.pid, remote_base, image_len),
1406                )? {
1407                    Some(cookie) => {
1408                        tracing::info!(
1409                            pid = process.pid,
1410                            remote_base = format_args!("{remote_base:#x}"),
1411                            cookie = format_args!("{cookie:#x}"),
1412                            "payload load-config security cookie seeded"
1413                        );
1414                        loader_metadata_notes.push(
1415                            "loader metadata: seeded load-config security cookie".to_string(),
1416                        );
1417                    }
1418                    None if pe.has_load_config() => {
1419                        tracing::debug!(
1420                            pid = process.pid,
1421                            "payload load-config has no default security cookie to seed"
1422                        );
1423                    }
1424                    None => {}
1425                }
1426            }
1427
1428            let dependency_scratch = stage + STAGE_SCRATCH_OFFSET;
1429            let mut resolve_payload_import = |module: &[u8],
1430                                              symbol: ImportSymbol<'_>|
1431             -> Result<usize, GuestInjectError> {
1432                let module = std::str::from_utf8(module)
1433                    .map_err(|e| GuestInjectError::Image(format!("import module name: {e}")))?;
1434                match symbol {
1435                    ImportSymbol::Name(name) => {
1436                        let name = std::str::from_utf8(name)
1437                            .map_err(|e| GuestInjectError::Image(format!("import name: {e}")))?;
1438                        let addr = resolve_import_symbol_with_dependency_policy(
1439                            backend,
1440                            process.pid,
1441                            &hook,
1442                            loader_apis,
1443                            dependency_scratch,
1444                            STAGE_SCRATCH_SIZE,
1445                            req.plan.execution.timeout_ms,
1446                            req.plan.dependency_policy,
1447                            module,
1448                            ImportSymbol::Name(name.as_bytes()),
1449                        )?;
1450                        tracing::debug!(
1451                            pid = process.pid,
1452                            module,
1453                            symbol = name,
1454                            address = format_args!("{addr:#x}"),
1455                            "payload import resolved"
1456                        );
1457                        Ok(addr as usize)
1458                    }
1459                    ImportSymbol::Ordinal(ordinal) => {
1460                        let addr = resolve_import_symbol_with_dependency_policy(
1461                            backend,
1462                            process.pid,
1463                            &hook,
1464                            loader_apis,
1465                            dependency_scratch,
1466                            STAGE_SCRATCH_SIZE,
1467                            req.plan.execution.timeout_ms,
1468                            req.plan.dependency_policy,
1469                            module,
1470                            ImportSymbol::Ordinal(ordinal),
1471                        )?;
1472                        tracing::debug!(
1473                            pid = process.pid,
1474                            module,
1475                            ordinal,
1476                            address = format_args!("{addr:#x}"),
1477                            "payload ordinal import resolved"
1478                        );
1479                        Ok(addr as usize)
1480                    }
1481                }
1482            };
1483            pe.resolve_imports(&mut image, &mut resolve_payload_import)?;
1484            tracing::info!("payload imports resolved");
1485            pe.resolve_delay_imports(&mut image, &mut resolve_payload_import)?;
1486            tracing::info!("payload delay imports resolved");
1487            let has_static_tls = pe.has_static_tls(&image)?;
1488            let mut tls_slot_index: Option<u32> = None;
1489            match (req.plan.tls, has_static_tls) {
1490                (GuestTlsMode::RequireStatic, _) => {
1491                    return Err(GuestInjectError::Unsupported {
1492                        operation: "guest static TLS",
1493                        reason: "static TLS registration requires loader-managed TLS slots; use tls = \"callbacks-only\" or tls = \"skip\" with the current backend".into(),
1494                    });
1495                }
1496                (_, true)
1497                    if !req.plan.loader_metadata.allows_unregistered_metadata()
1498                        && req.plan.loader_entries != GuestLoaderEntries::Synthesized =>
1499                {
1500                    return Err(GuestInjectError::Unsupported {
1501                        operation: "guest static TLS",
1502                        reason: "payload uses static TLS; guest injection can call TLS callbacks but cannot register loader-managed TLS slots. Rebuild without static TLS or set guest.loader_metadata = \"best-effort\"/\"allow-unsupported\" only when the payload does not read static TLS".into(),
1503                    });
1504                }
1505                (GuestTlsMode::CallbacksOnly, true)
1506                    if req.plan.loader_entries == GuestLoaderEntries::Synthesized =>
1507                {
1508                    let tls_alloc =
1509                        resolve_import_symbol(backend, process.pid, "kernel32.dll", "TlsAlloc")?;
1510                    let tls_set_value =
1511                        resolve_import_symbol(backend, process.pid, "kernel32.dll", "TlsSetValue")?;
1512                    let raw_slot = backend.call_iat_hook(
1513                        process.pid,
1514                        &hook,
1515                        tls_alloc,
1516                        [0, 0, 0, 0],
1517                        req.plan.execution.timeout_ms,
1518                    )?;
1519                    let slot = raw_slot as u32;
1520                    if slot == u32::MAX {
1521                        return Err(GuestInjectError::Backend(
1522                            "guest TlsAlloc returned TLS_OUT_OF_INDEXES".into(),
1523                        ));
1524                    }
1525                    if let Some(index_offset) = pe.tls_index_offset(&image, remote_base as usize)? {
1526                        image[index_offset..index_offset + 4].copy_from_slice(&slot.to_le_bytes());
1527                        tracing::info!(
1528                            pid = process.pid,
1529                            tls_slot = slot,
1530                            index_offset = format_args!("{index_offset:#x}"),
1531                            "static TLS slot allocated; index patched into local image buffer"
1532                        );
1533                        tls_slot_index = Some(slot);
1534                        if let Some(template) = pe.tls_template(&image, remote_base as usize)? {
1535                            let template_buf = backend.call_iat_hook(
1536                                process.pid,
1537                                &hook,
1538                                virtual_alloc,
1539                                [0, template.len() as u64, MEM_COMMIT_RESERVE, PAGE_READWRITE],
1540                                req.plan.execution.timeout_ms,
1541                            )?;
1542                            if template_buf != 0 {
1543                                backend.touch_iat_hook(
1544                                    process.pid,
1545                                    &hook,
1546                                    template_buf,
1547                                    template.len(),
1548                                    req.plan.execution.timeout_ms,
1549                                )?;
1550                                write_verified(
1551                                    backend,
1552                                    process.pid,
1553                                    template_buf,
1554                                    &template,
1555                                    "TLS template copy",
1556                                )?;
1557                                let set_ok = backend.call_iat_hook(
1558                                    process.pid,
1559                                    &hook,
1560                                    tls_set_value,
1561                                    [slot as u64, template_buf, 0, 0],
1562                                    req.plan.execution.timeout_ms,
1563                                )?;
1564                                if set_ok == 0 {
1565                                    tracing::warn!(
1566                                        pid = process.pid,
1567                                        slot,
1568                                        "guest TlsSetValue returned FALSE; static TLS template not installed for the current helper/target thread"
1569                                    );
1570                                } else {
1571                                    tracing::info!(
1572                                        pid = process.pid,
1573                                        slot,
1574                                        template_buf = format_args!("{template_buf:#x}"),
1575                                        template_len = template.len(),
1576                                        "static TLS template copied and TlsSetValue called for the current helper thread"
1577                                    );
1578                                    if req.plan.execution.method
1579                                        == GuestExecutionMethod::RemoteThread
1580                                    {
1581                                        tracing::warn!(
1582                                            pid = process.pid,
1583                                            slot,
1584                                            "remote-thread DllMain runs on a new thread; static TLS template is not propagated to that thread"
1585                                        );
1586                                    }
1587                                }
1588                            }
1589                        }
1590                    }
1591                }
1592                (GuestTlsMode::CallbacksOnly, true) => {
1593                    tracing::warn!(
1594                        pid = process.pid,
1595                        "payload uses static TLS; guest injection will call TLS callbacks without registering loader-managed TLS slots"
1596                    );
1597                }
1598                (GuestTlsMode::Skip, _) | (GuestTlsMode::CallbacksOnly, false) => {}
1599            }
1600
1601            match &sec_image_snapshot {
1602                Some(snap) => {
1603                    let vp = virtual_protect.expect(
1604                        "sec-image requires final_protections = section so VirtualProtect is resolved",
1605                    );
1606                    let mut patched_pages = 0u64;
1607                    let patched_ranges = sec_image_patched_ranges(&image, snap);
1608                    for (range_start, range_end) in &patched_ranges {
1609                        guest_virtual_protect(
1610                            backend,
1611                            process.pid,
1612                            &hook,
1613                            vp,
1614                            remote_base + *range_start as u64,
1615                            (range_end - range_start) as u64,
1616                            PAGE_READWRITE,
1617                            hook.result_addr + OLD_PROTECT_RESULT_OFFSET,
1618                            req.plan.execution.timeout_ms,
1619                            "SEC_IMAGE patched range",
1620                        )?;
1621                        backend.preserve_touch_iat_hook(
1622                            process.pid,
1623                            &hook,
1624                            remote_base + *range_start as u64,
1625                            range_end - range_start,
1626                            req.plan.execution.timeout_ms,
1627                        )?;
1628                        write_verified(
1629                            backend,
1630                            process.pid,
1631                            remote_base + *range_start as u64,
1632                            &image[*range_start..*range_end],
1633                            "SEC_IMAGE patched range",
1634                        )?;
1635                        patched_pages +=
1636                            ((range_end - range_start).div_ceil(GUEST_PAGE_SIZE)) as u64;
1637                    }
1638                    tracing::info!(
1639                        pid = process.pid,
1640                        remote_base = format_args!("{remote_base:#x}"),
1641                        patched_pages,
1642                        patched_ranges = patched_ranges.len(),
1643                        total_pages = image.len() as u64 / GUEST_PAGE_SIZE as u64,
1644                        "SEC_IMAGE patched pages written; unpatched pages remain file-backed"
1645                    );
1646                }
1647                None => {
1648                    write_verified(
1649                        backend,
1650                        process.pid,
1651                        remote_base,
1652                        &image,
1653                        "guest mapped payload image",
1654                    )?;
1655                    tracing::info!(
1656                        pid = process.pid,
1657                        remote_base = format_args!("{remote_base:#x}"),
1658                        bytes = image.len(),
1659                        "payload image written to guest"
1660                    );
1661                }
1662            }
1663
1664            if pe.has_exception_directory() {
1665                match req.plan.loader_metadata {
1666                    GuestLoaderMetadataPolicy::RejectUnsupported => {
1667                        return Err(GuestInjectError::Unsupported {
1668                            operation: "guest exception metadata",
1669                            reason: "payload has an exception directory; set guest.loader_metadata = \"best-effort\" to register the x64 runtime function table or \"allow-unsupported\" only when the payload does not unwind across the mapped image".into(),
1670                        });
1671                    }
1672                    GuestLoaderMetadataPolicy::BestEffort => {
1673                        register_guest_exception_table(
1674                            backend,
1675                            process.pid,
1676                            &hook,
1677                            remote_base,
1678                            &pe,
1679                            req.plan.execution.timeout_ms,
1680                        )?;
1681                        loader_metadata_notes.push(
1682                            "loader metadata: registered x64 runtime function table".to_string(),
1683                        );
1684                    }
1685                    GuestLoaderMetadataPolicy::AllowUnsupported => {
1686                        tracing::warn!(
1687                            pid = process.pid,
1688                            "payload has an exception directory; guest injection did not register unwind data"
1689                        );
1690                    }
1691                }
1692            }
1693            if pe.has_load_config() {
1694                match req.plan.loader_metadata {
1695                    GuestLoaderMetadataPolicy::RejectUnsupported => {
1696                        return Err(GuestInjectError::Unsupported {
1697                            operation: "guest load-config metadata",
1698                            reason: "payload has a load-config directory; set guest.loader_metadata = \"best-effort\" to seed the security cookie when present or \"allow-unsupported\" only when load-config entries are inert for the payload".into(),
1699                        });
1700                    }
1701                    GuestLoaderMetadataPolicy::BestEffort => {
1702                        tracing::debug!(
1703                            pid = process.pid,
1704                            "payload load-config was processed with available public metadata hooks; broader loader-private entries are not synthesized"
1705                        );
1706                    }
1707                    GuestLoaderMetadataPolicy::AllowUnsupported => {
1708                        tracing::debug!(
1709                            pid = process.pid,
1710                            "payload has a load-config directory; security-cookie/CFG metadata is left as mapped"
1711                        );
1712                    }
1713                }
1714            }
1715
1716            if let Some(virtual_protect) = virtual_protect {
1717                protect_guest_sections(
1718                    backend,
1719                    process.pid,
1720                    &hook,
1721                    virtual_protect,
1722                    remote_base,
1723                    &pe,
1724                    protect_skip_initial(req.plan, allocation_protection),
1725                    req.plan.execution.timeout_ms,
1726                )?;
1727            }
1728
1729            if req.plan.tls == GuestTlsMode::Skip {
1730                tracing::info!("payload TLS callbacks skipped by config");
1731            } else {
1732                let callbacks = pe.tls_callbacks(&image, remote_base as usize)?;
1733                tracing::info!(
1734                    count = callbacks.len(),
1735                    "payload TLS callback list prepared"
1736                );
1737                for callback in callbacks {
1738                    tracing::info!(
1739                        pid = process.pid,
1740                        callback = format_args!("{callback:#x}"),
1741                        "calling payload TLS callback"
1742                    );
1743                    let _ = backend.call_iat_hook(
1744                        process.pid,
1745                        &hook,
1746                        callback as u64,
1747                        [remote_base, DLL_PROCESS_ATTACH as u64, 0, 0],
1748                        req.plan.execution.timeout_ms,
1749                    )?;
1750                }
1751            }
1752
1753            let mut peb_entry_addr: Option<u64> = None;
1754            let mut peb_entry_unlinked = false;
1755            let mut cfg_marked = false;
1756            let mut cfg_target_count = 0u32;
1757            if req.plan.loader_entries == GuestLoaderEntries::Synthesized {
1758                if should_request_cfg_call_target(req.plan, &pe)
1759                    && let Ok(set_valid_call_targets) = resolve_import_symbol(
1760                        backend,
1761                        process.pid,
1762                        "kernel32.dll",
1763                        "SetProcessValidCallTargets",
1764                    )
1765                {
1766                    let targets = pe.cfg_call_targets(&image)?;
1767                    cfg_target_count = targets.len() as u32;
1768                    if !targets.is_empty() {
1769                        let info_bytes = targets.len() * 16;
1770                        let cfg_info = backend.call_iat_hook(
1771                            process.pid,
1772                            &hook,
1773                            virtual_alloc,
1774                            [0, info_bytes as u64, MEM_COMMIT_RESERVE, PAGE_READWRITE],
1775                            req.plan.execution.timeout_ms,
1776                        )?;
1777                        if cfg_info != 0 {
1778                            backend.touch_iat_hook(
1779                                process.pid,
1780                                &hook,
1781                                cfg_info,
1782                                info_bytes,
1783                                req.plan.execution.timeout_ms,
1784                            )?;
1785                            let mut info = vec![0u8; info_bytes];
1786                            for (i, &rva) in targets.iter().enumerate() {
1787                                let off = i * 16;
1788                                info[off..off + 8].copy_from_slice(&(rva as u64).to_le_bytes());
1789                                info[off + 8..off + 16]
1790                                    .copy_from_slice(&CFG_CALL_TARGET_VALID.to_le_bytes());
1791                            }
1792                            write_verified(
1793                                backend,
1794                                process.pid,
1795                                cfg_info,
1796                                &info,
1797                                "CFG_CALL_TARGET_INFO array",
1798                            )?;
1799                            let ok = call_guest_proc(
1800                                backend,
1801                                process.pid,
1802                                &hook,
1803                                stage + STAGE_TRAMPOLINE_OFFSET,
1804                                stage + STAGE_PARAM_OFFSET,
1805                                set_valid_call_targets,
1806                                &cfg_call_target_registration_args(
1807                                    remote_base,
1808                                    pe.size_of_image as u64,
1809                                    targets.len() as u64,
1810                                    cfg_info,
1811                                ),
1812                                req.plan.execution.timeout_ms,
1813                                "SetProcessValidCallTargets",
1814                            )?;
1815                            if let Ok(virtual_free) = resolve_import_symbol(
1816                                backend,
1817                                process.pid,
1818                                "kernel32.dll",
1819                                "VirtualFree",
1820                            ) {
1821                                let _ = backend.call_iat_hook(
1822                                    process.pid,
1823                                    &hook,
1824                                    virtual_free,
1825                                    [cfg_info, 0, MEM_RELEASE, 0],
1826                                    req.plan.execution.timeout_ms,
1827                                );
1828                            }
1829                            if ok != 0 {
1830                                cfg_marked = true;
1831                                tracing::info!(
1832                                    pid = process.pid,
1833                                    remote_base = format_args!("{remote_base:#x}"),
1834                                    target_count = targets.len(),
1835                                    "CFG: marked all export/entry targets as valid indirect call targets"
1836                                );
1837                            } else {
1838                                tracing::debug!(
1839                                    pid = process.pid,
1840                                    "CFG: SetProcessValidCallTargets returned FALSE; process may not have CFG enabled"
1841                                );
1842                            }
1843                        }
1844                    }
1845                }
1846                let dll_name = req
1847                    .payload_path
1848                    .file_name()
1849                    .and_then(|n| n.to_str())
1850                    .unwrap_or("payload.dll");
1851                let entry_point = pe
1852                    .entry(remote_base as usize)?
1853                    .map(|e| e as u64)
1854                    .unwrap_or(0);
1855                peb_entry_addr = Some(synthesize_peb_loader_entry(
1856                    backend,
1857                    process.pid,
1858                    &hook,
1859                    virtual_alloc,
1860                    stage,
1861                    remote_base,
1862                    entry_point,
1863                    pe.size_of_image as u32,
1864                    dll_name,
1865                    req.plan.execution.timeout_ms,
1866                )?);
1867                if req.plan.cleanup == GuestCleanup::Tracked
1868                    && let Some(peb_entry) = peb_entry_addr
1869                {
1870                    unlink_synthesized_peb_loader_entry(backend, process.pid, peb_entry)?;
1871                    peb_entry_unlinked = true;
1872                }
1873            }
1874
1875            if req.plan.vad_spoof == GuestVadSpoof::VadImageMap {
1876                if req.plan.image_backing != GuestImageBacking::Private {
1877                    return Err(GuestInjectError::Unsupported {
1878                        operation: "VAD type spoofing",
1879                        reason: "vad-image-map applies only to private image backing".into(),
1880                    });
1881                }
1882                backend.spoof_vad_type(process.pid, remote_base, pe.size_of_image as u64)?;
1883                tracing::info!(
1884                    pid = process.pid,
1885                    remote_base = format_args!("{remote_base:#x}"),
1886                    "VAD type spoofed to VadImageMap for private mapping"
1887                );
1888            }
1889
1890            if let Some(entry) = pe.entry(remote_base as usize)? {
1891                tracing::info!(
1892                    pid = process.pid,
1893                    entry = format_args!("{entry:#x}"),
1894                    execution = req.plan.execution.method.label(),
1895                    "calling payload DllMain"
1896                );
1897                let ok = match req.plan.execution.method {
1898                    GuestExecutionMethod::RemoteThread => invoke_dllmain_remote_thread(
1899                        backend,
1900                        process.pid,
1901                        &hook,
1902                        virtual_alloc,
1903                        stage,
1904                        entry as u64,
1905                        remote_base,
1906                        &image,
1907                        &pe,
1908                        req.plan.thread_starts == GuestThreadStartPolicy::RequireModuleBacked,
1909                        req.plan.execution.timeout_ms,
1910                    )?,
1911                    _ => backend.call_iat_hook(
1912                        process.pid,
1913                        &hook,
1914                        entry as u64,
1915                        [remote_base, DLL_PROCESS_ATTACH as u64, 0, 0],
1916                        req.plan.execution.timeout_ms,
1917                    )?,
1918                };
1919                tracing::info!(
1920                    pid = process.pid,
1921                    entry_result = ok,
1922                    "payload DllMain returned"
1923                );
1924                if ok == 0 {
1925                    return Err(GuestInjectError::Image("DllMain returned FALSE".into()));
1926                }
1927            } else {
1928                tracing::info!("payload has no entry point");
1929            }
1930
1931            if req.plan.header_wipe == GuestHeaderWipe::AfterLoad {
1932                let wipe_len = pe.size_of_headers.min(0x1000);
1933                let zeros = vec![0u8; wipe_len];
1934                if req.plan.image_backing == GuestImageBacking::SecImage {
1935                    let vp = virtual_protect.expect(
1936                        "header wipe requires final_protections = section so VirtualProtect is resolved",
1937                    );
1938                    guest_virtual_protect(
1939                        backend,
1940                        process.pid,
1941                        &hook,
1942                        vp,
1943                        remote_base,
1944                        wipe_len as u64,
1945                        PAGE_READWRITE,
1946                        hook.result_addr + OLD_PROTECT_RESULT_OFFSET,
1947                        req.plan.execution.timeout_ms,
1948                        "header wipe",
1949                    )?;
1950                }
1951                write_verified(backend, process.pid, remote_base, &zeros, "PE header wipe")?;
1952                tracing::info!(
1953                    pid = process.pid,
1954                    remote_base = format_args!("{remote_base:#x}"),
1955                    bytes = wipe_len,
1956                    "PE headers wiped"
1957                );
1958            }
1959
1960            let mut notes = vec![
1961                format!(
1962                    "manual-mapped {} bytes from {}",
1963                    req.payload_image.len(),
1964                    req.payload_path.display()
1965                ),
1966                format!(
1967                    "execution via {}!{} IAT slot {:#x}",
1968                    req.plan.hook_module, req.plan.hook_function, hook.iat_slot
1969                ),
1970                format!(
1971                    "dependencies={}, tls={}, final_protections={}",
1972                    req.plan.dependency_policy.label(),
1973                    req.plan.tls.label(),
1974                    req.plan.final_protections.label()
1975                ),
1976                format!("loader_metadata={}", req.plan.loader_metadata.label()),
1977                format!("call_stack={}", req.plan.call_stack.label()),
1978                format!(
1979                    "permission_transitions={}",
1980                    req.plan.permission_transitions.label()
1981                ),
1982                format!("thread_starts={}", req.plan.thread_starts.label()),
1983                format!("image_backing={}", req.plan.image_backing.label()),
1984                format!("base_address={}", req.plan.base_address.label()),
1985                format!("header_wipe={}", req.plan.header_wipe.label()),
1986                format!("loader_entries={}", req.plan.loader_entries.label()),
1987                format!("stack_shaping={}", req.plan.stack_shaping.label()),
1988                format!("cleanup={}", req.plan.cleanup.label()),
1989                format!("vad_spoof={}", req.plan.vad_spoof.label()),
1990            ];
1991            notes.append(&mut loader_metadata_notes);
1992            notes.append(&mut call_stack_notes);
1993            notes.append(&mut thread_start_notes);
1994            if let Some(peb_entry) = peb_entry_addr {
1995                match peb_entry_unlinked {
1996                    true => notes.push(format!(
1997                        "loader entries: synthesized transient LDR_DATA_TABLE_ENTRY at {peb_entry:#x}, then unlinked from PEB InLoadOrder/InMemoryOrder lists by cleanup=tracked"
1998                    )),
1999                    false => notes.push(format!(
2000                        "loader entries: synthesized LDR_DATA_TABLE_ENTRY at {peb_entry:#x} linked into PEB InLoadOrder/InMemoryOrder lists"
2001                    )),
2002                }
2003            }
2004            if let Some(slot) = tls_slot_index {
2005                let thread_scope = match req.plan.execution.method {
2006                    GuestExecutionMethod::RemoteThread => {
2007                        "current helper thread only; the remote DllMain thread and other existing threads are not covered"
2008                    }
2009                    _ => "current target thread only; other threads are not covered",
2010                };
2011                notes.push(format!(
2012                    "static TLS: allocated slot {slot} via TlsAlloc, patched index into image, copied TLS template, and called TlsSetValue for {thread_scope}"
2013                ));
2014            }
2015            if cfg_marked {
2016                notes.push(format!(
2017                    "CFG: marked {cfg_target_count} export/entry targets as valid indirect call targets via SetProcessValidCallTargets before DllMain"
2018                ));
2019            }
2020            notes.extend(guest_artifact_notes(req, &pe, remote_base, has_static_tls));
2021
2022            Ok(GuestLoadInfo {
2023                method: self.name().into(),
2024                pid: process.pid,
2025                remote_base: Some(remote_base),
2026                notes,
2027            })
2028        })();
2029
2030        match &result {
2031            Ok(info) => tracing::info!(
2032                pid = info.pid,
2033                remote_base = info.remote_base.map(|base| format!("{base:#x}")).as_deref(),
2034                "guest injection completed"
2035            ),
2036            Err(err) => tracing::error!(error = %err, "guest injection failed"),
2037        }
2038        result
2039    }
2040}
2041
2042struct RemoteIatHook {
2043    iat_slot: u64,
2044    original_target: u64,
2045}
2046
2047struct IatHookTransaction<'a, B: GuestMemoryBackend + ?Sized> {
2048    backend: &'a B,
2049    pid: u32,
2050    hook: GuestIatHook,
2051    original_iat: [u8; 8],
2052    original_stub: Vec<u8>,
2053    original_result: Vec<u8>,
2054    restored: bool,
2055}
2056
2057impl<'a, B: GuestMemoryBackend + ?Sized> IatHookTransaction<'a, B> {
2058    fn prepare(
2059        backend: &'a B,
2060        pid: u32,
2061        hook: &GuestIatHook,
2062        stub_len: usize,
2063    ) -> Result<Self, GuestInjectError> {
2064        let current_iat = backend.read(pid, hook.iat_slot, 8)?;
2065        let expected_iat = hook.original_target.to_le_bytes();
2066        if current_iat != expected_iat {
2067            return Err(GuestInjectError::Unsupported {
2068                operation: "guest iat-hook execution",
2069                reason: format!(
2070                    "IAT slot {:#x} changed before arming; refusing nested or stale hook",
2071                    hook.iat_slot
2072                ),
2073            });
2074        }
2075        let original_stub = backend.read(pid, hook.stub_addr, stub_len)?;
2076        let original_result = backend.read(pid, hook.result_addr, RESULT_BLOCK_SIZE)?;
2077        Ok(Self {
2078            backend,
2079            pid,
2080            hook: *hook,
2081            original_iat: expected_iat,
2082            original_stub,
2083            original_result,
2084            restored: false,
2085        })
2086    }
2087
2088    fn restore(&mut self) -> Result<(), GuestInjectError> {
2089        if self.restored {
2090            return Ok(());
2091        }
2092        let mut errors = Vec::new();
2093        for (addr, bytes, label) in [
2094            (self.hook.iat_slot, self.original_iat.as_slice(), "IAT slot"),
2095            (
2096                self.hook.stub_addr,
2097                self.original_stub.as_slice(),
2098                "stub bytes",
2099            ),
2100            (
2101                self.hook.result_addr,
2102                self.original_result.as_slice(),
2103                "result bytes",
2104            ),
2105        ] {
2106            if let Err(err) = self.backend.write(self.pid, addr, bytes) {
2107                errors.push(format!("{label} at {addr:#x}: {err}"));
2108            }
2109        }
2110        self.restored = true;
2111        match errors.is_empty() {
2112            true => Ok(()),
2113            false => Err(GuestInjectError::Backend(format!(
2114                "failed restoring guest IAT hook transaction: {}",
2115                errors.join("; ")
2116            ))),
2117        }
2118    }
2119}
2120
2121impl<B: GuestMemoryBackend + ?Sized> Drop for IatHookTransaction<'_, B> {
2122    fn drop(&mut self) {
2123        if !self.restored
2124            && let Err(err) = self.restore()
2125        {
2126            tracing::warn!(pid = self.pid, error = %err, "guest IAT-hook transaction restore failed");
2127        }
2128    }
2129}
2130
2131fn memory_iat_call<B: GuestMemoryBackend + ?Sized>(
2132    backend: &B,
2133    pid: u32,
2134    hook: &GuestIatHook,
2135    function: u64,
2136    args: [u64; 4],
2137    timeout_ms: u32,
2138) -> Result<u64, GuestInjectError> {
2139    let zero_result = vec![0u8; RESULT_BLOCK_SIZE];
2140    let stub = call_stub(hook, function, args);
2141    let mut transaction = IatHookTransaction::prepare(backend, pid, hook, stub.len())?;
2142    tracing::debug!(
2143        pid,
2144        function = format_args!("{function:#x}"),
2145        iat_slot = format_args!("{:#x}", hook.iat_slot),
2146        original_target = format_args!("{:#x}", hook.original_target),
2147        stub_addr = format_args!("{:#x}", hook.stub_addr),
2148        result_addr = format_args!("{:#x}", hook.result_addr),
2149        arg0 = format_args!("{:#x}", args[0]),
2150        arg1 = format_args!("{:#x}", args[1]),
2151        arg2 = format_args!("{:#x}", args[2]),
2152        arg3 = format_args!("{:#x}", args[3]),
2153        timeout_ms,
2154        "guest IAT-hook call installing stub"
2155    );
2156    write_verified(
2157        backend,
2158        pid,
2159        hook.result_addr,
2160        &zero_result,
2161        "guest IAT-hook result block",
2162    )?;
2163    write_verified(
2164        backend,
2165        pid,
2166        hook.stub_addr,
2167        &stub,
2168        "guest IAT-hook call stub",
2169    )?;
2170    write_verified(
2171        backend,
2172        pid,
2173        hook.iat_slot,
2174        &hook.stub_addr.to_le_bytes(),
2175        "guest IAT-hook thunk",
2176    )?;
2177    tracing::debug!(
2178        pid,
2179        iat_slot = format_args!("{:#x}", hook.iat_slot),
2180        stub_bytes = stub.len(),
2181        "guest IAT-hook call armed"
2182    );
2183    let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
2184    loop {
2185        let result = backend.read(pid, hook.result_addr, RESULT_BLOCK_SIZE)?;
2186        let state = u64::from_le_bytes(result[0..8].try_into().unwrap());
2187        let value = u64::from_le_bytes(result[8..16].try_into().unwrap());
2188        if state == RESULT_STATE {
2189            tracing::debug!(
2190                pid,
2191                function = format_args!("{function:#x}"),
2192                return_value = format_args!("{value:#x}"),
2193                "guest IAT-hook call completed"
2194            );
2195            transaction.restore()?;
2196            return Ok(value);
2197        }
2198        if Instant::now() >= deadline {
2199            let restore_result = transaction.restore();
2200            tracing::warn!(
2201                pid,
2202                function = format_args!("{function:#x}"),
2203                iat_slot = format_args!("{:#x}", hook.iat_slot),
2204                result_state = format_args!("{state:#x}"),
2205                result_value = format_args!("{value:#x}"),
2206                timeout_ms,
2207                "guest IAT-hook call timed out; restored original IAT target"
2208            );
2209            if let Err(err) = restore_result {
2210                tracing::warn!(pid, error = %err, "guest IAT-hook timeout restore failed");
2211            }
2212            return Err(GuestInjectError::Unsupported {
2213                operation: "guest iat-hook execution",
2214                reason: format!(
2215                    "target did not call the configured import before {} ms",
2216                    timeout_ms
2217                ),
2218            });
2219        }
2220        thread::sleep(Duration::from_millis(10));
2221    }
2222}
2223
2224fn memory_iat_touch<B: GuestMemoryBackend + ?Sized>(
2225    backend: &B,
2226    pid: u32,
2227    hook: &GuestIatHook,
2228    addr: u64,
2229    len: usize,
2230    timeout_ms: u32,
2231) -> Result<(), GuestInjectError> {
2232    let zero_result = vec![0u8; RESULT_BLOCK_SIZE];
2233    let stub = touch_stub(hook, addr, len);
2234    let mut transaction = IatHookTransaction::prepare(backend, pid, hook, stub.len())?;
2235    tracing::debug!(
2236        pid,
2237        iat_slot = format_args!("{:#x}", hook.iat_slot),
2238        original_target = format_args!("{:#x}", hook.original_target),
2239        stub_addr = format_args!("{:#x}", hook.stub_addr),
2240        result_addr = format_args!("{:#x}", hook.result_addr),
2241        addr = format_args!("{addr:#x}"),
2242        len,
2243        timeout_ms,
2244        "guest IAT-hook page touch installing stub"
2245    );
2246    write_verified(
2247        backend,
2248        pid,
2249        hook.result_addr,
2250        &zero_result,
2251        "guest IAT-hook result block",
2252    )?;
2253    write_verified(
2254        backend,
2255        pid,
2256        hook.stub_addr,
2257        &stub,
2258        "guest IAT-hook page-touch stub",
2259    )?;
2260    write_verified(
2261        backend,
2262        pid,
2263        hook.iat_slot,
2264        &hook.stub_addr.to_le_bytes(),
2265        "guest IAT-hook thunk",
2266    )?;
2267    tracing::debug!(
2268        pid,
2269        iat_slot = format_args!("{:#x}", hook.iat_slot),
2270        stub_bytes = stub.len(),
2271        "guest IAT-hook page touch armed"
2272    );
2273    let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
2274    loop {
2275        let result = backend.read(pid, hook.result_addr, RESULT_BLOCK_SIZE)?;
2276        let state = u64::from_le_bytes(result[0..8].try_into().unwrap());
2277        let value = u64::from_le_bytes(result[8..16].try_into().unwrap());
2278        if state == RESULT_STATE {
2279            tracing::debug!(
2280                pid,
2281                addr = format_args!("{addr:#x}"),
2282                len,
2283                return_value = format_args!("{value:#x}"),
2284                "guest IAT-hook page touch completed"
2285            );
2286            transaction.restore()?;
2287            return Ok(());
2288        }
2289        if Instant::now() >= deadline {
2290            let restore_result = transaction.restore();
2291            tracing::warn!(
2292                pid,
2293                iat_slot = format_args!("{:#x}", hook.iat_slot),
2294                result_state = format_args!("{state:#x}"),
2295                result_value = format_args!("{value:#x}"),
2296                timeout_ms,
2297                "guest IAT-hook page touch timed out; restored original IAT target"
2298            );
2299            if let Err(err) = restore_result {
2300                tracing::warn!(pid, error = %err, "guest IAT-hook page-touch timeout restore failed");
2301            }
2302            return Err(GuestInjectError::Unsupported {
2303                operation: "guest iat-hook page touch",
2304                reason: format!(
2305                    "target did not call the configured import before {} ms",
2306                    timeout_ms
2307                ),
2308            });
2309        }
2310        thread::sleep(Duration::from_millis(10));
2311    }
2312}
2313
2314fn memory_iat_read_touch<B: GuestMemoryBackend + ?Sized>(
2315    backend: &B,
2316    pid: u32,
2317    hook: &GuestIatHook,
2318    addr: u64,
2319    len: usize,
2320    timeout_ms: u32,
2321) -> Result<(), GuestInjectError> {
2322    let zero_result = vec![0u8; RESULT_BLOCK_SIZE];
2323    let stub = read_touch_stub(hook, addr, len);
2324    let mut transaction = IatHookTransaction::prepare(backend, pid, hook, stub.len())?;
2325    tracing::debug!(
2326        pid,
2327        iat_slot = format_args!("{:#x}", hook.iat_slot),
2328        original_target = format_args!("{:#x}", hook.original_target),
2329        stub_addr = format_args!("{:#x}", hook.stub_addr),
2330        result_addr = format_args!("{:#x}", hook.result_addr),
2331        addr = format_args!("{addr:#x}"),
2332        len,
2333        timeout_ms,
2334        "guest IAT-hook read-touch installing stub"
2335    );
2336    write_verified(
2337        backend,
2338        pid,
2339        hook.result_addr,
2340        &zero_result,
2341        "guest IAT-hook result block",
2342    )?;
2343    write_verified(
2344        backend,
2345        pid,
2346        hook.stub_addr,
2347        &stub,
2348        "guest IAT-hook read-touch stub",
2349    )?;
2350    write_verified(
2351        backend,
2352        pid,
2353        hook.iat_slot,
2354        &hook.stub_addr.to_le_bytes(),
2355        "guest IAT-hook thunk",
2356    )?;
2357    tracing::debug!(
2358        pid,
2359        iat_slot = format_args!("{:#x}", hook.iat_slot),
2360        stub_bytes = stub.len(),
2361        "guest IAT-hook read touch armed"
2362    );
2363    let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
2364    loop {
2365        let result = backend.read(pid, hook.result_addr, RESULT_BLOCK_SIZE)?;
2366        let state = u64::from_le_bytes(result[0..8].try_into().unwrap());
2367        let value = u64::from_le_bytes(result[8..16].try_into().unwrap());
2368        if state == RESULT_STATE {
2369            tracing::debug!(
2370                pid,
2371                addr = format_args!("{addr:#x}"),
2372                len,
2373                return_value = format_args!("{value:#x}"),
2374                "guest IAT-hook read touch completed"
2375            );
2376            transaction.restore()?;
2377            return Ok(());
2378        }
2379        if Instant::now() >= deadline {
2380            let restore_result = transaction.restore();
2381            tracing::warn!(
2382                pid,
2383                iat_slot = format_args!("{:#x}", hook.iat_slot),
2384                result_state = format_args!("{state:#x}"),
2385                result_value = format_args!("{value:#x}"),
2386                timeout_ms,
2387                "guest IAT-hook read touch timed out; restored original IAT target"
2388            );
2389            if let Err(err) = restore_result {
2390                tracing::warn!(pid, error = %err, "guest IAT-hook read-touch timeout restore failed");
2391            }
2392            return Err(GuestInjectError::Unsupported {
2393                operation: "guest iat-hook read touch",
2394                reason: format!(
2395                    "target did not call the configured import before {} ms",
2396                    timeout_ms
2397                ),
2398            });
2399        }
2400        thread::sleep(Duration::from_millis(10));
2401    }
2402}
2403
2404fn memory_iat_preserve_touch<B: GuestMemoryBackend + ?Sized>(
2405    backend: &B,
2406    pid: u32,
2407    hook: &GuestIatHook,
2408    addr: u64,
2409    len: usize,
2410    timeout_ms: u32,
2411) -> Result<(), GuestInjectError> {
2412    let zero_result = vec![0u8; RESULT_BLOCK_SIZE];
2413    let stub = preserve_touch_stub(hook, addr, len);
2414    let mut transaction = IatHookTransaction::prepare(backend, pid, hook, stub.len())?;
2415    tracing::debug!(
2416        pid,
2417        iat_slot = format_args!("{:#x}", hook.iat_slot),
2418        original_target = format_args!("{:#x}", hook.original_target),
2419        stub_addr = format_args!("{:#x}", hook.stub_addr),
2420        result_addr = format_args!("{:#x}", hook.result_addr),
2421        addr = format_args!("{addr:#x}"),
2422        len,
2423        timeout_ms,
2424        "guest IAT-hook preserve-touch installing stub"
2425    );
2426    write_verified(
2427        backend,
2428        pid,
2429        hook.result_addr,
2430        &zero_result,
2431        "guest IAT-hook result block",
2432    )?;
2433    write_verified(
2434        backend,
2435        pid,
2436        hook.stub_addr,
2437        &stub,
2438        "guest IAT-hook preserve-touch stub",
2439    )?;
2440    write_verified(
2441        backend,
2442        pid,
2443        hook.iat_slot,
2444        &hook.stub_addr.to_le_bytes(),
2445        "guest IAT-hook thunk",
2446    )?;
2447    tracing::debug!(
2448        pid,
2449        iat_slot = format_args!("{:#x}", hook.iat_slot),
2450        stub_bytes = stub.len(),
2451        "guest IAT-hook preserve touch armed"
2452    );
2453    let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
2454    loop {
2455        let result = backend.read(pid, hook.result_addr, RESULT_BLOCK_SIZE)?;
2456        let state = u64::from_le_bytes(result[0..8].try_into().unwrap());
2457        let value = u64::from_le_bytes(result[8..16].try_into().unwrap());
2458        if state == RESULT_STATE {
2459            tracing::debug!(
2460                pid,
2461                addr = format_args!("{addr:#x}"),
2462                len,
2463                return_value = format_args!("{value:#x}"),
2464                "guest IAT-hook preserve touch completed"
2465            );
2466            transaction.restore()?;
2467            return Ok(());
2468        }
2469        if Instant::now() >= deadline {
2470            let restore_result = transaction.restore();
2471            tracing::warn!(
2472                pid,
2473                iat_slot = format_args!("{:#x}", hook.iat_slot),
2474                result_state = format_args!("{state:#x}"),
2475                result_value = format_args!("{value:#x}"),
2476                timeout_ms,
2477                "guest IAT-hook preserve touch timed out; restored original IAT target"
2478            );
2479            if let Err(err) = restore_result {
2480                tracing::warn!(
2481                    pid,
2482                    error = %err,
2483                    "guest IAT-hook preserve-touch timeout restore failed"
2484                );
2485            }
2486            return Err(GuestInjectError::Unsupported {
2487                operation: "guest iat-hook preserve touch",
2488                reason: format!(
2489                    "target did not call the configured import before {} ms",
2490                    timeout_ms
2491                ),
2492            });
2493        }
2494        thread::sleep(Duration::from_millis(10));
2495    }
2496}
2497
2498fn guest_artifact_notes(
2499    req: &GuestInjectionRequest<'_>,
2500    pe: &Pe,
2501    remote_base: u64,
2502    has_static_tls: bool,
2503) -> Vec<String> {
2504    let mut notes = vec![
2505        match req.plan.image_backing {
2506            GuestImageBacking::Private => format!(
2507                "artifact audit: payload image is a private committed mapping at {remote_base:#x}, not a loader-created SEC_IMAGE section"
2508            ),
2509            GuestImageBacking::SecImage => format!(
2510                "artifact audit: payload image at {remote_base:#x} is backed by a real SEC_IMAGE section over a staged guest copy of the payload file; the section object and image-file VAD backing are kernel-created, not forged; only patched pages (imports, security cookie) become copy-on-write private, unpatched pages remain file-backed"
2511            ),
2512        },
2513        match req.plan.loader_entries {
2514            GuestLoaderEntries::Absent => {
2515                "artifact audit: PEB loader lists, normal module enumeration, and loader VAD/section-object metadata are not synthesized by guest manual-map".into()
2516            }
2517            GuestLoaderEntries::Synthesized if req.plan.cleanup == GuestCleanup::Tracked => {
2518                "artifact audit: synthesized LDR_DATA_TABLE_ENTRY is linked transiently into PEB InLoadOrder/InMemoryOrder lists, then unlinked by cleanup=tracked to avoid leaving loader-owned shutdown state behind".into()
2519            }
2520            GuestLoaderEntries::Synthesized => {
2521                "artifact audit: synthesized LDR_DATA_TABLE_ENTRY linked into PEB InLoadOrder/InMemoryOrder lists; initialization-order links are left private to avoid loader shutdown ownership of the manual map".into()
2522            }
2523        },
2524        match req.plan.image_backing {
2525            GuestImageBacking::Private => {
2526                "artifact audit: PE headers, sections, imports, relocations, delay imports, TLS callbacks, and entrypoint were processed by decant rather than the Windows loader".into()
2527            }
2528            GuestImageBacking::SecImage => {
2529                "artifact audit: SEC_IMAGE provided the image section layout; relocations, imports, delay imports, TLS callbacks, and entrypoint were processed by decant rather than the Windows loader".into()
2530            }
2531        },
2532        format!(
2533            "artifact audit: execution method {} runs DllMain{}; {}",
2534            req.plan.execution.method.label(),
2535            match req.plan.execution.method {
2536                GuestExecutionMethod::RemoteThread => " on a kernel-created remote thread through a ThreadProc thunk",
2537                _ => " on the target thread that calls the hooked import",
2538            },
2539            match req.plan.execution.method {
2540                GuestExecutionMethod::RemoteThread => "the kernel-recorded thread start is the helper thunk, not the payload entrypoint",
2541                _ => "no remote thread or APC is created by this path",
2542            }
2543        ),
2544    ];
2545
2546    match req.plan.final_protections {
2547        GuestFinalProtections::Rwx => notes.push(
2548            "artifact audit: final_protections=rwx leaves the mapped image writable and executable"
2549                .into(),
2550        ),
2551        GuestFinalProtections::Section => match req.plan.permission_transitions {
2552            GuestPermissionTransitions::Standard => notes.push(
2553                "artifact audit: final_protections=section allocates RW memory, writes the image, then applies PE-derived page protections before TLS callbacks and DllMain"
2554                    .into(),
2555            ),
2556            GuestPermissionTransitions::WriteThroughFinal => notes.push(
2557                "artifact audit: permission_transitions=write-through-final allocates with final-ish image permissions, materializes pages by read touch when possible, writes through memflow, and skips section protects that already match the initial protection"
2558                    .into(),
2559            ),
2560        },
2561    }
2562    match req.plan.call_stack {
2563        GuestCallStackPolicy::Native => notes.push(
2564            "artifact audit: call_stack=native leaves the IAT-hook stub without dynamic unwind registration"
2565                .into(),
2566        ),
2567        GuestCallStackPolicy::RegisteredUnwind => notes.push(
2568            "artifact audit: call_stack=registered-unwind registers x64 unwind metadata for the IAT-hook stub; caller frames are not spoofed"
2569                .into(),
2570        ),
2571    }
2572    match req.plan.thread_starts {
2573        GuestThreadStartPolicy::ExistingThread => {
2574            if req.plan.execution.method == GuestExecutionMethod::RemoteThread {
2575                notes.push(
2576                    "artifact audit: thread_starts=existing-thread is not applicable to remote-thread execution; a guest thread is created with a helper thunk as its recorded start address, using a temporary thunk when no payload-image cave is available"
2577                        .into(),
2578                );
2579            } else {
2580                notes.push(
2581                    "artifact audit: thread_starts=existing-thread creates no guest thread, so no new thread start address is recorded by this path"
2582                        .into(),
2583                );
2584            }
2585        }
2586        GuestThreadStartPolicy::RequireModuleBacked => {
2587            if req.plan.execution.method == GuestExecutionMethod::RemoteThread {
2588                notes.push(
2589                    "artifact audit: thread_starts=require-module-backed requires the remote-thread ThreadProc thunk to be placed in a payload-image executable code cave; no temporary thread-start fallback is allowed"
2590                        .into(),
2591                );
2592            } else {
2593                notes.push(
2594                    "artifact audit: thread_starts=require-module-backed verified the IAT-hook plumbing is inside loaded module ranges; payload entrypoints and helper calls are not thread-start metadata"
2595                        .into(),
2596                );
2597            }
2598        }
2599    }
2600
2601    if req.plan.dependency_policy == GuestDependencyPolicy::LoadWithGuestLoader {
2602        notes.push(
2603            "artifact audit: missing dependencies are loaded through guest LoadLibraryA/GetProcAddress; the payload image itself remains manual-mapped"
2604                .into(),
2605        );
2606    }
2607    if req.plan.loader_metadata == GuestLoaderMetadataPolicy::BestEffort {
2608        notes.push(
2609            "artifact audit: loader_metadata=best-effort registers public runtime metadata that can be expressed through guest exports"
2610                .into(),
2611        );
2612    }
2613    if has_static_tls {
2614        match req.plan.loader_entries {
2615            GuestLoaderEntries::Synthesized => {
2616                let thread_scope = match req.plan.execution.method {
2617                    GuestExecutionMethod::RemoteThread => {
2618                        "the current helper thread only; the remote DllMain thread and other existing threads are not covered"
2619                    }
2620                    _ => "the current target thread only; other threads are not covered",
2621                };
2622                notes.push(format!(
2623                    "artifact audit: static TLS slot allocated via TlsAlloc, index patched into image buffer, template copied and TlsSetValue called for {thread_scope}"
2624                ));
2625            }
2626            GuestLoaderEntries::Absent => notes.push(
2627                "artifact audit: static TLS directory is present, but loader-managed TLS slots are not registered".into(),
2628            ),
2629        }
2630    }
2631    if pe.has_exception_directory() {
2632        match req.plan.loader_metadata {
2633            GuestLoaderMetadataPolicy::BestEffort => notes.push(
2634                "artifact audit: exception directory is present and RtlAddFunctionTable was called for the mapped image"
2635                    .into(),
2636            ),
2637            _ => notes.push(
2638                "artifact audit: exception directory is present, but unwind function tables are not registered"
2639                    .into(),
2640            ),
2641        }
2642    }
2643    if pe.has_load_config() {
2644        match req.plan.loader_metadata {
2645            GuestLoaderMetadataPolicy::BestEffort => notes.push(
2646                "artifact audit: load-config directory is present; default security-cookie state is seeded when exposed, and a CFG valid-call-target mark is requested when loader_entries=synthesized; broader loader-private entries are not synthesized"
2647                    .into(),
2648            ),
2649            _ => notes.push(
2650                "artifact audit: load-config directory is present, but CFG/security-cookie loader metadata is not processed"
2651                    .into(),
2652            ),
2653        }
2654    }
2655    match req.plan.header_wipe {
2656        GuestHeaderWipe::None => {}
2657        GuestHeaderWipe::AfterLoad => notes.push(
2658            "artifact audit: PE headers (DOS header, NT headers, section headers) were zeroed after DllMain returned".into(),
2659        ),
2660    }
2661    match req.plan.stack_shaping {
2662        GuestStackShaping::Native => {}
2663        GuestStackShaping::Spoofed => {
2664            if req.plan.execution.method == GuestExecutionMethod::RemoteThread {
2665                notes.push(
2666                    "artifact audit: stack_shaping=spoofed is disabled for remote-thread launch helpers; remote-thread DllMain runs through a ThreadProc thunk and is not stack-shaped"
2667                        .into(),
2668                );
2669            } else {
2670                notes.push(
2671                    "artifact audit: stack_shaping=spoofed writes a synthetic return address from a loaded module onto the stack before payload calls so stack walks attribute the call to a legitimate module"
2672                        .into(),
2673                );
2674            }
2675        }
2676    }
2677    match req.plan.cleanup {
2678        GuestCleanup::Resident => {}
2679        GuestCleanup::Tracked => notes.push(
2680            "artifact audit: cleanup=tracked removes transient loader-list links after load; payload memory remains resident for the running fixture".into(),
2681        ),
2682    }
2683    match req.plan.vad_spoof {
2684        GuestVadSpoof::Off => {}
2685        GuestVadSpoof::VadImageMap => notes.push(
2686            "artifact audit: vad_spoof=vad-image-map completed before DllMain; this mutates only VAD metadata for a private mapping and does not create a real SEC_IMAGE section object, so deep kernel inspection may still distinguish it from loader-created image backing".into(),
2687        ),
2688    }
2689    notes
2690}
2691
2692#[allow(clippy::too_many_arguments)]
2693fn protect_guest_sections(
2694    backend: &dyn GuestMemoryBackend,
2695    pid: u32,
2696    hook: &GuestIatHook,
2697    virtual_protect: u64,
2698    remote_base: u64,
2699    pe: &Pe,
2700    skip_initial_protect: Option<u64>,
2701    timeout_ms: u32,
2702) -> Result<(), GuestInjectError> {
2703    let old_protect = hook.result_addr + OLD_PROTECT_RESULT_OFFSET;
2704    if pe.size_of_headers != 0 {
2705        match skip_initial_protect == Some(PAGE_READONLY) {
2706            true => tracing::debug!(
2707                pid,
2708                remote_base = format_args!("{remote_base:#x}"),
2709                "guest final header protection already matches initial allocation"
2710            ),
2711            false => guest_virtual_protect(
2712                backend,
2713                pid,
2714                hook,
2715                virtual_protect,
2716                remote_base,
2717                pe.size_of_headers as u64,
2718                PAGE_READONLY,
2719                old_protect,
2720                timeout_ms,
2721                "headers",
2722            )?,
2723        }
2724    }
2725
2726    let mut applied = 0usize;
2727    let mut skipped = 0usize;
2728    for section in &pe.sections {
2729        let size = guest_section_size(section);
2730        if size == 0 {
2731            continue;
2732        }
2733        let addr = remote_base
2734            .checked_add(u64::from(section.virtual_address))
2735            .ok_or_else(|| GuestInjectError::Image("section address overflows".into()))?;
2736        let protect = guest_section_protect(section.characteristics);
2737        if skip_initial_protect == Some(protect) {
2738            skipped += 1;
2739            tracing::debug!(
2740                pid,
2741                addr = format_args!("{addr:#x}"),
2742                protect = format_args!("{protect:#x}"),
2743                "guest final section protection already matches initial allocation"
2744            );
2745            continue;
2746        }
2747        guest_virtual_protect(
2748            backend,
2749            pid,
2750            hook,
2751            virtual_protect,
2752            addr,
2753            u64::from(size),
2754            protect,
2755            old_protect,
2756            timeout_ms,
2757            "section",
2758        )?;
2759        applied += 1;
2760    }
2761    tracing::info!(
2762        pid,
2763        remote_base = format_args!("{remote_base:#x}"),
2764        sections = applied,
2765        skipped,
2766        "guest final section protections applied"
2767    );
2768    Ok(())
2769}
2770
2771fn find_spoofed_return(
2772    backend: &dyn GuestMemoryBackend,
2773    pid: u32,
2774) -> Result<GuestSpoofedReturn, GuestInjectError> {
2775    let modules = backend.module_list(pid)?;
2776    let regions = backend.memory_map(pid).unwrap_or_else(|err| {
2777        tracing::debug!(
2778            pid,
2779            error = %err,
2780            "stack spoofing: guest memory map unavailable; falling back to bounded module scan"
2781        );
2782        Vec::new()
2783    });
2784
2785    for preferred in ["ntdll.dll", "kernel32.dll", "kernelbase.dll"] {
2786        for module in modules
2787            .iter()
2788            .filter(|module| module.name.eq_ignore_ascii_case(preferred))
2789        {
2790            for (start, end) in spoofed_return_scan_ranges(module, &regions) {
2791                if let Some(gadget) = find_spoofed_return_in_guest_range(backend, pid, start, end)?
2792                {
2793                    tracing::info!(
2794                        pid,
2795                        addr = format_args!("{:#x}", gadget.gadget_addr),
2796                        module = %module.name,
2797                        stack_adjust = format_args!("{:#x}", gadget.stack_adjust),
2798                        range_start = format_args!("{start:#x}"),
2799                        range_end = format_args!("{end:#x}"),
2800                        "stack spoofing: found shadow-space-safe return gadget"
2801                    );
2802                    return Ok(gadget);
2803                }
2804            }
2805        }
2806    }
2807
2808    Err(GuestInjectError::Backend(
2809        "stack spoofing: no shadow-space-safe return gadget found in readable executable ntdll/kernel32/kernelbase ranges".into(),
2810    ))
2811}
2812
2813fn spoofed_return_scan_ranges(
2814    module: &GuestModuleInfo,
2815    regions: &[GuestMemoryRegion],
2816) -> Vec<(u64, u64)> {
2817    let module_end = module.base.saturating_add(module.size);
2818    let mut ranges = Vec::new();
2819    let mut remaining = module.size.min(SPOOFED_RETURN_SCAN_LIMIT);
2820
2821    for region in regions {
2822        if remaining == 0 {
2823            break;
2824        }
2825        if !region.readable || !region.executable {
2826            continue;
2827        }
2828        let region_end = region.base.saturating_add(region.size);
2829        let start = region.base.max(module.base);
2830        let end = region_end.min(module_end);
2831        if start >= end {
2832            continue;
2833        }
2834        let capped_end = start.saturating_add((end - start).min(remaining));
2835        ranges.push((start, capped_end));
2836        remaining = remaining.saturating_sub(capped_end - start);
2837    }
2838
2839    if ranges.is_empty() && module.size != 0 {
2840        ranges.push((
2841            module.base,
2842            module
2843                .base
2844                .saturating_add(module.size.min(SPOOFED_RETURN_SCAN_LIMIT)),
2845        ));
2846    }
2847
2848    ranges
2849}
2850
2851fn find_spoofed_return_in_guest_range(
2852    backend: &dyn GuestMemoryBackend,
2853    pid: u32,
2854    start: u64,
2855    end: u64,
2856) -> Result<Option<GuestSpoofedReturn>, GuestInjectError> {
2857    let mut addr = start;
2858    while addr < end {
2859        let len = (end - addr).min(GUEST_PAGE_SIZE as u64) as usize;
2860        match backend.read(pid, addr, len) {
2861            Ok(bytes) => {
2862                if let Some((off, stack_adjust)) = find_spoofed_return_gadget(&bytes) {
2863                    return Ok(Some(GuestSpoofedReturn {
2864                        gadget_addr: addr + off as u64,
2865                        stack_adjust,
2866                    }));
2867                }
2868            }
2869            Err(err) if is_process_gone_error(&err) => return Err(err),
2870            Err(err) => {
2871                tracing::debug!(
2872                    pid,
2873                    addr = format_args!("{addr:#x}"),
2874                    len,
2875                    error = %err,
2876                    "stack spoofing: skipping unreadable scan chunk"
2877                );
2878            }
2879        }
2880        addr = addr.saturating_add(len as u64);
2881    }
2882    Ok(None)
2883}
2884
2885fn find_spoofed_return_gadget(bytes: &[u8]) -> Option<(usize, u8)> {
2886    for (stack_adjust, pattern) in [
2887        (0x20, [0x48, 0x83, 0xC4, 0x20, 0xC3]),
2888        (0x28, [0x48, 0x83, 0xC4, 0x28, 0xC3]),
2889    ] {
2890        if let Some(off) = bytes.windows(pattern.len()).position(|w| w == pattern) {
2891            return Some((off, stack_adjust));
2892        }
2893    }
2894    None
2895}
2896
2897fn random_base_address() -> u64 {
2898    let time = SystemTime::now()
2899        .duration_since(UNIX_EPOCH)
2900        .map(|d| d.as_nanos() as u64)
2901        .unwrap_or(0);
2902    let pid = std::process::id() as u64;
2903    let mut state = time ^ pid.rotate_left(17) ^ 0x9E37_79B9_7F4A_7C15;
2904    state ^= state >> 33;
2905    state = state.wrapping_mul(0xff51_afd7_ed55_8ccd);
2906    state ^= state >> 33;
2907    let range = 0x7FF0_0000_0000u64 - 0x1000_0000u64;
2908    let offset = state % range;
2909    let base = 0x1000_0000u64 + offset;
2910    base & !0xFFFF
2911}
2912
2913fn remote_thread_param_block(
2914    entry_point: u64,
2915    remote_base: u64,
2916    exit_code: u64,
2917    status: u64,
2918) -> [u8; REMOTE_THREAD_PARAM_SIZE] {
2919    let values = [
2920        entry_point,
2921        remote_base,
2922        DLL_PROCESS_ATTACH as u64,
2923        0,
2924        exit_code,
2925        status,
2926    ];
2927    let mut block = [0u8; REMOTE_THREAD_PARAM_SIZE];
2928    for (i, value) in values.into_iter().enumerate() {
2929        let off = i * 8;
2930        block[off..off + 8].copy_from_slice(&value.to_le_bytes());
2931    }
2932    block
2933}
2934
2935fn poll_remote_thread_exit(
2936    backend: &dyn GuestMemoryBackend,
2937    pid: u32,
2938    status_addr: u64,
2939    exit_code_addr: u64,
2940    timeout_ms: u32,
2941) -> Result<u64, GuestInjectError> {
2942    let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
2943    loop {
2944        let status = read_remote_u32(backend, pid, status_addr)?;
2945        if status != 0 {
2946            return read_remote_u32(backend, pid, exit_code_addr).map(u64::from);
2947        }
2948        if Instant::now() >= deadline {
2949            return Err(GuestInjectError::Backend(format!(
2950                "remote thread did not complete within {timeout_ms} ms"
2951            )));
2952        }
2953        thread::sleep(Duration::from_millis(10));
2954    }
2955}
2956
2957fn best_effort_virtual_free(
2958    backend: &dyn GuestMemoryBackend,
2959    pid: u32,
2960    hook: &GuestIatHook,
2961    virtual_free: u64,
2962    addr: u64,
2963    timeout_ms: u32,
2964) {
2965    if addr != 0 {
2966        let _ = backend.call_iat_hook(
2967            pid,
2968            hook,
2969            virtual_free,
2970            [addr, 0, MEM_RELEASE, 0],
2971            timeout_ms,
2972        );
2973    }
2974}
2975
2976#[allow(clippy::too_many_arguments)]
2977fn invoke_dllmain_remote_thread(
2978    backend: &dyn GuestMemoryBackend,
2979    pid: u32,
2980    hook: &GuestIatHook,
2981    virtual_alloc: u64,
2982    stage: u64,
2983    entry_point: u64,
2984    remote_base: u64,
2985    image: &[u8],
2986    pe: &Pe,
2987    require_module_backed_start: bool,
2988    timeout_ms: u32,
2989) -> Result<u64, GuestInjectError> {
2990    let helper_hook = GuestIatHook {
2991        spoofed_return: None,
2992        ..*hook
2993    };
2994    let hook = &helper_hook;
2995    let trampoline = stage + STAGE_TRAMPOLINE_OFFSET;
2996    let param_block = stage + STAGE_PARAM_OFFSET;
2997    let create_thread = resolve_import_symbol(backend, pid, "kernel32.dll", "CreateThread")?;
2998    let virtual_free = resolve_import_symbol(backend, pid, "kernel32.dll", "VirtualFree")?;
2999    let close_handle = resolve_import_symbol(backend, pid, "kernel32.dll", "CloseHandle")?;
3000    let virtual_protect = resolve_import_symbol(backend, pid, "kernel32.dll", "VirtualProtect")?;
3001
3002    let thunk_code_size = REMOTE_THREAD_DLLMAIN_THUNK.len();
3003    let scratch = backend.call_iat_hook(
3004        pid,
3005        hook,
3006        virtual_alloc,
3007        [
3008            0,
3009            REMOTE_THREAD_THUNK_ALLOCATION_SIZE,
3010            MEM_COMMIT_RESERVE,
3011            PAGE_EXECUTE_READWRITE,
3012        ],
3013        timeout_ms,
3014    )?;
3015    if scratch == 0 {
3016        return Err(GuestInjectError::Backend(
3017            "VirtualAlloc returned NULL for remote-thread scratch".into(),
3018        ));
3019    }
3020    if let Err(err) = backend.touch_iat_hook(
3021        pid,
3022        hook,
3023        scratch,
3024        REMOTE_THREAD_THUNK_ALLOCATION_SIZE as usize,
3025        timeout_ms,
3026    ) {
3027        best_effort_virtual_free(backend, pid, hook, virtual_free, scratch, timeout_ms);
3028        return Err(err);
3029    }
3030
3031    let thunk_param = scratch + REMOTE_THREAD_PARAM_OFFSET;
3032    let exit_code_slot = scratch + REMOTE_THREAD_EXIT_CODE_OFFSET;
3033    let status_slot = scratch + REMOTE_THREAD_STATUS_OFFSET;
3034    let tid_slot = scratch + REMOTE_THREAD_THREAD_ID_OFFSET;
3035    let mut scratch_region = vec![0u8; REMOTE_THREAD_THREAD_ID_OFFSET as usize + 8];
3036    scratch_region[REMOTE_THREAD_PARAM_OFFSET as usize
3037        ..REMOTE_THREAD_PARAM_OFFSET as usize + REMOTE_THREAD_PARAM_SIZE]
3038        .copy_from_slice(&remote_thread_param_block(
3039            entry_point,
3040            remote_base,
3041            exit_code_slot,
3042            status_slot,
3043        ));
3044
3045    let (thunk_addr, thread_start_location) = match find_in_image_code_cave(
3046        pe,
3047        image,
3048        thunk_code_size,
3049    ) {
3050        Some(thunk_offset) => {
3051            let thunk_addr = remote_base + thunk_offset as u64;
3052            if let Err(err) = guest_virtual_protect(
3053                backend,
3054                pid,
3055                hook,
3056                virtual_protect,
3057                thunk_addr,
3058                thunk_code_size as u64,
3059                PAGE_EXECUTE_READWRITE,
3060                hook.result_addr + OLD_PROTECT_RESULT_OFFSET,
3061                timeout_ms,
3062                "remote-thread in-image thunk",
3063            ) {
3064                best_effort_virtual_free(backend, pid, hook, virtual_free, scratch, timeout_ms);
3065                return Err(err);
3066            }
3067            let old_protect =
3068                match backend.read(pid, hook.result_addr + OLD_PROTECT_RESULT_OFFSET, 4) {
3069                    Ok(old_protect) => old_protect,
3070                    Err(err) => {
3071                        best_effort_virtual_free(
3072                            backend,
3073                            pid,
3074                            hook,
3075                            virtual_free,
3076                            scratch,
3077                            timeout_ms,
3078                        );
3079                        return Err(err);
3080                    }
3081                };
3082            if let Err(err) = write_verified(
3083                backend,
3084                pid,
3085                thunk_addr,
3086                REMOTE_THREAD_DLLMAIN_THUNK,
3087                "remote-thread in-image thunk",
3088            ) {
3089                best_effort_virtual_free(backend, pid, hook, virtual_free, scratch, timeout_ms);
3090                return Err(err);
3091            }
3092            let old_protect = u32::from_le_bytes(old_protect[0..4].try_into().unwrap()) as u64;
3093            if old_protect != 0 {
3094                let _ = guest_virtual_protect(
3095                    backend,
3096                    pid,
3097                    hook,
3098                    virtual_protect,
3099                    thunk_addr,
3100                    thunk_code_size as u64,
3101                    old_protect,
3102                    hook.result_addr + OLD_PROTECT_RESULT_OFFSET,
3103                    timeout_ms,
3104                    "remote-thread in-image thunk restore",
3105                );
3106            }
3107            tracing::info!(
3108                pid,
3109                thunk_addr = format_args!("{thunk_addr:#x}"),
3110                entry_point = format_args!("{entry_point:#x}"),
3111                "DllMain thunk written into payload image code cave; thread start will be module-backed"
3112            );
3113            (thunk_addr, "payload-image code cave")
3114        }
3115        None => {
3116            if require_module_backed_start {
3117                best_effort_virtual_free(backend, pid, hook, virtual_free, scratch, timeout_ms);
3118                return Err(GuestInjectError::Backend(
3119                    "remote-thread module-backed start requires an executable payload-image code cave for the DllMain thunk"
3120                        .into(),
3121                ));
3122            }
3123            scratch_region[..thunk_code_size].copy_from_slice(REMOTE_THREAD_DLLMAIN_THUNK);
3124            tracing::info!(
3125                pid,
3126                thunk_addr = format_args!("{scratch:#x}"),
3127                entry_point = format_args!("{entry_point:#x}"),
3128                "remote-thread: no payload-image code cave available; using temporary ThreadProc thunk"
3129            );
3130            (scratch, "temporary helper allocation")
3131        }
3132    };
3133    if let Err(err) = write_verified(
3134        backend,
3135        pid,
3136        scratch,
3137        &scratch_region,
3138        "remote-thread scratch",
3139    ) {
3140        best_effort_virtual_free(backend, pid, hook, virtual_free, scratch, timeout_ms);
3141        return Err(err);
3142    }
3143
3144    let saved = match backend.read(pid, trampoline, GUEST_PROC_TRAMPOLINE.len()) {
3145        Ok(saved) => saved,
3146        Err(err) => {
3147            best_effort_virtual_free(backend, pid, hook, virtual_free, scratch, timeout_ms);
3148            return Err(err);
3149        }
3150    };
3151    if let Err(err) = write_verified(
3152        backend,
3153        pid,
3154        trampoline,
3155        GUEST_PROC_TRAMPOLINE,
3156        "remote-thread trampoline",
3157    ) {
3158        best_effort_virtual_free(backend, pid, hook, virtual_free, scratch, timeout_ms);
3159        return Err(err);
3160    }
3161
3162    let thread_handle = call_guest_proc(
3163        backend,
3164        pid,
3165        hook,
3166        trampoline,
3167        param_block,
3168        create_thread,
3169        &[0, 0, thunk_addr, thunk_param, 0, tid_slot],
3170        timeout_ms,
3171        "CreateThread",
3172    );
3173    if let Err(err) = backend.write(pid, trampoline, &saved) {
3174        tracing::warn!(
3175            pid,
3176            error = %err,
3177            "failed to restore guest proc trampoline after remote-thread create"
3178        );
3179    }
3180    let thread_handle = match thread_handle {
3181        Ok(handle) => handle,
3182        Err(err) => {
3183            best_effort_virtual_free(backend, pid, hook, virtual_free, scratch, timeout_ms);
3184            return Err(err);
3185        }
3186    };
3187    if thread_handle == 0 {
3188        best_effort_virtual_free(backend, pid, hook, virtual_free, scratch, timeout_ms);
3189        return Err(GuestInjectError::Backend(
3190            "CreateThread returned NULL".into(),
3191        ));
3192    }
3193    tracing::info!(
3194        pid,
3195        thread_handle = format_args!("{thread_handle:#x}"),
3196        thunk_addr = format_args!("{thunk_addr:#x}"),
3197        thread_start = thread_start_location,
3198        "guest thread created; kernel-recorded start address selected"
3199    );
3200
3201    let exit_code =
3202        match poll_remote_thread_exit(backend, pid, status_slot, exit_code_slot, timeout_ms) {
3203            Ok(exit_code) => exit_code,
3204            Err(err) => {
3205                let _ = backend.call_iat_hook(
3206                    pid,
3207                    hook,
3208                    close_handle,
3209                    [thread_handle, 0, 0, 0],
3210                    timeout_ms.min(250),
3211                );
3212                best_effort_virtual_free(
3213                    backend,
3214                    pid,
3215                    hook,
3216                    virtual_free,
3217                    scratch,
3218                    timeout_ms.min(250),
3219                );
3220                return Err(err);
3221            }
3222        };
3223    let _ = backend.call_iat_hook(
3224        pid,
3225        hook,
3226        close_handle,
3227        [thread_handle, 0, 0, 0],
3228        timeout_ms.min(250),
3229    );
3230    best_effort_virtual_free(
3231        backend,
3232        pid,
3233        hook,
3234        virtual_free,
3235        scratch,
3236        timeout_ms.min(250),
3237    );
3238    tracing::info!(
3239        pid,
3240        thread_handle = format_args!("{thread_handle:#x}"),
3241        exit_code,
3242        "remote thread completed; DllMain return value retrieved"
3243    );
3244    Ok(exit_code)
3245}
3246
3247fn find_in_image_code_cave(pe: &Pe, image: &[u8], size: usize) -> Option<usize> {
3248    for section in &pe.sections {
3249        if section.characteristics & IMAGE_SCN_MEM_EXECUTE == 0 {
3250            continue;
3251        }
3252        let start = section.virtual_address as usize;
3253        let sec_size = guest_section_size(section) as usize;
3254        if start + sec_size > image.len() {
3255            continue;
3256        }
3257        if let Some(off) = find_code_cave(&image[start..start + sec_size], size) {
3258            return Some(start + off);
3259        }
3260    }
3261    None
3262}
3263
3264#[allow(clippy::too_many_arguments)]
3265fn allocate_virtual(
3266    backend: &dyn GuestMemoryBackend,
3267    pid: u32,
3268    hook: &GuestIatHook,
3269    virtual_alloc: u64,
3270    pe: &Pe,
3271    allocation_protection: u64,
3272    base_policy: GuestBaseAddress,
3273    timeout_ms: u32,
3274) -> Result<u64, GuestInjectError> {
3275    let size = pe.size_of_image as u64;
3276    let try_alloc = |addr: u64| {
3277        backend.call_iat_hook(
3278            pid,
3279            hook,
3280            virtual_alloc,
3281            [addr, size, MEM_COMMIT_RESERVE, allocation_protection],
3282            timeout_ms,
3283        )
3284    };
3285    match base_policy {
3286        GuestBaseAddress::Preferred => {
3287            tracing::info!(
3288                pid,
3289                preferred_base = format_args!("{:#x}", pe.image_base),
3290                size,
3291                protection = format_args!("{allocation_protection:#x}"),
3292                "calling guest VirtualAlloc at preferred image base through IAT hook"
3293            );
3294            let preferred = try_alloc(pe.image_base)?;
3295            if preferred != 0 || !pe.has_relocation_directory() {
3296                return Ok(preferred);
3297            }
3298            tracing::info!(
3299                pid,
3300                preferred_base = format_args!("{:#x}", pe.image_base),
3301                "preferred image base unavailable; retrying guest VirtualAlloc at any base"
3302            );
3303            try_alloc(0)
3304        }
3305        GuestBaseAddress::Randomized => {
3306            if !pe.has_relocation_directory() {
3307                tracing::info!(
3308                    pid,
3309                    "base_address=randomized but payload has no relocation directory; using preferred base"
3310                );
3311                return try_alloc(pe.image_base);
3312            }
3313            for attempt in 0..3u32 {
3314                let candidate = random_base_address();
3315                tracing::info!(
3316                    pid,
3317                    attempt,
3318                    candidate = format_args!("{candidate:#x}"),
3319                    size,
3320                    "calling guest VirtualAlloc at randomized base"
3321                );
3322                let result = try_alloc(candidate)?;
3323                if result != 0 {
3324                    return Ok(result);
3325                }
3326            }
3327            tracing::info!(
3328                pid,
3329                "randomized base attempts exhausted; falling back to any base"
3330            );
3331            try_alloc(0)
3332        }
3333    }
3334}
3335
3336fn initial_allocation_protection(plan: &GuestInjectionPlan, pe: &Pe) -> u64 {
3337    match (plan.final_protections, plan.permission_transitions) {
3338        (GuestFinalProtections::Rwx, _) => PAGE_EXECUTE_READWRITE,
3339        (GuestFinalProtections::Section, GuestPermissionTransitions::Standard) => PAGE_READWRITE,
3340        (GuestFinalProtections::Section, GuestPermissionTransitions::WriteThroughFinal) => {
3341            final_image_allocation_protection(pe)
3342        }
3343    }
3344}
3345
3346fn final_image_allocation_protection(pe: &Pe) -> u64 {
3347    let mut has_execute = false;
3348    for section in &pe.sections {
3349        if guest_section_size(section) == 0 {
3350            continue;
3351        }
3352        let executable = section.characteristics & IMAGE_SCN_MEM_EXECUTE != 0;
3353        let writable = section.characteristics & IMAGE_SCN_MEM_WRITE != 0;
3354        if executable && writable {
3355            return PAGE_EXECUTE_READWRITE;
3356        }
3357        has_execute |= executable;
3358    }
3359    match has_execute {
3360        true => PAGE_EXECUTE_READ,
3361        false => PAGE_READONLY,
3362    }
3363}
3364
3365fn protection_is_writable(protect: u64) -> bool {
3366    matches!(protect, PAGE_READWRITE | PAGE_EXECUTE_READWRITE)
3367}
3368
3369fn protect_skip_initial(plan: &GuestInjectionPlan, initial_protect: u64) -> Option<u64> {
3370    match (plan.final_protections, plan.permission_transitions) {
3371        (GuestFinalProtections::Section, GuestPermissionTransitions::WriteThroughFinal) => {
3372            Some(initial_protect)
3373        }
3374        _ => None,
3375    }
3376}
3377
3378fn validate_guest_thread_start_policy(
3379    backend: &dyn GuestMemoryBackend,
3380    pid: u32,
3381    plan: &GuestInjectionPlan,
3382    stage: u64,
3383    hook: &GuestIatHook,
3384) -> Result<Vec<String>, GuestInjectError> {
3385    match plan.thread_starts {
3386        GuestThreadStartPolicy::ExistingThread => {
3387            if plan.execution.method == GuestExecutionMethod::RemoteThread {
3388                Ok(vec![
3389                    "thread starts: remote-thread execution creates a guest thread; thread_starts=existing-thread is not applicable to this execution method"
3390                        .into(),
3391                ])
3392            } else {
3393                Ok(vec![
3394                    "thread starts: existing target thread is used; decant does not create guest thread-start metadata"
3395                        .into(),
3396                ])
3397            }
3398        }
3399        GuestThreadStartPolicy::RequireModuleBacked => {
3400            if plan.execution.method == GuestExecutionMethod::RemoteThread {
3401                return Ok(vec![
3402                    "thread starts: remote-thread execution requires a payload-image code cave for a module-backed ThreadProc thunk"
3403                        .into(),
3404                    "thread starts: payloads without a large enough executable cave fail rather than falling back to a temporary thread start"
3405                        .into(),
3406                ]);
3407            }
3408            if plan.execution.method != GuestExecutionMethod::IatHook {
3409                return Err(GuestInjectError::Unsupported {
3410                    operation: "guest thread start policy",
3411                    reason: format!(
3412                        "thread_starts = \"require-module-backed\" is implemented for iat-hook and remote-thread execution, not {}",
3413                        plan.execution.method.label()
3414                    ),
3415                });
3416            }
3417            let modules = backend.module_list(pid)?;
3418            let stage_module =
3419                module_covering_range(&modules, stage, STAGE_CAVE_SIZE as u64, "staging cave")?;
3420            let iat_module = module_covering_range(&modules, hook.iat_slot, 8, "IAT slot")?;
3421            let target_module =
3422                module_covering_range(&modules, hook.original_target, 1, "original import target")?;
3423            tracing::info!(
3424                pid,
3425                stage = format_args!("{stage:#x}"),
3426                stage_module = %stage_module.name,
3427                iat_slot = format_args!("{:#x}", hook.iat_slot),
3428                iat_module = %iat_module.name,
3429                original_target = format_args!("{:#x}", hook.original_target),
3430                target_module = %target_module.name,
3431                "guest thread-start policy verified module-backed IAT-hook path"
3432            );
3433            Ok(vec![
3434                "thread starts: no guest thread is created; existing target thread-start metadata remains target-owned".into(),
3435                format!(
3436                    "thread starts: IAT-hook staging cave is module-backed by {}",
3437                    stage_module.name
3438                ),
3439                format!(
3440                    "thread starts: IAT-hook slot is module-backed by {}; original import target is module-backed by {}",
3441                    iat_module.name, target_module.name
3442                ),
3443            ])
3444        }
3445    }
3446}
3447
3448fn module_covering_range<'a>(
3449    modules: &'a [GuestModuleInfo],
3450    addr: u64,
3451    len: u64,
3452    label: &str,
3453) -> Result<&'a GuestModuleInfo, GuestInjectError> {
3454    modules
3455        .iter()
3456        .find(|module| module_contains_range(module, addr, len))
3457        .ok_or_else(|| GuestInjectError::Unsupported {
3458            operation: "guest module-backed thread starts",
3459            reason: format!("{label} range {addr:#x}+{len:#x} is not inside a loaded module"),
3460        })
3461}
3462
3463fn module_contains_range(module: &GuestModuleInfo, addr: u64, len: u64) -> bool {
3464    let Some(end) = addr.checked_add(len) else {
3465        return false;
3466    };
3467    let Some(module_end) = module.base.checked_add(module.size) else {
3468        return false;
3469    };
3470    addr >= module.base && end <= module_end
3471}
3472
3473fn register_guest_stub_unwind(
3474    backend: &dyn GuestMemoryBackend,
3475    pid: u32,
3476    hook: &GuestIatHook,
3477    stage: u64,
3478    timeout_ms: u32,
3479) -> Result<(), GuestInjectError> {
3480    let metadata_addr = stage
3481        .checked_add(STAGE_UNWIND_OFFSET)
3482        .ok_or_else(|| GuestInjectError::Image("stub unwind metadata address overflows".into()))?;
3483    let metadata = stub_unwind_metadata()?;
3484    write_verified(
3485        backend,
3486        pid,
3487        metadata_addr,
3488        &metadata,
3489        "guest IAT-hook unwind metadata",
3490    )?;
3491    let rtl_add_function_table =
3492        resolve_import_symbol(backend, pid, "kernel32.dll", "RtlAddFunctionTable")
3493            .or_else(|_| resolve_import_symbol(backend, pid, "ntdll.dll", "RtlAddFunctionTable"))?;
3494    tracing::info!(
3495        pid,
3496        stage = format_args!("{stage:#x}"),
3497        function_table = format_args!("{metadata_addr:#x}"),
3498        "registering guest IAT-hook runtime function table"
3499    );
3500    let ok = backend.call_iat_hook(
3501        pid,
3502        hook,
3503        rtl_add_function_table,
3504        [metadata_addr, 1, stage, 0],
3505        timeout_ms,
3506    )?;
3507    if ok == 0 {
3508        return Err(GuestInjectError::Backend(format!(
3509            "guest RtlAddFunctionTable({metadata_addr:#x}, 1, {stage:#x}) returned FALSE"
3510        )));
3511    }
3512    Ok(())
3513}
3514
3515fn stub_unwind_metadata() -> Result<Vec<u8>, GuestInjectError> {
3516    let begin = u32::try_from(STAGE_STUB_OFFSET)
3517        .map_err(|_| GuestInjectError::Image("stub begin RVA exceeds u32".into()))?;
3518    let end = u32::try_from(STAGE_SCRATCH_OFFSET)
3519        .map_err(|_| GuestInjectError::Image("stub end RVA exceeds u32".into()))?;
3520    let unwind = u32::try_from(STAGE_UNWIND_OFFSET + 12)
3521        .map_err(|_| GuestInjectError::Image("stub unwind RVA exceeds u32".into()))?;
3522    let alloc_info = (FRAMED_STUB_STACK_ALLOC / 8)
3523        .checked_sub(1)
3524        .ok_or_else(|| GuestInjectError::Image("invalid framed stub allocation".into()))?;
3525    let mut out = Vec::with_capacity(20);
3526    out.extend_from_slice(&begin.to_le_bytes());
3527    out.extend_from_slice(&end.to_le_bytes());
3528    out.extend_from_slice(&unwind.to_le_bytes());
3529    out.push(1);
3530    out.push(4);
3531    out.push(1);
3532    out.push(0);
3533    out.push(4);
3534    out.push((alloc_info << 4) | 2);
3535    out.extend_from_slice(&[0, 0]);
3536    Ok(out)
3537}
3538
3539fn register_guest_exception_table(
3540    backend: &dyn GuestMemoryBackend,
3541    pid: u32,
3542    hook: &GuestIatHook,
3543    remote_base: u64,
3544    pe: &Pe,
3545    timeout_ms: u32,
3546) -> Result<(), GuestInjectError> {
3547    if pe.exception.rva == 0 || pe.exception.size == 0 {
3548        return Ok(());
3549    }
3550    let function_table = remote_base
3551        .checked_add(u64::from(pe.exception.rva))
3552        .ok_or_else(|| GuestInjectError::Image("exception table address overflows".into()))?;
3553    let entry_count = pe.exception.size / 12;
3554    if entry_count == 0 {
3555        return Ok(());
3556    }
3557    let rtl_add_function_table =
3558        resolve_import_symbol(backend, pid, "kernel32.dll", "RtlAddFunctionTable")
3559            .or_else(|_| resolve_import_symbol(backend, pid, "ntdll.dll", "RtlAddFunctionTable"))?;
3560    tracing::info!(
3561        pid,
3562        function_table = format_args!("{function_table:#x}"),
3563        entry_count,
3564        base = format_args!("{remote_base:#x}"),
3565        "registering guest runtime function table"
3566    );
3567    let ok = backend.call_iat_hook(
3568        pid,
3569        hook,
3570        rtl_add_function_table,
3571        [function_table, u64::from(entry_count), remote_base, 0],
3572        timeout_ms,
3573    )?;
3574    if ok == 0 {
3575        return Err(GuestInjectError::Backend(format!(
3576            "guest RtlAddFunctionTable({function_table:#x}, {entry_count}, {remote_base:#x}) returned FALSE"
3577        )));
3578    }
3579    Ok(())
3580}
3581
3582fn loader_security_cookie(pid: u32, remote_base: u64, image_len: usize) -> u64 {
3583    let time = SystemTime::now()
3584        .duration_since(UNIX_EPOCH)
3585        .map(|duration| duration.as_nanos() as u64)
3586        .unwrap_or(0);
3587    let mut value = time
3588        ^ remote_base.rotate_left(17)
3589        ^ ((pid as u64) << 32)
3590        ^ (image_len as u64).rotate_left(7);
3591    value ^= value >> 33;
3592    value = value.wrapping_mul(0xff51_afd7_ed55_8ccd);
3593    value ^= value >> 33;
3594    value & 0x0000_FFFF_FFFF_FFFF
3595}
3596
3597const GENERIC_READ: u64 = 0x8000_0000;
3598const GENERIC_WRITE: u64 = 0x4000_0000;
3599const CREATE_ALWAYS: u64 = 2;
3600const FILE_ATTRIBUTE_NORMAL: u64 = 0x80;
3601const FILE_FLAG_DELETE_ON_CLOSE: u64 = 0x0400_0000;
3602const SEC_IMAGE: u64 = 0x0100_0000;
3603const FILE_MAP_COPY: u64 = 1;
3604const MEM_RELEASE: u64 = 0x8000;
3605const STAGE_TRAMPOLINE_OFFSET: u64 = STAGE_SCRATCH_OFFSET;
3606const STAGE_PARAM_OFFSET: u64 = STAGE_SCRATCH_OFFSET + 0x60;
3607const GUEST_PARAM_BLOCK_SIZE: usize = 0x58;
3608
3609const REMOTE_THREAD_THUNK_ALLOCATION_SIZE: u64 = 0x1000;
3610const REMOTE_THREAD_PARAM_OFFSET: u64 = 0x80;
3611const REMOTE_THREAD_PARAM_SIZE: usize = 0x30;
3612const REMOTE_THREAD_EXIT_CODE_OFFSET: u64 = 0x100;
3613const REMOTE_THREAD_STATUS_OFFSET: u64 = 0x108;
3614const REMOTE_THREAD_THREAD_ID_OFFSET: u64 = 0x110;
3615
3616const _: () = {
3617    assert!(
3618        REMOTE_THREAD_PARAM_OFFSET + REMOTE_THREAD_PARAM_SIZE as u64
3619            <= REMOTE_THREAD_EXIT_CODE_OFFSET
3620    );
3621    assert!(REMOTE_THREAD_EXIT_CODE_OFFSET + 4 <= REMOTE_THREAD_STATUS_OFFSET);
3622    assert!(REMOTE_THREAD_STATUS_OFFSET + 4 <= REMOTE_THREAD_THREAD_ID_OFFSET);
3623    assert!(REMOTE_THREAD_THREAD_ID_OFFSET + 8 <= REMOTE_THREAD_THUNK_ALLOCATION_SIZE);
3624};
3625
3626const SEC_IMAGE_PATH_CHARS: u64 = 0x400;
3627const SEC_IMAGE_PATH_BYTES: u64 = SEC_IMAGE_PATH_CHARS * 2;
3628
3629const GUEST_PROC_TRAMPOLINE: &[u8] = &[
3630    0x55, 0x48, 0x89, 0xE5, 0x48, 0x83, 0xE4, 0xF0, 0x48, 0x83, 0xEC, 0x40, 0x4C, 0x8B, 0x11, 0x48,
3631    0x8B, 0x41, 0x30, 0x48, 0x89, 0x44, 0x24, 0x20, 0x48, 0x8B, 0x41, 0x38, 0x48, 0x89, 0x44, 0x24,
3632    0x28, 0x48, 0x8B, 0x41, 0x40, 0x48, 0x89, 0x44, 0x24, 0x30, 0x48, 0x8B, 0x41, 0x48, 0x48, 0x89,
3633    0x44, 0x24, 0x38, 0x4C, 0x8B, 0x49, 0x28, 0x4C, 0x8B, 0x41, 0x20, 0x48, 0x8B, 0x51, 0x18, 0x48,
3634    0x8B, 0x49, 0x10, 0x4C, 0x89, 0xD0, 0xFF, 0xD0, 0x48, 0x89, 0xEC, 0x5D, 0xC3,
3635];
3636const GUEST_GET_PEB_TRAMPOLINE: &[u8] =
3637    &[0x65, 0x48, 0x8B, 0x04, 0x25, 0x60, 0x00, 0x00, 0x00, 0xC3];
3638const REMOTE_THREAD_DLLMAIN_THUNK: &[u8] = &[
3639    0x53, // push rbx
3640    0x48, 0x83, 0xEC, 0x20, // sub rsp, 0x20
3641    0x48, 0x89, 0xCB, // mov rbx, rcx
3642    0x48, 0x8B, 0x03, // mov rax, [rbx]
3643    0x48, 0x8B, 0x4B, 0x08, // mov rcx, [rbx+8]
3644    0x48, 0x8B, 0x53, 0x10, // mov rdx, [rbx+0x10]
3645    0x4C, 0x8B, 0x43, 0x18, // mov r8, [rbx+0x18]
3646    0xFF, 0xD0, // call rax
3647    0x4C, 0x8B, 0x53, 0x20, // mov r10, [rbx+0x20]
3648    0x41, 0x89, 0x02, // mov [r10], eax
3649    0x4C, 0x8B, 0x53, 0x28, // mov r10, [rbx+0x28]
3650    0x41, 0xC7, 0x02, 0x01, 0x00, 0x00, 0x00, // mov dword ptr [r10], 1
3651    0x48, 0x83, 0xC4, 0x20, // add rsp, 0x20
3652    0x5B, // pop rbx
3653    0xC3, // ret
3654];
3655
3656struct SecImageCleanup<'a> {
3657    backend: &'a dyn GuestMemoryBackend,
3658    pid: u32,
3659    hook: &'a GuestIatHook,
3660    unmap_view: u64,
3661    close_handle: u64,
3662    virtual_free: u64,
3663    trampoline: u64,
3664    saved_trampoline: Vec<u8>,
3665    timeout_ms: u32,
3666    path_buf: u64,
3667    payload_buf: u64,
3668    file_handle: u64,
3669    mapping_handle: u64,
3670    view_base: u64,
3671}
3672
3673impl Drop for SecImageCleanup<'_> {
3674    fn drop(&mut self) {
3675        if self.view_base != 0 {
3676            let _ = self.backend.call_iat_hook(
3677                self.pid,
3678                self.hook,
3679                self.unmap_view,
3680                [self.view_base, 0, 0, 0],
3681                self.timeout_ms,
3682            );
3683        }
3684        if self.mapping_handle != 0 {
3685            let _ = self.backend.call_iat_hook(
3686                self.pid,
3687                self.hook,
3688                self.close_handle,
3689                [self.mapping_handle, 0, 0, 0],
3690                self.timeout_ms,
3691            );
3692        }
3693        if self.file_handle != 0 {
3694            let _ = self.backend.call_iat_hook(
3695                self.pid,
3696                self.hook,
3697                self.close_handle,
3698                [self.file_handle, 0, 0, 0],
3699                self.timeout_ms,
3700            );
3701        }
3702        if self.payload_buf != 0 {
3703            let _ = self.backend.call_iat_hook(
3704                self.pid,
3705                self.hook,
3706                self.virtual_free,
3707                [self.payload_buf, 0, MEM_RELEASE, 0],
3708                self.timeout_ms,
3709            );
3710        }
3711        if self.path_buf != 0 {
3712            let _ = self.backend.call_iat_hook(
3713                self.pid,
3714                self.hook,
3715                self.virtual_free,
3716                [self.path_buf, 0, MEM_RELEASE, 0],
3717                self.timeout_ms,
3718            );
3719        }
3720        let _ = self
3721            .backend
3722            .write(self.pid, self.trampoline, &self.saved_trampoline);
3723    }
3724}
3725
3726fn sec_image_error(label: &str, err: impl std::fmt::Display) -> GuestInjectError {
3727    GuestInjectError::Backend(format!("guest SEC_IMAGE {label}: {err}"))
3728}
3729
3730fn encode_wide(s: &str) -> Vec<u8> {
3731    let mut out: Vec<u16> = s.encode_utf16().collect();
3732    out.push(0);
3733    let mut bytes = Vec::with_capacity(out.len() * 2);
3734    for word in out {
3735        bytes.extend_from_slice(&word.to_le_bytes());
3736    }
3737    bytes
3738}
3739
3740fn decode_wide(bytes: &[u8]) -> String {
3741    let pairs: Vec<u16> = bytes
3742        .chunks_exact(2)
3743        .map(|c| u16::from_le_bytes([c[0], c[1]]))
3744        .collect();
3745    let len = pairs.iter().position(|&w| w == 0).unwrap_or(pairs.len());
3746    String::from_utf16_lossy(&pairs[..len])
3747}
3748
3749fn should_request_cfg_call_target(plan: &GuestInjectionPlan, pe: &Pe) -> bool {
3750    plan.loader_entries == GuestLoaderEntries::Synthesized
3751        && plan.loader_metadata == GuestLoaderMetadataPolicy::BestEffort
3752        && pe.has_load_config()
3753}
3754
3755fn cfg_call_target_registration_args(
3756    remote_base: u64,
3757    region_size: u64,
3758    target_count: u64,
3759    cfg_info: u64,
3760) -> [u64; 5] {
3761    [u64::MAX, remote_base, region_size, target_count, cfg_info]
3762}
3763
3764#[allow(clippy::too_many_arguments)]
3765fn call_guest_proc(
3766    backend: &dyn GuestMemoryBackend,
3767    pid: u32,
3768    hook: &GuestIatHook,
3769    trampoline: u64,
3770    param_block: u64,
3771    target: u64,
3772    args: &[u64],
3773    timeout_ms: u32,
3774    label: &str,
3775) -> Result<u64, GuestInjectError> {
3776    let mut block = [0u8; GUEST_PARAM_BLOCK_SIZE];
3777    block[0..8].copy_from_slice(&target.to_le_bytes());
3778    for (i, &value) in args.iter().take(8).enumerate() {
3779        let off = 0x10 + i * 8;
3780        block[off..off + 8].copy_from_slice(&value.to_le_bytes());
3781    }
3782    write_verified(backend, pid, param_block, &block, "guest proc param block")?;
3783    tracing::debug!(
3784        pid,
3785        label,
3786        target = format_args!("{target:#x}"),
3787        arg_count = args.len(),
3788        "guest proc call armed"
3789    );
3790    backend.call_iat_hook(pid, hook, trampoline, [param_block, 0, 0, 0], timeout_ms)
3791}
3792
3793#[allow(clippy::too_many_arguments)]
3794fn allocate_sec_image(
3795    backend: &dyn GuestMemoryBackend,
3796    pid: u32,
3797    hook: &GuestIatHook,
3798    virtual_alloc: u64,
3799    stage: u64,
3800    payload_image: &[u8],
3801    timeout_ms: u32,
3802) -> Result<u64, GuestInjectError> {
3803    let trampoline = stage + STAGE_TRAMPOLINE_OFFSET;
3804    let param_block = stage + STAGE_PARAM_OFFSET;
3805    let saved_trampoline = backend.read(pid, trampoline, GUEST_PROC_TRAMPOLINE.len())?;
3806    let get_temp_path = resolve_import_symbol(backend, pid, "kernel32.dll", "GetTempPathW")?;
3807    let create_file = resolve_import_symbol(backend, pid, "kernel32.dll", "CreateFileW")?;
3808    let write_file = resolve_import_symbol(backend, pid, "kernel32.dll", "WriteFile")?;
3809    let close_handle = resolve_import_symbol(backend, pid, "kernel32.dll", "CloseHandle")?;
3810    let virtual_free = resolve_import_symbol(backend, pid, "kernel32.dll", "VirtualFree")?;
3811
3812    let create_file_mapping =
3813        resolve_import_symbol(backend, pid, "kernel32.dll", "CreateFileMappingW")?;
3814    let map_view = resolve_import_symbol(backend, pid, "kernel32.dll", "MapViewOfFile")?;
3815    let unmap_view = resolve_import_symbol(backend, pid, "kernel32.dll", "UnmapViewOfFile")?;
3816
3817    let mut cleanup = SecImageCleanup {
3818        backend,
3819        pid,
3820        hook,
3821        unmap_view,
3822        close_handle,
3823        virtual_free,
3824        trampoline,
3825        saved_trampoline,
3826        timeout_ms,
3827        path_buf: 0,
3828        payload_buf: 0,
3829        file_handle: 0,
3830        mapping_handle: 0,
3831        view_base: 0,
3832    };
3833    write_verified(
3834        backend,
3835        pid,
3836        trampoline,
3837        GUEST_PROC_TRAMPOLINE,
3838        "guest proc trampoline",
3839    )?;
3840
3841    cleanup.path_buf = backend.call_iat_hook(
3842        pid,
3843        hook,
3844        virtual_alloc,
3845        [0, SEC_IMAGE_PATH_BYTES, MEM_COMMIT_RESERVE, PAGE_READWRITE],
3846        timeout_ms,
3847    )?;
3848    if cleanup.path_buf == 0 {
3849        return Err(sec_image_error("path buffer", "VirtualAlloc returned NULL"));
3850    }
3851    cleanup.payload_buf = backend.call_iat_hook(
3852        pid,
3853        hook,
3854        virtual_alloc,
3855        [
3856            0,
3857            payload_image.len() as u64,
3858            MEM_COMMIT_RESERVE,
3859            PAGE_READWRITE,
3860        ],
3861        timeout_ms,
3862    )?;
3863    if cleanup.payload_buf == 0 {
3864        return Err(sec_image_error(
3865            "payload buffer",
3866            "VirtualAlloc returned NULL",
3867        ));
3868    }
3869    backend.touch_iat_hook(
3870        pid,
3871        hook,
3872        cleanup.payload_buf,
3873        payload_image.len(),
3874        timeout_ms,
3875    )?;
3876    write_verified(
3877        backend,
3878        pid,
3879        cleanup.payload_buf,
3880        payload_image,
3881        "guest SEC_IMAGE payload buffer",
3882    )?;
3883
3884    let temp_len = backend.call_iat_hook(
3885        pid,
3886        hook,
3887        get_temp_path,
3888        [SEC_IMAGE_PATH_CHARS, cleanup.path_buf, 0, 0],
3889        timeout_ms,
3890    )?;
3891    if temp_len == 0 || temp_len >= SEC_IMAGE_PATH_CHARS {
3892        return Err(sec_image_error(
3893            "GetTempPathW",
3894            format!("returned unusable length {temp_len}"),
3895        ));
3896    }
3897    let temp_bytes = backend.read(pid, cleanup.path_buf, (temp_len as usize) * 2)?;
3898    let temp_dir = decode_wide(&temp_bytes);
3899    let stamp = SystemTime::now()
3900        .duration_since(UNIX_EPOCH)
3901        .map(|d| d.as_nanos() as u64)
3902        .unwrap_or(0);
3903    let full_path = format!("{temp_dir}decant_{stamp:x}.dll");
3904    let wide_path = encode_wide(&full_path);
3905    if wide_path.len() > SEC_IMAGE_PATH_BYTES as usize {
3906        return Err(sec_image_error(
3907            "temp path",
3908            format!("path buffer too small for {full_path}"),
3909        ));
3910    }
3911    write_verified(
3912        backend,
3913        pid,
3914        cleanup.path_buf,
3915        &wide_path,
3916        "guest SEC_IMAGE wide path",
3917    )?;
3918
3919    let write_len = u32::try_from(payload_image.len())
3920        .map_err(|_| sec_image_error("WriteFile", "payload exceeds DWORD byte count"))?;
3921    let written_slot = cleanup.path_buf;
3922    cleanup.file_handle = call_guest_proc(
3923        backend,
3924        pid,
3925        hook,
3926        trampoline,
3927        param_block,
3928        create_file,
3929        &[
3930            cleanup.path_buf,
3931            GENERIC_READ | GENERIC_WRITE,
3932            0,
3933            0,
3934            CREATE_ALWAYS,
3935            FILE_ATTRIBUTE_NORMAL | FILE_FLAG_DELETE_ON_CLOSE,
3936            0,
3937        ],
3938        timeout_ms,
3939        "CreateFileW",
3940    )?;
3941    let invalid_handle = u64::MAX;
3942    if cleanup.file_handle == 0 || cleanup.file_handle == invalid_handle {
3943        return Err(sec_image_error(
3944            "CreateFileW",
3945            format!("returned invalid handle for {full_path}"),
3946        ));
3947    }
3948    write_verified(
3949        backend,
3950        pid,
3951        written_slot,
3952        &[0; 8],
3953        "guest SEC_IMAGE WriteFile byte-count slot",
3954    )?;
3955    let write_ok = call_guest_proc(
3956        backend,
3957        pid,
3958        hook,
3959        trampoline,
3960        param_block,
3961        write_file,
3962        &[
3963            cleanup.file_handle,
3964            cleanup.payload_buf,
3965            u64::from(write_len),
3966            written_slot,
3967            0,
3968        ],
3969        timeout_ms,
3970        "WriteFile",
3971    )?;
3972    if write_ok == 0 {
3973        return Err(sec_image_error(
3974            "WriteFile",
3975            format!("returned FALSE for {full_path}"),
3976        ));
3977    }
3978    let written = read_remote_u32(backend, pid, written_slot)?;
3979    if written != write_len {
3980        return Err(sec_image_error(
3981            "WriteFile",
3982            format!(
3983                "wrote {written} of {} bytes for {full_path}",
3984                payload_image.len()
3985            ),
3986        ));
3987    }
3988    let _ = backend.call_iat_hook(
3989        pid,
3990        hook,
3991        virtual_free,
3992        [cleanup.payload_buf, 0, MEM_RELEASE, 0],
3993        timeout_ms,
3994    );
3995    cleanup.payload_buf = 0;
3996
3997    cleanup.mapping_handle = call_guest_proc(
3998        backend,
3999        pid,
4000        hook,
4001        trampoline,
4002        param_block,
4003        create_file_mapping,
4004        &[cleanup.file_handle, 0, PAGE_READONLY | SEC_IMAGE, 0, 0, 0],
4005        timeout_ms,
4006        "CreateFileMappingW",
4007    )?;
4008    if cleanup.mapping_handle == 0 {
4009        return Err(sec_image_error(
4010            "CreateFileMappingW",
4011            format!("returned NULL for {full_path}"),
4012        ));
4013    }
4014    cleanup.view_base = call_guest_proc(
4015        backend,
4016        pid,
4017        hook,
4018        trampoline,
4019        param_block,
4020        map_view,
4021        &[cleanup.mapping_handle, FILE_MAP_COPY, 0, 0, 0],
4022        timeout_ms,
4023        "MapViewOfFile",
4024    )?;
4025    let _ = backend.call_iat_hook(
4026        pid,
4027        hook,
4028        close_handle,
4029        [cleanup.mapping_handle, 0, 0, 0],
4030        timeout_ms,
4031    );
4032    cleanup.mapping_handle = 0;
4033    let _ = backend.call_iat_hook(
4034        pid,
4035        hook,
4036        close_handle,
4037        [cleanup.file_handle, 0, 0, 0],
4038        timeout_ms,
4039    );
4040    cleanup.file_handle = 0;
4041    let _ = backend.call_iat_hook(
4042        pid,
4043        hook,
4044        virtual_free,
4045        [cleanup.path_buf, 0, MEM_RELEASE, 0],
4046        timeout_ms,
4047    );
4048    cleanup.path_buf = 0;
4049    if cleanup.view_base == 0 {
4050        return Err(GuestInjectError::Backend(format!(
4051            "guest MapViewOfFile(SEC_IMAGE, {full_path}) returned NULL"
4052        )));
4053    }
4054    let view_base = cleanup.view_base;
4055    cleanup.view_base = 0;
4056    tracing::info!(
4057        pid,
4058        remote_base = format_args!("{view_base:#x}"),
4059        path = %full_path,
4060        bytes = payload_image.len(),
4061        "guest SEC_IMAGE mapping installed; file and mapping handles released, trampoline restored, view retained"
4062    );
4063    Ok(view_base)
4064}
4065
4066#[allow(clippy::too_many_arguments)]
4067fn synthesize_peb_loader_entry(
4068    backend: &dyn GuestMemoryBackend,
4069    pid: u32,
4070    hook: &GuestIatHook,
4071    virtual_alloc: u64,
4072    stage: u64,
4073    remote_base: u64,
4074    entry_point: u64,
4075    size_of_image: u32,
4076    dll_name: &str,
4077    timeout_ms: u32,
4078) -> Result<u64, GuestInjectError> {
4079    let trampoline = stage + STAGE_TRAMPOLINE_OFFSET;
4080    let saved = backend.read(pid, trampoline, GUEST_GET_PEB_TRAMPOLINE.len())?;
4081    write_verified(
4082        backend,
4083        pid,
4084        trampoline,
4085        GUEST_GET_PEB_TRAMPOLINE,
4086        "PEB lookup trampoline",
4087    )?;
4088
4089    let peb = backend.call_iat_hook(pid, hook, trampoline, [0, 0, 0, 0], timeout_ms)?;
4090    let _ = backend.write(pid, trampoline, &saved);
4091    if peb == 0 {
4092        return Err(GuestInjectError::Backend(
4093            "PEB synthesis: guest PEB lookup returned null".into(),
4094        ));
4095    }
4096    let ldr_bytes = backend.read(pid, peb + 0x18, 8)?;
4097    let ldr = u64::from_le_bytes(ldr_bytes[0..8].try_into().unwrap());
4098    if ldr == 0 {
4099        return Err(GuestInjectError::Backend(format!(
4100            "PEB Ldr is null for guest PEB {peb:#x}"
4101        )));
4102    }
4103
4104    let entry_size = 0x830u64;
4105    let entry = backend.call_iat_hook(
4106        pid,
4107        hook,
4108        virtual_alloc,
4109        [0, entry_size, MEM_COMMIT_RESERVE, PAGE_READWRITE],
4110        timeout_ms,
4111    )?;
4112    if entry == 0 {
4113        let _ = backend.write(pid, trampoline, &saved);
4114        return Err(GuestInjectError::Backend(
4115            "PEB synthesis: VirtualAlloc for LDR entry returned NULL".into(),
4116        ));
4117    }
4118    backend.touch_iat_hook(pid, hook, entry, entry_size as usize, timeout_ms)?;
4119
4120    let mut entry_data = vec![0u8; entry_size as usize];
4121    entry_data[0x30..0x38].copy_from_slice(&remote_base.to_le_bytes());
4122    entry_data[0x38..0x40].copy_from_slice(&entry_point.to_le_bytes());
4123    entry_data[0x40..0x44].copy_from_slice(&size_of_image.to_le_bytes());
4124    let init_links = entry + 0x20;
4125    entry_data[0x20..0x28].copy_from_slice(&init_links.to_le_bytes());
4126    entry_data[0x28..0x30].copy_from_slice(&init_links.to_le_bytes());
4127
4128    let base_name_wide = encode_wide(dll_name);
4129    let full_name = format!("C:\\Windows\\System32\\{dll_name}");
4130    let full_name_wide = encode_wide(&full_name);
4131    let base_name_offset = 0x200usize;
4132    let full_name_offset = 0x410usize;
4133    entry_data[base_name_offset..base_name_offset + base_name_wide.len()]
4134        .copy_from_slice(&base_name_wide);
4135    entry_data[full_name_offset..full_name_offset + full_name_wide.len()]
4136        .copy_from_slice(&full_name_wide);
4137
4138    let base_name_len = (base_name_wide.len() - 2) as u16;
4139    let full_name_len = (full_name_wide.len() - 2) as u16;
4140    entry_data[0x48..0x4A].copy_from_slice(&full_name_len.to_le_bytes());
4141    entry_data[0x4A..0x4C].copy_from_slice(&(full_name_wide.len() as u16).to_le_bytes());
4142    entry_data[0x50..0x58].copy_from_slice(&(entry + full_name_offset as u64).to_le_bytes());
4143    entry_data[0x58..0x5A].copy_from_slice(&base_name_len.to_le_bytes());
4144    entry_data[0x5A..0x5C].copy_from_slice(&(base_name_wide.len() as u16).to_le_bytes());
4145    entry_data[0x60..0x68].copy_from_slice(&(entry + base_name_offset as u64).to_le_bytes());
4146
4147    write_verified(backend, pid, entry, &entry_data, "LDR_DATA_TABLE_ENTRY")?;
4148
4149    let list_insert = |list_head: u64, links_offset: u64| -> Result<(), GuestInjectError> {
4150        let links = entry + links_offset;
4151        let head_blink_bytes = backend.read(pid, list_head + 8, 8)?;
4152        let head_blink = u64::from_le_bytes(head_blink_bytes[0..8].try_into().unwrap());
4153        let flink = list_head.to_le_bytes();
4154        let blink = head_blink.to_le_bytes();
4155        backend.write(pid, links, &flink)?;
4156        backend.write(pid, links + 8, &blink)?;
4157        backend.write(pid, head_blink, &links.to_le_bytes())?;
4158        backend.write(pid, list_head + 8, &links.to_le_bytes())?;
4159        Ok(())
4160    };
4161    list_insert(ldr + 0x10, 0x00)?;
4162    list_insert(ldr + 0x20, 0x10)?;
4163
4164    let _ = backend.write(pid, trampoline, &saved);
4165    tracing::info!(
4166        pid,
4167        peb = format_args!("{peb:#x}"),
4168        ldr = format_args!("{ldr:#x}"),
4169        entry = format_args!("{entry:#x}"),
4170        remote_base = format_args!("{remote_base:#x}"),
4171        "synthesized PEB loader entry linked into InLoadOrder and InMemoryOrder lists"
4172    );
4173    Ok(entry)
4174}
4175
4176fn unlink_synthesized_peb_loader_entry(
4177    backend: &dyn GuestMemoryBackend,
4178    pid: u32,
4179    entry: u64,
4180) -> Result<(), GuestInjectError> {
4181    unlink_guest_list_entry(backend, pid, entry)?;
4182    unlink_guest_list_entry(backend, pid, entry + 0x10)?;
4183    tracing::info!(
4184        pid,
4185        entry = format_args!("{entry:#x}"),
4186        "unlinked synthesized PEB loader entry from load and memory lists"
4187    );
4188    Ok(())
4189}
4190
4191fn unlink_guest_list_entry(
4192    backend: &dyn GuestMemoryBackend,
4193    pid: u32,
4194    links: u64,
4195) -> Result<(), GuestInjectError> {
4196    let bytes = backend.read(pid, links, 16)?;
4197    let flink = u64::from_le_bytes(bytes[0..8].try_into().unwrap());
4198    let blink = u64::from_le_bytes(bytes[8..16].try_into().unwrap());
4199    if flink == 0 || blink == 0 {
4200        return Err(GuestInjectError::Backend(format!(
4201            "PEB synthesis cleanup: list entry {links:#x} has null links"
4202        )));
4203    }
4204    backend.write(pid, flink + 8, &blink.to_le_bytes())?;
4205    backend.write(pid, blink, &flink.to_le_bytes())?;
4206    backend.write(pid, links, &links.to_le_bytes())?;
4207    backend.write(pid, links + 8, &links.to_le_bytes())?;
4208    Ok(())
4209}
4210
4211#[allow(clippy::too_many_arguments)]
4212fn guest_virtual_protect(
4213    backend: &dyn GuestMemoryBackend,
4214    pid: u32,
4215    hook: &GuestIatHook,
4216    virtual_protect: u64,
4217    addr: u64,
4218    size: u64,
4219    protect: u64,
4220    old_protect: u64,
4221    timeout_ms: u32,
4222    label: &str,
4223) -> Result<(), GuestInjectError> {
4224    tracing::debug!(
4225        pid,
4226        label,
4227        addr = format_args!("{addr:#x}"),
4228        size,
4229        protect = format_args!("{protect:#x}"),
4230        "calling guest VirtualProtect"
4231    );
4232    let ok = backend.call_iat_hook(
4233        pid,
4234        hook,
4235        virtual_protect,
4236        [addr, size, protect, old_protect],
4237        timeout_ms,
4238    )?;
4239    if ok == 0 {
4240        return Err(GuestInjectError::Backend(format!(
4241            "guest VirtualProtect({label} at {addr:#x}, size {size:#x}, protect {protect:#x}) returned FALSE"
4242        )));
4243    }
4244    Ok(())
4245}
4246
4247fn guest_section_size(section: &Section) -> u32 {
4248    match section.virtual_size {
4249        0 => section.raw_size,
4250        v => v,
4251    }
4252}
4253
4254fn guest_section_protect(characteristics: u32) -> u64 {
4255    let executable = characteristics & IMAGE_SCN_MEM_EXECUTE != 0;
4256    let readable = characteristics & IMAGE_SCN_MEM_READ != 0;
4257    let writable = characteristics & IMAGE_SCN_MEM_WRITE != 0;
4258    match (executable, readable, writable) {
4259        (true, _, true) => PAGE_EXECUTE_READWRITE,
4260        (true, true, false) => PAGE_EXECUTE_READ,
4261        (true, false, false) => PAGE_EXECUTE,
4262        (false, _, true) => PAGE_READWRITE,
4263        (false, true, false) => PAGE_READONLY,
4264        (false, false, false) => PAGE_NOACCESS,
4265    }
4266}
4267
4268fn write_verified<B: GuestMemoryBackend + ?Sized>(
4269    backend: &B,
4270    pid: u32,
4271    addr: u64,
4272    data: &[u8],
4273    label: &str,
4274) -> Result<(), GuestInjectError> {
4275    backend.write(pid, addr, data)?;
4276    let got = backend.read(pid, addr, data.len())?;
4277    if got != data {
4278        return Err(GuestInjectError::Backend(format!(
4279            "guest write verification failed for {label} at {addr:#x}: wrote {} bytes",
4280            data.len()
4281        )));
4282    }
4283    Ok(())
4284}
4285
4286fn sec_image_patched_ranges(image: &[u8], snapshot: &[u8]) -> Vec<(usize, usize)> {
4287    debug_assert_eq!(image.len(), snapshot.len());
4288    let len = image.len().min(snapshot.len());
4289    let mut ranges = Vec::new();
4290    let mut i = 0usize;
4291    while i < len {
4292        if image[i] == snapshot[i] {
4293            i += 1;
4294            continue;
4295        }
4296
4297        let range_start = i & !(GUEST_PAGE_SIZE - 1);
4298        let mut range_end = (range_start + GUEST_PAGE_SIZE).min(len);
4299        let mut scan = range_end;
4300        while scan < len {
4301            let page_end = (scan + GUEST_PAGE_SIZE).min(len);
4302            if image[scan..page_end]
4303                .iter()
4304                .zip(&snapshot[scan..page_end])
4305                .any(|(image, snapshot)| image != snapshot)
4306            {
4307                range_end = page_end;
4308                scan = page_end;
4309            } else {
4310                break;
4311            }
4312        }
4313        ranges.push((range_start, range_end));
4314        i = range_end;
4315    }
4316    ranges
4317}
4318
4319fn call_stub(hook: &GuestIatHook, function: u64, args: [u64; 4]) -> Vec<u8> {
4320    if hook.call_stack == GuestCallStackPolicy::RegisteredUnwind {
4321        return framed_call_stub(hook, function, args);
4322    }
4323    let mut code = X64Stub::new();
4324    preserve_import_args(&mut code);
4325    code.mov_abs(Reg64::R10, hook.result_addr);
4326    code.xor_eax_eax();
4327    code.mov_abs(Reg64::R11, RESULT_RUNNING);
4328    code.lock_cmpxchg_rax_at_r10_with_r11();
4329    let skip_call = code.jne_rel32_placeholder();
4330
4331    code.mov_abs(Reg64::Rcx, args[0]);
4332    code.mov_abs(Reg64::Rdx, args[1]);
4333    code.mov_abs(Reg64::R8, args[2]);
4334    code.mov_abs(Reg64::R9, args[3]);
4335    match hook.spoofed_return {
4336        Some(gadget) => emit_spoofed_call(&mut code, hook, function, gadget, 0),
4337        None => {
4338            code.mov_abs(Reg64::Rax, function);
4339            code.call_rax_windows_x64();
4340        }
4341    }
4342    code.mov_abs(Reg64::R10, hook.result_addr + 8);
4343    code.store_rax_at_r10();
4344    code.mov_abs(Reg64::R10, hook.result_addr);
4345    code.mov_abs(Reg64::Rax, RESULT_STATE);
4346    code.store_rax_at_r10();
4347
4348    let tail_original = code.len();
4349    code.patch_rel32(skip_call, tail_original);
4350    tail_jump_original_import(&mut code, hook.original_target);
4351    code.finish()
4352}
4353
4354fn framed_call_stub(hook: &GuestIatHook, function: u64, args: [u64; 4]) -> Vec<u8> {
4355    let mut code = X64Stub::new();
4356    framed_prologue(&mut code);
4357    code.mov_abs(Reg64::R10, hook.result_addr);
4358    code.xor_eax_eax();
4359    code.mov_abs(Reg64::R11, RESULT_RUNNING);
4360    code.lock_cmpxchg_rax_at_r10_with_r11();
4361    let skip_call = code.jne_rel32_placeholder();
4362
4363    code.mov_abs(Reg64::Rcx, args[0]);
4364    code.mov_abs(Reg64::Rdx, args[1]);
4365    code.mov_abs(Reg64::R8, args[2]);
4366    code.mov_abs(Reg64::R9, args[3]);
4367    match hook.spoofed_return {
4368        Some(gadget) => emit_spoofed_call(&mut code, hook, function, gadget, 8),
4369        None => {
4370            code.mov_abs(Reg64::Rax, function);
4371            code.call_rax_with_current_frame();
4372        }
4373    }
4374    code.mov_abs(Reg64::R10, hook.result_addr + 8);
4375    code.store_rax_at_r10();
4376    code.mov_abs(Reg64::R10, hook.result_addr);
4377    code.mov_abs(Reg64::Rax, RESULT_STATE);
4378    code.store_rax_at_r10();
4379
4380    let tail_original = code.len();
4381    code.patch_rel32(skip_call, tail_original);
4382    framed_tail_jump_original_import(&mut code, hook.original_target);
4383    code.finish()
4384}
4385
4386fn touch_stub(hook: &GuestIatHook, addr: u64, len: usize) -> Vec<u8> {
4387    if hook.call_stack == GuestCallStackPolicy::RegisteredUnwind {
4388        return framed_touch_stub(hook, addr, len, TouchMode::WriteZero);
4389    }
4390    let mut code = X64Stub::new();
4391    preserve_import_args(&mut code);
4392    code.mov_abs(Reg64::R10, hook.result_addr);
4393    code.xor_eax_eax();
4394    code.mov_abs(Reg64::R11, RESULT_RUNNING);
4395    code.lock_cmpxchg_rax_at_r10_with_r11();
4396    let skip_touch = code.jne_rel32_placeholder();
4397
4398    emit_touch_loop(&mut code, addr, len, TouchMode::WriteZero);
4399
4400    code.mov_abs(Reg64::R10, hook.result_addr + 8);
4401    code.mov_abs(Reg64::Rax, addr);
4402    code.store_rax_at_r10();
4403    code.mov_abs(Reg64::R10, hook.result_addr);
4404    code.mov_abs(Reg64::Rax, RESULT_STATE);
4405    code.store_rax_at_r10();
4406
4407    let tail_original = code.len();
4408    code.patch_rel32(skip_touch, tail_original);
4409    tail_jump_original_import(&mut code, hook.original_target);
4410    code.finish()
4411}
4412
4413fn read_touch_stub(hook: &GuestIatHook, addr: u64, len: usize) -> Vec<u8> {
4414    if hook.call_stack == GuestCallStackPolicy::RegisteredUnwind {
4415        return framed_touch_stub(hook, addr, len, TouchMode::ReadOnly);
4416    }
4417    touch_mode_stub(hook, addr, len, TouchMode::ReadOnly)
4418}
4419
4420fn preserve_touch_stub(hook: &GuestIatHook, addr: u64, len: usize) -> Vec<u8> {
4421    if hook.call_stack == GuestCallStackPolicy::RegisteredUnwind {
4422        return framed_touch_stub(hook, addr, len, TouchMode::WriteSame);
4423    }
4424    touch_mode_stub(hook, addr, len, TouchMode::WriteSame)
4425}
4426
4427fn touch_mode_stub(hook: &GuestIatHook, addr: u64, len: usize, mode: TouchMode) -> Vec<u8> {
4428    let mut code = X64Stub::new();
4429    preserve_import_args(&mut code);
4430    code.mov_abs(Reg64::R10, hook.result_addr);
4431    code.xor_eax_eax();
4432    code.mov_abs(Reg64::R11, RESULT_RUNNING);
4433    code.lock_cmpxchg_rax_at_r10_with_r11();
4434    let skip_touch = code.jne_rel32_placeholder();
4435
4436    emit_touch_loop(&mut code, addr, len, mode);
4437
4438    code.mov_abs(Reg64::R10, hook.result_addr + 8);
4439    code.mov_abs(Reg64::Rax, addr);
4440    code.store_rax_at_r10();
4441    code.mov_abs(Reg64::R10, hook.result_addr);
4442    code.mov_abs(Reg64::Rax, RESULT_STATE);
4443    code.store_rax_at_r10();
4444
4445    let tail_original = code.len();
4446    code.patch_rel32(skip_touch, tail_original);
4447    tail_jump_original_import(&mut code, hook.original_target);
4448    code.finish()
4449}
4450
4451#[derive(Clone, Copy)]
4452enum TouchMode {
4453    WriteZero,
4454    ReadOnly,
4455    WriteSame,
4456}
4457
4458fn framed_touch_stub(hook: &GuestIatHook, addr: u64, len: usize, mode: TouchMode) -> Vec<u8> {
4459    let mut code = X64Stub::new();
4460    framed_prologue(&mut code);
4461    code.mov_abs(Reg64::R10, hook.result_addr);
4462    code.xor_eax_eax();
4463    code.mov_abs(Reg64::R11, RESULT_RUNNING);
4464    code.lock_cmpxchg_rax_at_r10_with_r11();
4465    let skip_touch = code.jne_rel32_placeholder();
4466
4467    emit_touch_loop(&mut code, addr, len, mode);
4468
4469    code.mov_abs(Reg64::R10, hook.result_addr + 8);
4470    code.mov_abs(Reg64::Rax, addr);
4471    code.store_rax_at_r10();
4472    code.mov_abs(Reg64::R10, hook.result_addr);
4473    code.mov_abs(Reg64::Rax, RESULT_STATE);
4474    code.store_rax_at_r10();
4475
4476    let tail_original = code.len();
4477    code.patch_rel32(skip_touch, tail_original);
4478    framed_tail_jump_original_import(&mut code, hook.original_target);
4479    code.finish()
4480}
4481
4482fn emit_touch_loop(code: &mut X64Stub, addr: u64, len: usize, mode: TouchMode) {
4483    let page_count = len.div_ceil(GUEST_PAGE_SIZE) as u64;
4484    if page_count == 0 {
4485        return;
4486    }
4487    code.mov_abs(Reg64::Rdx, addr);
4488    code.mov_abs(Reg64::Rcx, page_count);
4489    let touch_loop = code.len();
4490    match mode {
4491        TouchMode::WriteZero => code.mov_byte_zero_at_rdx(),
4492        TouchMode::ReadOnly => code.movzx_eax_byte_at_rdx(),
4493        TouchMode::WriteSame => {
4494            code.movzx_eax_byte_at_rdx();
4495            code.mov_al_at_rdx();
4496        }
4497    }
4498    code.add_rdx_imm32(GUEST_PAGE_SIZE as u32);
4499    code.dec_rcx();
4500    let repeat = code.jne_rel32_placeholder();
4501    code.patch_rel32(repeat, touch_loop);
4502}
4503
4504fn preserve_import_args(code: &mut X64Stub) {
4505    code.push(Reg64::Rcx);
4506    code.push(Reg64::Rdx);
4507    code.push(Reg64::R8);
4508    code.push(Reg64::R9);
4509}
4510
4511fn emit_spoofed_call(
4512    code: &mut X64Stub,
4513    hook: &GuestIatHook,
4514    function: u64,
4515    gadget: GuestSpoofedReturn,
4516    desired_frame_mod: u8,
4517) {
4518    let (frame_size, post_add) = spoofed_stack_frame(gadget.stack_adjust, desired_frame_mod);
4519    let landing = code.len();
4520    let landing_addr = hook.stub_addr + landing as u64 + SPOOFED_CALL_LANDING_DELTA;
4521    code.sub_rsp(frame_size);
4522    code.mov_abs(Reg64::R11, gadget.gadget_addr);
4523    code.store_reg_at_rsp_disp(Reg64::R11, 0);
4524    code.mov_abs(Reg64::Rax, landing_addr);
4525    code.store_reg_at_rsp_disp(Reg64::Rax, 8 + gadget.stack_adjust);
4526    code.mov_abs(Reg64::Rax, function);
4527    code.jmp_rax();
4528    if post_add != 0 {
4529        code.add_rsp(post_add);
4530    }
4531}
4532
4533fn spoofed_stack_frame(stack_adjust: u8, desired_frame_mod: u8) -> (u8, u8) {
4534    let base = 16u16 + u16::from(stack_adjust);
4535    let desired = u16::from(desired_frame_mod);
4536    let post_add = (16 + desired - (base % 16)) % 16;
4537    let frame_size = base + post_add;
4538    (
4539        u8::try_from(frame_size).expect("spoofed stack frame fits in imm8"),
4540        u8::try_from(post_add).expect("spoofed stack fixup fits in imm8"),
4541    )
4542}
4543
4544fn tail_jump_original_import(code: &mut X64Stub, original_target: u64) {
4545    code.pop(Reg64::R9);
4546    code.pop(Reg64::R8);
4547    code.pop(Reg64::Rdx);
4548    code.pop(Reg64::Rcx);
4549    code.mov_abs(Reg64::Rax, original_target);
4550    code.jmp_rax();
4551}
4552
4553fn framed_prologue(code: &mut X64Stub) {
4554    code.sub_rsp(FRAMED_STUB_STACK_ALLOC);
4555    code.store_reg_at_rsp_disp(Reg64::Rcx, 0x28);
4556    code.store_reg_at_rsp_disp(Reg64::Rdx, 0x30);
4557    code.store_reg_at_rsp_disp(Reg64::R8, 0x38);
4558    code.store_reg_at_rsp_disp(Reg64::R9, 0x40);
4559}
4560
4561fn framed_tail_jump_original_import(code: &mut X64Stub, original_target: u64) {
4562    code.load_reg_from_rsp_disp(Reg64::Rcx, 0x28);
4563    code.load_reg_from_rsp_disp(Reg64::Rdx, 0x30);
4564    code.load_reg_from_rsp_disp(Reg64::R8, 0x38);
4565    code.load_reg_from_rsp_disp(Reg64::R9, 0x40);
4566    code.add_rsp(FRAMED_STUB_STACK_ALLOC);
4567    code.mov_abs(Reg64::Rax, original_target);
4568    code.jmp_rax();
4569}
4570
4571#[derive(Clone, Copy)]
4572enum Reg64 {
4573    Rax,
4574    Rcx,
4575    Rdx,
4576    R8,
4577    R9,
4578    R10,
4579    R11,
4580}
4581
4582struct X64Stub {
4583    bytes: Vec<u8>,
4584}
4585
4586impl X64Stub {
4587    fn new() -> Self {
4588        Self {
4589            bytes: Vec::with_capacity(192),
4590        }
4591    }
4592
4593    fn finish(self) -> Vec<u8> {
4594        self.bytes
4595    }
4596
4597    fn len(&self) -> usize {
4598        self.bytes.len()
4599    }
4600
4601    fn mov_abs(&mut self, reg: Reg64, value: u64) {
4602        match reg {
4603            Reg64::Rax => self.bytes.extend_from_slice(&[0x48, 0xB8]),
4604            Reg64::Rcx => self.bytes.extend_from_slice(&[0x48, 0xB9]),
4605            Reg64::Rdx => self.bytes.extend_from_slice(&[0x48, 0xBA]),
4606            Reg64::R8 => self.bytes.extend_from_slice(&[0x49, 0xB8]),
4607            Reg64::R9 => self.bytes.extend_from_slice(&[0x49, 0xB9]),
4608            Reg64::R10 => self.bytes.extend_from_slice(&[0x49, 0xBA]),
4609            Reg64::R11 => self.bytes.extend_from_slice(&[0x49, 0xBB]),
4610        }
4611        self.bytes.extend_from_slice(&value.to_le_bytes());
4612    }
4613
4614    fn store_rax_at_r10(&mut self) {
4615        self.bytes.extend_from_slice(&[0x49, 0x89, 0x02]);
4616    }
4617
4618    fn push(&mut self, reg: Reg64) {
4619        match reg {
4620            Reg64::Rax => self.bytes.push(0x50),
4621            Reg64::Rcx => self.bytes.push(0x51),
4622            Reg64::Rdx => self.bytes.push(0x52),
4623            Reg64::R8 => self.bytes.extend_from_slice(&[0x41, 0x50]),
4624            Reg64::R9 => self.bytes.extend_from_slice(&[0x41, 0x51]),
4625            Reg64::R10 => self.bytes.extend_from_slice(&[0x41, 0x52]),
4626            Reg64::R11 => self.bytes.extend_from_slice(&[0x41, 0x53]),
4627        }
4628    }
4629
4630    fn pop(&mut self, reg: Reg64) {
4631        match reg {
4632            Reg64::Rax => self.bytes.push(0x58),
4633            Reg64::Rcx => self.bytes.push(0x59),
4634            Reg64::Rdx => self.bytes.push(0x5A),
4635            Reg64::R8 => self.bytes.extend_from_slice(&[0x41, 0x58]),
4636            Reg64::R9 => self.bytes.extend_from_slice(&[0x41, 0x59]),
4637            Reg64::R10 => self.bytes.extend_from_slice(&[0x41, 0x5A]),
4638            Reg64::R11 => self.bytes.extend_from_slice(&[0x41, 0x5B]),
4639        }
4640    }
4641
4642    fn mov_byte_zero_at_rdx(&mut self) {
4643        self.bytes.extend_from_slice(&[0xC6, 0x02, 0x00]);
4644    }
4645
4646    fn movzx_eax_byte_at_rdx(&mut self) {
4647        self.bytes.extend_from_slice(&[0x0F, 0xB6, 0x02]);
4648    }
4649
4650    fn mov_al_at_rdx(&mut self) {
4651        self.bytes.extend_from_slice(&[0x88, 0x02]);
4652    }
4653
4654    fn xor_eax_eax(&mut self) {
4655        self.bytes.extend_from_slice(&[0x31, 0xC0]);
4656    }
4657
4658    fn lock_cmpxchg_rax_at_r10_with_r11(&mut self) {
4659        self.bytes
4660            .extend_from_slice(&[0xF0, 0x4D, 0x0F, 0xB1, 0x1A]);
4661    }
4662
4663    fn jne_rel32_placeholder(&mut self) -> usize {
4664        self.bytes.extend_from_slice(&[0x0F, 0x85, 0, 0, 0, 0]);
4665        self.bytes.len() - 4
4666    }
4667
4668    fn patch_rel32(&mut self, imm_offset: usize, target: usize) {
4669        let next = imm_offset + 4;
4670        let rel = (target as isize)
4671            .checked_sub(next as isize)
4672            .expect("stub branch target fits in isize");
4673        let rel = i32::try_from(rel).expect("stub branch target fits in rel32");
4674        self.bytes[imm_offset..imm_offset + 4].copy_from_slice(&rel.to_le_bytes());
4675    }
4676
4677    fn call_rax_windows_x64(&mut self) {
4678        self.sub_rsp(0x28);
4679        self.bytes.extend_from_slice(&[0xFF, 0xD0]);
4680        self.add_rsp(0x28);
4681    }
4682
4683    fn call_rax_with_current_frame(&mut self) {
4684        self.bytes.extend_from_slice(&[0xFF, 0xD0]);
4685    }
4686
4687    fn sub_rsp(&mut self, amount: u8) {
4688        self.bytes.extend_from_slice(&[0x48, 0x83, 0xEC, amount]);
4689    }
4690
4691    fn add_rsp(&mut self, amount: u8) {
4692        self.bytes.extend_from_slice(&[0x48, 0x83, 0xC4, amount]);
4693    }
4694
4695    fn add_rdx_imm32(&mut self, amount: u32) {
4696        self.bytes.extend_from_slice(&[0x48, 0x81, 0xC2]);
4697        self.bytes.extend_from_slice(&amount.to_le_bytes());
4698    }
4699
4700    fn jmp_rax(&mut self) {
4701        self.bytes.extend_from_slice(&[0xFF, 0xE0]);
4702    }
4703
4704    fn dec_rcx(&mut self) {
4705        self.bytes.extend_from_slice(&[0x48, 0xFF, 0xC9]);
4706    }
4707
4708    fn store_reg_at_rsp_disp(&mut self, reg: Reg64, disp: u8) {
4709        match reg {
4710            Reg64::Rax => self
4711                .bytes
4712                .extend_from_slice(&[0x48, 0x89, 0x44, 0x24, disp]),
4713            Reg64::Rcx => self
4714                .bytes
4715                .extend_from_slice(&[0x48, 0x89, 0x4C, 0x24, disp]),
4716            Reg64::Rdx => self
4717                .bytes
4718                .extend_from_slice(&[0x48, 0x89, 0x54, 0x24, disp]),
4719            Reg64::R8 => self
4720                .bytes
4721                .extend_from_slice(&[0x4C, 0x89, 0x44, 0x24, disp]),
4722            Reg64::R9 => self
4723                .bytes
4724                .extend_from_slice(&[0x4C, 0x89, 0x4C, 0x24, disp]),
4725            Reg64::R11 => self
4726                .bytes
4727                .extend_from_slice(&[0x4C, 0x89, 0x5C, 0x24, disp]),
4728            _ => unreachable!("unsupported stack spill register"),
4729        }
4730    }
4731
4732    fn load_reg_from_rsp_disp(&mut self, reg: Reg64, disp: u8) {
4733        match reg {
4734            Reg64::Rcx => self
4735                .bytes
4736                .extend_from_slice(&[0x48, 0x8B, 0x4C, 0x24, disp]),
4737            Reg64::Rdx => self
4738                .bytes
4739                .extend_from_slice(&[0x48, 0x8B, 0x54, 0x24, disp]),
4740            Reg64::R8 => self
4741                .bytes
4742                .extend_from_slice(&[0x4C, 0x8B, 0x44, 0x24, disp]),
4743            Reg64::R9 => self
4744                .bytes
4745                .extend_from_slice(&[0x4C, 0x8B, 0x4C, 0x24, disp]),
4746            _ => unreachable!("unsupported stack reload register"),
4747        }
4748    }
4749}
4750
4751#[cfg(test)]
4752fn windows_x64_call_sequence_offset(bytes: &[u8]) -> Option<usize> {
4753    bytes
4754        .windows(10)
4755        .position(|w| w == [0x48, 0x83, 0xEC, 0x28, 0xFF, 0xD0, 0x48, 0x83, 0xC4, 0x28])
4756}
4757
4758fn find_stage(
4759    backend: &dyn GuestMemoryBackend,
4760    pid: u32,
4761    pattern: Option<&GuestBytePattern>,
4762) -> Result<u64, GuestInjectError> {
4763    if let Some(pattern) = pattern
4764        && let Some(addr) = find_stage_pattern(backend, pid, pattern)?
4765    {
4766        return Ok(addr);
4767    }
4768    find_writable_executable_code_cave(backend, pid, STAGE_CAVE_SIZE)
4769}
4770
4771fn find_stage_pattern(
4772    backend: &dyn GuestMemoryBackend,
4773    pid: u32,
4774    pattern: &GuestBytePattern,
4775) -> Result<Option<u64>, GuestInjectError> {
4776    for region in backend.memory_map(pid)? {
4777        if !region.readable || !region.executable || region.size < pattern.len() as u64 {
4778            continue;
4779        }
4780        let mut pos = 0u64;
4781        while pos < region.size {
4782            let len = (region.size - pos).min(SCAN_CHUNK + pattern.len() as u64) as usize;
4783            let addr = region.base + pos;
4784            if let Ok(bytes) = backend.read(pid, addr, len)
4785                && let Some(off) = pattern.find_in(&bytes)
4786            {
4787                return Ok(Some(addr + off as u64));
4788            }
4789            pos += SCAN_CHUNK;
4790        }
4791    }
4792    Ok(None)
4793}
4794
4795fn find_result_block(
4796    backend: &dyn GuestMemoryBackend,
4797    pid: u32,
4798    pattern: Option<&GuestBytePattern>,
4799    fallback: u64,
4800) -> Result<u64, GuestInjectError> {
4801    if let Some(pattern) = pattern
4802        && let Some(addr) = find_writable_pattern(backend, pid, pattern)?
4803    {
4804        return Ok(addr);
4805    }
4806    validate_result_region(backend, pid, fallback)?;
4807    Ok(fallback)
4808}
4809
4810fn find_writable_pattern(
4811    backend: &dyn GuestMemoryBackend,
4812    pid: u32,
4813    pattern: &GuestBytePattern,
4814) -> Result<Option<u64>, GuestInjectError> {
4815    for region in backend.memory_map(pid)? {
4816        if !region.readable || !region.writable || region.size < pattern.len() as u64 {
4817            continue;
4818        }
4819        let mut pos = 0u64;
4820        while pos < region.size {
4821            let len = (region.size - pos).min(SCAN_CHUNK + pattern.len() as u64) as usize;
4822            let addr = region.base + pos;
4823            if let Ok(bytes) = backend.read(pid, addr, len)
4824                && let Some(off) = pattern.find_in(&bytes)
4825            {
4826                return Ok(Some(addr + off as u64));
4827            }
4828            pos += SCAN_CHUNK;
4829        }
4830    }
4831    Ok(None)
4832}
4833
4834fn validate_stub_region(
4835    backend: &dyn GuestMemoryBackend,
4836    pid: u32,
4837    stage: u64,
4838    size: usize,
4839) -> Result<(), GuestInjectError> {
4840    let end = stage
4841        .checked_add(size as u64)
4842        .ok_or_else(|| GuestInjectError::Config("guest stage range overflows".into()))?;
4843    for region in backend.memory_map(pid)? {
4844        let region_end = region.base.saturating_add(region.size);
4845        if stage >= region.base && end <= region_end {
4846            if region.readable && region.executable {
4847                return Ok(());
4848            }
4849            return Err(GuestInjectError::Config(format!(
4850                "guest execution stub at {stage:#x} must be readable+executable; region permissions are {}{}{}",
4851                if region.readable { 'r' } else { '-' },
4852                if region.writable { 'w' } else { '-' },
4853                if region.executable { 'x' } else { '-' },
4854            )));
4855        }
4856    }
4857    Err(GuestInjectError::Config(format!(
4858        "guest execution stub at {stage:#x} does not fit in one mapped region"
4859    )))
4860}
4861
4862fn validate_result_region(
4863    backend: &dyn GuestMemoryBackend,
4864    pid: u32,
4865    result: u64,
4866) -> Result<(), GuestInjectError> {
4867    let end = result
4868        .checked_add(RESULT_BLOCK_SIZE as u64)
4869        .ok_or_else(|| GuestInjectError::Config("guest result block range overflows".into()))?;
4870    for region in backend.memory_map(pid)? {
4871        let region_end = region.base.saturating_add(region.size);
4872        if result >= region.base && end <= region_end {
4873            if region.readable && region.writable {
4874                return Ok(());
4875            }
4876            return Err(GuestInjectError::Config(format!(
4877                "guest result block at {result:#x} must be readable+writable; region permissions are {}{}{}",
4878                if region.readable { 'r' } else { '-' },
4879                if region.writable { 'w' } else { '-' },
4880                if region.executable { 'x' } else { '-' },
4881            )));
4882        }
4883    }
4884    Err(GuestInjectError::Config(format!(
4885        "guest result block at {result:#x} does not fit in one mapped region"
4886    )))
4887}
4888
4889fn find_writable_executable_code_cave(
4890    backend: &dyn GuestMemoryBackend,
4891    pid: u32,
4892    size: usize,
4893) -> Result<u64, GuestInjectError> {
4894    for region in backend.memory_map(pid)? {
4895        if !region.readable || !region.writable || !region.executable || region.size < size as u64 {
4896            continue;
4897        }
4898        let mut pos = 0u64;
4899        while pos < region.size {
4900            let len = (region.size - pos).min(SCAN_CHUNK + size as u64) as usize;
4901            let addr = region.base + pos;
4902            if let Ok(bytes) = backend.read(pid, addr, len)
4903                && let Some(off) = find_code_cave(&bytes, size)
4904            {
4905                return Ok(addr + off as u64);
4906            }
4907            pos += SCAN_CHUNK;
4908        }
4909    }
4910    Err(GuestInjectError::Config(
4911        "guest IAT-hook execution needs guest.stage_base, guest.stage_pattern, or a writable+executable staging region; refusing to patch arbitrary RX code".into(),
4912    ))
4913}
4914
4915fn find_process_pattern<B: GuestMemoryBackend + ?Sized>(
4916    backend: &B,
4917    pid: u32,
4918    pattern: &GuestBytePattern,
4919) -> Result<Option<u64>, GuestInjectError> {
4920    let regions = match backend.memory_map(pid) {
4921        Ok(regions) => regions,
4922        Err(err) if is_process_gone_error(&err) => return Ok(None),
4923        Err(err) => return Err(err),
4924    };
4925    for region in regions {
4926        if !region.readable || region.size < pattern.len() as u64 {
4927            continue;
4928        }
4929        let mut pos = 0u64;
4930        while pos < region.size {
4931            let len = (region.size - pos).min(SCAN_CHUNK + pattern.len() as u64) as usize;
4932            let addr = region.base + pos;
4933            if let Ok(bytes) = backend.read(pid, addr, len)
4934                && let Some(off) = pattern.find_in(&bytes)
4935            {
4936                return Ok(Some(addr + off as u64));
4937            }
4938            pos += SCAN_CHUNK;
4939        }
4940    }
4941    Ok(None)
4942}
4943
4944fn is_process_gone_error(err: &GuestInjectError) -> bool {
4945    match err {
4946        GuestInjectError::Process(_) => true,
4947        GuestInjectError::Backend(message) => message.contains("no such process"),
4948        _ => false,
4949    }
4950}
4951
4952fn find_code_cave(bytes: &[u8], size: usize) -> Option<usize> {
4953    let mut start = None;
4954    for (idx, byte) in bytes.iter().enumerate() {
4955        if matches!(*byte, 0x00 | 0x90 | 0xCC) {
4956            let aligned = match start {
4957                Some(pos) => pos,
4958                None => align_up(idx, 16),
4959            };
4960            start = Some(aligned);
4961            if idx + 1 >= aligned + size {
4962                return Some(aligned);
4963            }
4964        } else {
4965            start = None;
4966        }
4967    }
4968    None
4969}
4970
4971fn align_up(value: usize, align: usize) -> usize {
4972    (value + align - 1) & !(align - 1)
4973}
4974
4975fn find_iat_hook(
4976    backend: &dyn GuestMemoryBackend,
4977    pid: u32,
4978    plan: &GuestInjectionPlan,
4979) -> Result<RemoteIatHook, GuestInjectError> {
4980    let module = target_module(backend, pid, plan)?;
4981    let mut errors = Vec::new();
4982    for hook_module in import_module_candidates(&plan.hook_module) {
4983        match find_import_iat(backend, pid, module.base, &hook_module, &plan.hook_function) {
4984            Ok(import) => return Ok(import),
4985            Err(err) => errors.push(format!("{hook_module}: {err}")),
4986        }
4987    }
4988    Err(GuestInjectError::Image(format!(
4989        "target import {}!{} not found via {}",
4990        plan.hook_module,
4991        plan.hook_function,
4992        errors.join("; ")
4993    )))
4994}
4995
4996fn target_module(
4997    backend: &dyn GuestMemoryBackend,
4998    pid: u32,
4999    plan: &GuestInjectionPlan,
5000) -> Result<GuestModuleInfo, GuestInjectError> {
5001    let modules = backend.module_list(pid)?;
5002    match plan.target_module.as_deref() {
5003        Some(name) => modules
5004            .into_iter()
5005            .find(|m| m.name.eq_ignore_ascii_case(name))
5006            .ok_or_else(|| GuestInjectError::Process(format!("module {name:?} not found"))),
5007        None => modules
5008            .into_iter()
5009            .find(|m| m.name.to_ascii_lowercase().ends_with(".exe"))
5010            .ok_or_else(|| GuestInjectError::Process("target exe module not found".into())),
5011    }
5012}
5013
5014fn find_import_iat(
5015    backend: &dyn GuestMemoryBackend,
5016    pid: u32,
5017    base: u64,
5018    module_name: &str,
5019    function_name: &str,
5020) -> Result<RemoteIatHook, GuestInjectError> {
5021    let hdr = backend.read(pid, base, 0x1000)?;
5022    if u16_at(&hdr, 0).map_err(GuestInjectError::from)? != 0x5A4D {
5023        return Err(GuestInjectError::Image(
5024            "target module missing MZ header".into(),
5025        ));
5026    }
5027    let nt = u32_at(&hdr, 0x3C).map_err(GuestInjectError::from)? as usize;
5028    if u32_at(&hdr, nt).map_err(GuestInjectError::from)? != 0x0000_4550 {
5029        return Err(GuestInjectError::Image(
5030            "target module missing PE header".into(),
5031        ));
5032    }
5033    let opt = nt + 24;
5034    let import_rva = u32_at(&hdr, opt + 112 + 8).map_err(GuestInjectError::from)? as u64;
5035    if import_rva == 0 {
5036        return Err(GuestInjectError::Image(
5037            "target module has no import table".into(),
5038        ));
5039    }
5040    let mut desc_addr = base + import_rva;
5041    loop {
5042        let desc = backend.read(pid, desc_addr, 20)?;
5043        let original_first_thunk = u32_at(&desc, 0).map_err(GuestInjectError::from)? as u64;
5044        let name_rva = u32_at(&desc, 12).map_err(GuestInjectError::from)? as u64;
5045        let first_thunk = u32_at(&desc, 16).map_err(GuestInjectError::from)? as u64;
5046        if original_first_thunk == 0 && name_rva == 0 && first_thunk == 0 {
5047            break;
5048        }
5049        let remote_name = read_remote_cstr(backend, pid, base + name_rva, 256)?;
5050        if remote_name.eq_ignore_ascii_case(module_name) {
5051            let lookup = match original_first_thunk {
5052                0 => first_thunk,
5053                rva => rva,
5054            };
5055            let mut index = 0u64;
5056            loop {
5057                let thunk = read_remote_u64(backend, pid, base + lookup + index * 8)?;
5058                if thunk == 0 {
5059                    break;
5060                }
5061                if thunk & 0x8000_0000_0000_0000 == 0 {
5062                    let name = read_remote_cstr(backend, pid, base + thunk + 2, 256)?;
5063                    if name == function_name {
5064                        let iat_slot = base + first_thunk + index * 8;
5065                        return Ok(RemoteIatHook {
5066                            iat_slot,
5067                            original_target: read_remote_u64(backend, pid, iat_slot)?,
5068                        });
5069                    }
5070                }
5071                index += 1;
5072            }
5073        }
5074        desc_addr += 20;
5075    }
5076    Err(GuestInjectError::Image(format!(
5077        "target import {module_name}!{function_name} not found"
5078    )))
5079}
5080
5081fn resolve_import_symbol(
5082    backend: &dyn GuestMemoryBackend,
5083    pid: u32,
5084    module: &str,
5085    name: &str,
5086) -> Result<u64, GuestInjectError> {
5087    let candidates = import_module_candidates(module);
5088    let mut errors = Vec::new();
5089    tracing::debug!(
5090        pid,
5091        requested_module = module,
5092        symbol = name,
5093        candidates = %candidates.join(","),
5094        "resolving guest import symbol"
5095    );
5096    for candidate in &candidates {
5097        match resolve_export_name(backend, pid, candidate, name, 0) {
5098            Ok(addr) => {
5099                tracing::debug!(
5100                    pid,
5101                    requested_module = module,
5102                    resolved_module = candidate,
5103                    symbol = name,
5104                    address = format_args!("{addr:#x}"),
5105                    "guest import symbol resolved"
5106                );
5107                return Ok(addr);
5108            }
5109            Err(err) => {
5110                tracing::debug!(
5111                    pid,
5112                    requested_module = module,
5113                    candidate,
5114                    symbol = name,
5115                    error = %err,
5116                    "guest import candidate failed"
5117                );
5118                errors.push(format!("{candidate}: {err}"));
5119            }
5120        }
5121    }
5122    Err(GuestInjectError::Image(format!(
5123        "import {module}!{name} not found via {}",
5124        errors.join("; ")
5125    )))
5126}
5127
5128fn resolve_import_symbol_ordinal(
5129    backend: &dyn GuestMemoryBackend,
5130    pid: u32,
5131    module: &str,
5132    ordinal: u16,
5133) -> Result<u64, GuestInjectError> {
5134    let candidates = import_module_candidates(module);
5135    let mut errors = Vec::new();
5136    tracing::debug!(
5137        pid,
5138        requested_module = module,
5139        ordinal,
5140        candidates = %candidates.join(","),
5141        "resolving guest ordinal import"
5142    );
5143    for candidate in &candidates {
5144        match resolve_export_ordinal(backend, pid, candidate, ordinal, 0) {
5145            Ok(addr) => {
5146                tracing::debug!(
5147                    pid,
5148                    requested_module = module,
5149                    resolved_module = candidate,
5150                    ordinal,
5151                    address = format_args!("{addr:#x}"),
5152                    "guest ordinal import resolved"
5153                );
5154                return Ok(addr);
5155            }
5156            Err(err) => {
5157                tracing::debug!(
5158                    pid,
5159                    requested_module = module,
5160                    candidate,
5161                    ordinal,
5162                    error = %err,
5163                    "guest ordinal import candidate failed"
5164                );
5165                errors.push(format!("{candidate}: {err}"));
5166            }
5167        }
5168    }
5169    Err(GuestInjectError::Image(format!(
5170        "import {module}!#{ordinal} not found via {}",
5171        errors.join("; ")
5172    )))
5173}
5174
5175#[allow(clippy::too_many_arguments)]
5176fn resolve_import_symbol_with_dependency_policy(
5177    backend: &dyn GuestMemoryBackend,
5178    pid: u32,
5179    hook: &GuestIatHook,
5180    loader_apis: Option<GuestLoaderApis>,
5181    scratch_addr: u64,
5182    scratch_len: usize,
5183    timeout_ms: u32,
5184    policy: GuestDependencyPolicy,
5185    module: &str,
5186    symbol: ImportSymbol<'_>,
5187) -> Result<u64, GuestInjectError> {
5188    let resolve = |backend: &dyn GuestMemoryBackend| match symbol {
5189        ImportSymbol::Name(name) => {
5190            let name = std::str::from_utf8(name)
5191                .map_err(|e| GuestInjectError::Image(format!("import name: {e}")))?;
5192            resolve_import_symbol(backend, pid, module, name)
5193        }
5194        ImportSymbol::Ordinal(ordinal) => {
5195            resolve_import_symbol_ordinal(backend, pid, module, ordinal)
5196        }
5197    };
5198
5199    match resolve(backend) {
5200        Ok(addr) => return Ok(addr),
5201        Err(err) if policy == GuestDependencyPolicy::LoadWithGuestLoader => {
5202            tracing::info!(
5203                pid,
5204                module,
5205                error = %err,
5206                "guest import dependency missing; loading through guest loader"
5207            );
5208        }
5209        Err(err) => return Err(err),
5210    }
5211
5212    let loader_apis = loader_apis.ok_or_else(|| GuestInjectError::Unsupported {
5213        operation: "guest dependency loading",
5214        reason: "LoadLibraryA/GetProcAddress were not resolved".into(),
5215    })?;
5216    let load_name = dependency_load_name(module);
5217    let loaded_base = load_guest_dependency(
5218        backend,
5219        pid,
5220        hook,
5221        loader_apis.load_library,
5222        scratch_addr,
5223        scratch_len,
5224        timeout_ms,
5225        &load_name,
5226    )?;
5227    match resolve(backend) {
5228        Ok(addr) => Ok(addr),
5229        Err(err) => {
5230            tracing::debug!(
5231                pid,
5232                module,
5233                loaded_module = load_name,
5234                loaded_base = format_args!("{loaded_base:#x}"),
5235                error = %err,
5236                "guest module-list import retry failed; resolving export through guest GetProcAddress"
5237            );
5238            resolve_loaded_import_symbol(
5239                backend,
5240                pid,
5241                hook,
5242                loader_apis.get_proc_address,
5243                scratch_addr,
5244                scratch_len,
5245                timeout_ms,
5246                &load_name,
5247                loaded_base,
5248                symbol,
5249            )
5250        }
5251    }
5252}
5253
5254#[derive(Clone, Copy)]
5255struct GuestLoaderApis {
5256    load_library: u64,
5257    get_proc_address: u64,
5258}
5259
5260fn dependency_load_name(module: &str) -> String {
5261    let lower = module.to_ascii_lowercase();
5262    if lower.starts_with("api-ms-win-crt-") {
5263        "ucrtbase.dll".into()
5264    } else if lower.starts_with("api-ms-win-core-") {
5265        "kernelbase.dll".into()
5266    } else {
5267        module.into()
5268    }
5269}
5270
5271#[allow(clippy::too_many_arguments)]
5272fn load_guest_dependency(
5273    backend: &dyn GuestMemoryBackend,
5274    pid: u32,
5275    hook: &GuestIatHook,
5276    load_library: u64,
5277    scratch_addr: u64,
5278    scratch_len: usize,
5279    timeout_ms: u32,
5280    module: &str,
5281) -> Result<u64, GuestInjectError> {
5282    let mut bytes = module.as_bytes().to_vec();
5283    bytes.push(0);
5284    if bytes.len() > scratch_len {
5285        return Err(GuestInjectError::Unsupported {
5286            operation: "guest dependency loading",
5287            reason: format!(
5288                "module name {module:?} needs {} scratch bytes, only {scratch_len} available",
5289                bytes.len()
5290            ),
5291        });
5292    }
5293
5294    let original = backend.read(pid, scratch_addr, bytes.len())?;
5295    write_verified(
5296        backend,
5297        pid,
5298        scratch_addr,
5299        &bytes,
5300        "guest dependency module name",
5301    )?;
5302    let call_result =
5303        backend.call_iat_hook(pid, hook, load_library, [scratch_addr, 0, 0, 0], timeout_ms);
5304    let restore_result = backend.write(pid, scratch_addr, &original);
5305    if let Err(err) = restore_result {
5306        tracing::warn!(
5307            pid,
5308            scratch_addr = format_args!("{scratch_addr:#x}"),
5309            error = %err,
5310            "failed to restore guest dependency scratch bytes"
5311        );
5312    }
5313
5314    let module_base = call_result?;
5315    if module_base == 0 {
5316        return Err(GuestInjectError::Backend(format!(
5317            "guest LoadLibraryA({module:?}) returned NULL"
5318        )));
5319    }
5320    tracing::info!(
5321        pid,
5322        module,
5323        module_base = format_args!("{module_base:#x}"),
5324        "guest dependency loaded"
5325    );
5326    Ok(module_base)
5327}
5328
5329#[allow(clippy::too_many_arguments)]
5330fn resolve_loaded_import_symbol(
5331    backend: &dyn GuestMemoryBackend,
5332    pid: u32,
5333    hook: &GuestIatHook,
5334    get_proc_address: u64,
5335    scratch_addr: u64,
5336    scratch_len: usize,
5337    timeout_ms: u32,
5338    module: &str,
5339    module_base: u64,
5340    symbol: ImportSymbol<'_>,
5341) -> Result<u64, GuestInjectError> {
5342    let proc = match symbol {
5343        ImportSymbol::Name(name) => {
5344            let name = std::str::from_utf8(name)
5345                .map_err(|e| GuestInjectError::Image(format!("import name: {e}")))?;
5346            guest_get_proc_address(
5347                backend,
5348                pid,
5349                hook,
5350                get_proc_address,
5351                scratch_addr,
5352                scratch_len,
5353                timeout_ms,
5354                module_base,
5355                name,
5356            )?
5357        }
5358        ImportSymbol::Ordinal(ordinal) => backend.call_iat_hook(
5359            pid,
5360            hook,
5361            get_proc_address,
5362            [module_base, u64::from(ordinal), 0, 0],
5363            timeout_ms,
5364        )?,
5365    };
5366    if proc == 0 {
5367        return Err(GuestInjectError::Image(format!(
5368            "export {module}!{} not found",
5369            symbol.label()
5370        )));
5371    }
5372    tracing::debug!(
5373        pid,
5374        module,
5375        module_base = format_args!("{module_base:#x}"),
5376        symbol = %symbol.label(),
5377        address = format_args!("{proc:#x}"),
5378        "guest loaded dependency export resolved through GetProcAddress"
5379    );
5380    Ok(proc)
5381}
5382
5383#[allow(clippy::too_many_arguments)]
5384fn guest_get_proc_address(
5385    backend: &dyn GuestMemoryBackend,
5386    pid: u32,
5387    hook: &GuestIatHook,
5388    get_proc_address: u64,
5389    scratch_addr: u64,
5390    scratch_len: usize,
5391    timeout_ms: u32,
5392    module_base: u64,
5393    name: &str,
5394) -> Result<u64, GuestInjectError> {
5395    let mut bytes = name.as_bytes().to_vec();
5396    bytes.push(0);
5397    if bytes.len() > scratch_len {
5398        return Err(GuestInjectError::Unsupported {
5399            operation: "guest dependency export lookup",
5400            reason: format!(
5401                "symbol name {name:?} needs {} scratch bytes, only {scratch_len} available",
5402                bytes.len()
5403            ),
5404        });
5405    }
5406
5407    let original = backend.read(pid, scratch_addr, bytes.len())?;
5408    write_verified(
5409        backend,
5410        pid,
5411        scratch_addr,
5412        &bytes,
5413        "guest dependency symbol name",
5414    )?;
5415    let call_result = backend.call_iat_hook(
5416        pid,
5417        hook,
5418        get_proc_address,
5419        [module_base, scratch_addr, 0, 0],
5420        timeout_ms,
5421    );
5422    let restore_result = backend.write(pid, scratch_addr, &original);
5423    if let Err(err) = restore_result {
5424        tracing::warn!(
5425            pid,
5426            scratch_addr = format_args!("{scratch_addr:#x}"),
5427            error = %err,
5428            "failed to restore guest dependency symbol scratch bytes"
5429        );
5430    }
5431    call_result
5432}
5433
5434impl ImportSymbol<'_> {
5435    fn label(&self) -> String {
5436        match self {
5437            ImportSymbol::Name(name) => String::from_utf8_lossy(name).into_owned(),
5438            ImportSymbol::Ordinal(ordinal) => format!("#{ordinal}"),
5439        }
5440    }
5441}
5442
5443#[derive(Clone, Copy)]
5444enum ExportLookup<'a> {
5445    Name(&'a str),
5446    Ordinal(u16),
5447}
5448
5449fn resolve_export_name(
5450    backend: &dyn GuestMemoryBackend,
5451    pid: u32,
5452    module: &str,
5453    name: &str,
5454    depth: usize,
5455) -> Result<u64, GuestInjectError> {
5456    resolve_export(backend, pid, module, ExportLookup::Name(name), depth)
5457}
5458
5459fn resolve_export_ordinal(
5460    backend: &dyn GuestMemoryBackend,
5461    pid: u32,
5462    module: &str,
5463    ordinal: u16,
5464    depth: usize,
5465) -> Result<u64, GuestInjectError> {
5466    resolve_export(backend, pid, module, ExportLookup::Ordinal(ordinal), depth)
5467}
5468
5469fn resolve_export(
5470    backend: &dyn GuestMemoryBackend,
5471    pid: u32,
5472    module: &str,
5473    lookup: ExportLookup<'_>,
5474    depth: usize,
5475) -> Result<u64, GuestInjectError> {
5476    if depth > MAX_EXPORT_FORWARD_DEPTH {
5477        return Err(GuestInjectError::Image(format!(
5478            "forwarded export chain exceeded {MAX_EXPORT_FORWARD_DEPTH} hops"
5479        )));
5480    }
5481    let module = find_module_ci(backend, pid, module)?;
5482    let export_rva = find_export_rva(backend, pid, &module, lookup)?;
5483    let export_dir = export_directory(backend, pid, module.base)?;
5484    if export_rva >= export_dir.rva as u64
5485        && export_rva < export_dir.rva as u64 + export_dir.size as u64
5486    {
5487        let forwarder = read_remote_cstr(backend, pid, module.base + export_rva, 256)?;
5488        tracing::debug!(
5489            pid,
5490            module = %module.name,
5491            forwarder,
5492            "guest export is forwarded"
5493        );
5494        return resolve_forwarded_export(backend, pid, &forwarder, depth + 1);
5495    }
5496    Ok(module.base + export_rva)
5497}
5498
5499fn resolve_forwarded_export(
5500    backend: &dyn GuestMemoryBackend,
5501    pid: u32,
5502    forwarder: &str,
5503    depth: usize,
5504) -> Result<u64, GuestInjectError> {
5505    let (module, symbol) = forwarder.rsplit_once('.').ok_or_else(|| {
5506        GuestInjectError::Image(format!("bad forwarded export string {forwarder:?}"))
5507    })?;
5508    let module = normalize_forwarder_module(module);
5509    match symbol.strip_prefix('#') {
5510        Some(ordinal) => {
5511            let ordinal = ordinal.parse::<u16>().map_err(|e| {
5512                GuestInjectError::Image(format!("bad forwarded export ordinal {forwarder:?}: {e}"))
5513            })?;
5514            resolve_export_ordinal(backend, pid, &module, ordinal, depth)
5515        }
5516        None => resolve_export_name(backend, pid, &module, symbol, depth),
5517    }
5518}
5519
5520fn normalize_forwarder_module(module: &str) -> String {
5521    let lower = module.to_ascii_lowercase();
5522    if lower.ends_with(".dll") || lower.ends_with(".exe") {
5523        module.to_string()
5524    } else {
5525        format!("{module}.dll")
5526    }
5527}
5528
5529fn find_module_ci(
5530    backend: &dyn GuestMemoryBackend,
5531    pid: u32,
5532    module: &str,
5533) -> Result<GuestModuleInfo, GuestInjectError> {
5534    let modules = backend.module_list(pid)?;
5535    modules
5536        .into_iter()
5537        .find(|m| {
5538            m.name.eq_ignore_ascii_case(module)
5539                || m.name
5540                    .rsplit(['\\', '/'])
5541                    .next()
5542                    .is_some_and(|base| base.eq_ignore_ascii_case(module))
5543        })
5544        .ok_or_else(|| GuestInjectError::Process(format!("module {module:?} not found")))
5545}
5546
5547fn find_export_rva(
5548    backend: &dyn GuestMemoryBackend,
5549    pid: u32,
5550    module: &GuestModuleInfo,
5551    lookup: ExportLookup<'_>,
5552) -> Result<u64, GuestInjectError> {
5553    let export = export_directory(backend, pid, module.base)?;
5554    let dir = backend.read(pid, module.base + export.rva as u64, 40)?;
5555    let ordinal_base = u32_at(&dir, 16).map_err(GuestInjectError::from)?;
5556    let function_count = u32_at(&dir, 20).map_err(GuestInjectError::from)?;
5557    let name_count = u32_at(&dir, 24).map_err(GuestInjectError::from)?;
5558    let functions_rva = u32_at(&dir, 28).map_err(GuestInjectError::from)? as u64;
5559    let names_rva = u32_at(&dir, 32).map_err(GuestInjectError::from)? as u64;
5560    let ordinals_rva = u32_at(&dir, 36).map_err(GuestInjectError::from)? as u64;
5561
5562    let index = match lookup {
5563        ExportLookup::Name(want) => {
5564            let mut found = None;
5565            for i in 0..name_count.min(65_536) {
5566                let name_rva =
5567                    read_remote_u32(backend, pid, module.base + names_rva + u64::from(i) * 4)?;
5568                let name = read_remote_cstr(backend, pid, module.base + u64::from(name_rva), 512)?;
5569                if name == want {
5570                    found = Some(u32::from(read_remote_u16(
5571                        backend,
5572                        pid,
5573                        module.base + ordinals_rva + u64::from(i) * 2,
5574                    )?));
5575                    break;
5576                }
5577            }
5578            found.ok_or_else(|| {
5579                GuestInjectError::Image(format!("export {}!{want} not found", module.name))
5580            })?
5581        }
5582        ExportLookup::Ordinal(ordinal) => {
5583            let ordinal = u32::from(ordinal);
5584            if ordinal < ordinal_base {
5585                return Err(GuestInjectError::Image(format!(
5586                    "export {}!#{ordinal} precedes ordinal base {ordinal_base}",
5587                    module.name
5588                )));
5589            }
5590            ordinal - ordinal_base
5591        }
5592    };
5593
5594    if index >= function_count {
5595        return Err(GuestInjectError::Image(format!(
5596            "export {} index {index} exceeds function count {function_count}",
5597            module.name
5598        )));
5599    }
5600    let rva = read_remote_u32(
5601        backend,
5602        pid,
5603        module.base + functions_rva + u64::from(index) * 4,
5604    )?;
5605    if rva == 0 {
5606        return Err(GuestInjectError::Image(format!(
5607            "export {} index {index} is null",
5608            module.name
5609        )));
5610    }
5611    Ok(u64::from(rva))
5612}
5613
5614fn export_directory(
5615    backend: &dyn GuestMemoryBackend,
5616    pid: u32,
5617    base: u64,
5618) -> Result<crate::pe::Dir, GuestInjectError> {
5619    let mut last = None;
5620    for attempt in 0..=EXPORT_HEADER_RETRIES {
5621        match export_directory_once(backend, pid, base) {
5622            Ok(dir) => return Ok(dir),
5623            Err(err)
5624                if is_transient_export_header_error(&err) && attempt < EXPORT_HEADER_RETRIES =>
5625            {
5626                tracing::debug!(
5627                    pid,
5628                    base = format_args!("{base:#x}"),
5629                    attempt = attempt + 1,
5630                    error = %err,
5631                    "guest export header not ready; retrying"
5632                );
5633                last = Some(err);
5634                thread::sleep(EXPORT_HEADER_RETRY_DELAY);
5635            }
5636            Err(err) => return Err(err),
5637        }
5638    }
5639    Err(last.unwrap_or_else(|| GuestInjectError::Image("export module header not ready".into())))
5640}
5641
5642fn export_directory_once(
5643    backend: &dyn GuestMemoryBackend,
5644    pid: u32,
5645    base: u64,
5646) -> Result<crate::pe::Dir, GuestInjectError> {
5647    let hdr = backend.read(pid, base, 0x1000)?;
5648    if u16_at(&hdr, 0).map_err(GuestInjectError::from)? != 0x5A4D {
5649        return Err(GuestInjectError::Image(format!(
5650            "export module at {base:#x} missing MZ header"
5651        )));
5652    }
5653    let nt = u32_at(&hdr, 0x3C).map_err(GuestInjectError::from)? as usize;
5654    if u32_at(&hdr, nt).map_err(GuestInjectError::from)? != 0x0000_4550 {
5655        return Err(GuestInjectError::Image(format!(
5656            "export module at {base:#x} missing PE header"
5657        )));
5658    }
5659    let opt = nt + 24;
5660    let rva = u32_at(&hdr, opt + 112).map_err(GuestInjectError::from)?;
5661    let size = u32_at(&hdr, opt + 116).map_err(GuestInjectError::from)?;
5662    if rva == 0 || size == 0 {
5663        return Err(GuestInjectError::Image(format!(
5664            "export module at {base:#x} has no export directory"
5665        )));
5666    }
5667    Ok(crate::pe::Dir { rva, size })
5668}
5669
5670fn is_transient_export_header_error(err: &GuestInjectError) -> bool {
5671    match err {
5672        GuestInjectError::Image(message) => {
5673            message.contains("missing MZ header") || message.contains("missing PE header")
5674        }
5675        GuestInjectError::Backend(message) => message.contains("read"),
5676        _ => false,
5677    }
5678}
5679
5680fn import_module_candidates(module: &str) -> Vec<String> {
5681    let mut out = vec![module.to_string()];
5682    let lower = module.to_ascii_lowercase();
5683    if lower == "kernel32.dll" {
5684        out.push("kernelbase.dll".into());
5685    } else if lower.starts_with("api-ms-win-crt-") {
5686        out.push("ucrtbase.dll".into());
5687    } else if lower.starts_with("api-ms-win-core-") {
5688        out.push("kernelbase.dll".into());
5689        out.push("kernel32.dll".into());
5690        out.push("ntdll.dll".into());
5691    }
5692    out.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
5693    out
5694}
5695
5696fn read_remote_u64(
5697    backend: &dyn GuestMemoryBackend,
5698    pid: u32,
5699    addr: u64,
5700) -> Result<u64, GuestInjectError> {
5701    let bytes = backend.read(pid, addr, 8)?;
5702    Ok(u64::from_le_bytes(bytes[0..8].try_into().unwrap()))
5703}
5704
5705fn read_remote_u32(
5706    backend: &dyn GuestMemoryBackend,
5707    pid: u32,
5708    addr: u64,
5709) -> Result<u32, GuestInjectError> {
5710    let bytes = backend.read(pid, addr, 4)?;
5711    Ok(u32::from_le_bytes(bytes[0..4].try_into().unwrap()))
5712}
5713
5714fn read_remote_u16(
5715    backend: &dyn GuestMemoryBackend,
5716    pid: u32,
5717    addr: u64,
5718) -> Result<u16, GuestInjectError> {
5719    let bytes = backend.read(pid, addr, 2)?;
5720    Ok(u16::from_le_bytes(bytes[0..2].try_into().unwrap()))
5721}
5722
5723fn read_remote_cstr(
5724    backend: &dyn GuestMemoryBackend,
5725    pid: u32,
5726    addr: u64,
5727    max: usize,
5728) -> Result<String, GuestInjectError> {
5729    let bytes = backend.read(pid, addr, max)?;
5730    let len = bytes
5731        .iter()
5732        .position(|b| *b == 0)
5733        .ok_or_else(|| GuestInjectError::Image(format!("unterminated string at {addr:#x}")))?;
5734    Ok(String::from_utf8_lossy(&bytes[..len]).into_owned())
5735}
5736
5737fn parse_hex_pattern(pattern: &str, field: &str) -> Result<GuestBytePattern, GuestInjectError> {
5738    let mut out = Vec::new();
5739    for token in pattern.split_whitespace() {
5740        if token == "?" || token == "??" {
5741            out.push(None);
5742        } else {
5743            let hex = token.trim_start_matches("0x");
5744            let byte = u8::from_str_radix(hex, 16).map_err(|e| {
5745                GuestInjectError::Config(format!("bad {field} byte {token:?}: {e}"))
5746            })?;
5747            out.push(Some(byte));
5748        }
5749    }
5750    match out.is_empty() {
5751        true => Err(GuestInjectError::Config(format!("{field} cannot be empty"))),
5752        false => Ok(GuestBytePattern { bytes: out }),
5753    }
5754}
5755
5756#[cfg(test)]
5757mod tests {
5758    use super::*;
5759
5760    #[test]
5761    fn target_requires_pid_or_name() {
5762        let target = GuestProcessSelector {
5763            pid: None,
5764            name: None,
5765            pattern: None,
5766        };
5767        assert!(matches!(
5768            target.validate(),
5769            Err(GuestInjectError::Config(_))
5770        ));
5771    }
5772
5773    #[test]
5774    fn memflow_capabilities_cover_iat_hook_manual_map() {
5775        let capabilities = GuestCapabilities::memflow_guest_injection();
5776        let missing = capabilities.missing_manual_map();
5777        assert!(missing.is_empty(), "{missing:?}");
5778        assert!(capabilities.exception_registration);
5779        assert!(!capabilities.vad_spoof);
5780    }
5781
5782    struct VadRejectBackend;
5783
5784    impl GuestMemoryBackend for VadRejectBackend {
5785        fn capabilities(&self) -> GuestCapabilities {
5786            GuestCapabilities::memflow_guest_injection()
5787        }
5788
5789        fn list_processes(&self) -> Result<Vec<GuestProcessInfo>, GuestInjectError> {
5790            panic!("vad spoof rejection should happen before process access")
5791        }
5792
5793        fn module_list(&self, _pid: u32) -> Result<Vec<GuestModuleInfo>, GuestInjectError> {
5794            panic!("vad spoof rejection should happen before module access")
5795        }
5796
5797        fn module_exports(
5798            &self,
5799            _pid: u32,
5800            _module: &str,
5801        ) -> Result<Vec<(String, u64)>, GuestInjectError> {
5802            panic!("vad spoof rejection should happen before export access")
5803        }
5804
5805        fn memory_map(&self, _pid: u32) -> Result<Vec<GuestMemoryRegion>, GuestInjectError> {
5806            panic!("vad spoof rejection should happen before memory-map access")
5807        }
5808
5809        fn read(&self, _pid: u32, _addr: u64, _len: usize) -> Result<Vec<u8>, GuestInjectError> {
5810            panic!("vad spoof rejection should happen before memory reads")
5811        }
5812
5813        fn write(&self, _pid: u32, _addr: u64, _data: &[u8]) -> Result<(), GuestInjectError> {
5814            panic!("vad spoof rejection should happen before memory writes")
5815        }
5816    }
5817
5818    #[test]
5819    fn vad_spoof_is_rejected_before_guest_access_when_backend_cannot_do_it() {
5820        let config = DecantConfig::from_toml_str(
5821            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
5822             [guest]\npid = 1\npayload_path = \"payload.dll\"\n\
5823             vad_spoof = \"vad-image-map\"\n",
5824        )
5825        .unwrap();
5826        let plan = GuestInjectionPlan::from_config(&config).unwrap();
5827        let injector = GuestManualMapInjector;
5828        let req = GuestInjectionRequest {
5829            plan: &plan,
5830            payload_path: std::path::Path::new("payload.dll"),
5831            payload_image: &[0x4d],
5832        };
5833
5834        match injector.inject(&VadRejectBackend, &req) {
5835            Err(GuestInjectError::Unsupported { operation, reason }) => {
5836                assert_eq!(operation, "VAD type spoofing");
5837                assert!(reason.contains("VAD mutation support"), "{reason}");
5838            }
5839            Ok(_) => panic!("expected VAD spoof rejection"),
5840            Err(err) => panic!("expected VAD spoof unsupported error, got {err:?}"),
5841        }
5842    }
5843
5844    #[test]
5845    fn thread_hijack_is_an_execution_policy() {
5846        let config = DecantConfig::from_toml_str(
5847            "[injection]\ndomain = \"guest\"\nmethod = \"thread-hijack\"\n\
5848             [guest]\npid = 1\npayload_path = \"payload.dll\"\n",
5849        )
5850        .unwrap();
5851        let err = GuestInjectionPlan::from_config(&config).unwrap_err();
5852        assert!(matches!(err, GuestInjectError::Config(_)));
5853    }
5854
5855    #[test]
5856    fn import_candidates_cover_forwarded_and_api_set_imports() {
5857        assert_eq!(
5858            import_module_candidates("KERNEL32.dll"),
5859            vec!["KERNEL32.dll", "kernelbase.dll"]
5860        );
5861        assert_eq!(
5862            import_module_candidates("api-ms-win-core-synch-l1-2-0.dll"),
5863            vec![
5864                "api-ms-win-core-synch-l1-2-0.dll",
5865                "kernelbase.dll",
5866                "kernel32.dll",
5867                "ntdll.dll"
5868            ]
5869        );
5870        assert_eq!(
5871            import_module_candidates("api-ms-win-crt-runtime-l1-1-0.dll"),
5872            vec!["api-ms-win-crt-runtime-l1-1-0.dll", "ucrtbase.dll"]
5873        );
5874        assert_eq!(normalize_forwarder_module("KERNELBASE"), "KERNELBASE.dll");
5875    }
5876
5877    #[test]
5878    fn iat_hook_stub_uses_windows_x64_shadow_space() {
5879        let hook = GuestIatHook {
5880            iat_slot: 0x1000,
5881            original_target: 0x2000,
5882            stub_addr: 0x3000,
5883            result_addr: 0x4000,
5884            call_stack: GuestCallStackPolicy::Native,
5885            spoofed_return: None,
5886        };
5887        let stub = call_stub(&hook, 0x5000, [1, 2, 3, 4]);
5888        assert!(
5889            windows_x64_call_sequence_offset(&stub).is_some(),
5890            "stub must reserve 32 bytes of Windows x64 shadow space and keep stack alignment"
5891        );
5892        assert!(
5893            stub.windows(5).any(|w| w == [0xF0, 0x4D, 0x0F, 0xB1, 0x1A]),
5894            "stub must atomically claim the result block so only one thread runs the injection call"
5895        );
5896        assert!(
5897            !stub.windows(8).any(|w| w == hook.iat_slot.to_le_bytes()),
5898            "stub must not write the guest IAT; the host transaction restores it"
5899        );
5900        assert!(
5901            stub.starts_with(&[0x51, 0x52, 0x41, 0x50, 0x41, 0x51]),
5902            "stub must save the import caller's register arguments before the injected call"
5903        );
5904        assert!(
5905            !stub
5906                .windows(10)
5907                .any(|w| w == [0x48, 0x83, 0xEC, 0x20, 0xFF, 0xD0, 0x48, 0x83, 0xC4, 0x20]),
5908            "0x20 would misalign the nested Windows x64 call from an IAT tail jump"
5909        );
5910        assert!(
5911            stub.windows(8)
5912                .any(|w| w == hook.original_target.to_le_bytes()),
5913            "stub must tail-jump to the original imported function after publishing the result"
5914        );
5915        assert_eq!(
5916            &stub[stub.len() - 2..],
5917            &[0xFF, 0xE0],
5918            "stub should tail-jump through RAX to the original import target"
5919        );
5920    }
5921
5922    #[test]
5923    fn iat_hook_spoofed_stub_keeps_landing_after_shadow_space() {
5924        let hook = GuestIatHook {
5925            iat_slot: 0x1000,
5926            original_target: 0x2000,
5927            stub_addr: 0x3000,
5928            result_addr: 0x4000,
5929            call_stack: GuestCallStackPolicy::Native,
5930            spoofed_return: Some(GuestSpoofedReturn {
5931                gadget_addr: 0x7000,
5932                stack_adjust: 0x20,
5933            }),
5934        };
5935        let stub = call_stub(&hook, 0x5000, [1, 2, 3, 4]);
5936        assert!(
5937            stub.windows(4).any(|w| w == [0x48, 0x83, 0xEC, 0x30]),
5938            "native spoofed call must reserve return, shadow space, and continuation"
5939        );
5940        assert!(
5941            stub.windows(5).any(|w| w == [0x4C, 0x89, 0x5C, 0x24, 0x00]),
5942            "spoofed gadget must be placed as the callee return address"
5943        );
5944        assert!(
5945            stub.windows(5).any(|w| w == [0x48, 0x89, 0x44, 0x24, 0x28]),
5946            "real continuation must be stored after the 32-byte Windows x64 shadow space"
5947        );
5948        assert!(
5949            !stub.windows(3).any(|w| w == [0x50, 0x41, 0x53]),
5950            "spoofed call must not put the continuation in the callee shadow space with pushes"
5951        );
5952    }
5953
5954    #[test]
5955    fn spoofed_stack_frame_restores_native_and_framed_callers() {
5956        assert_eq!(spoofed_stack_frame(0x20, 0), (0x30, 0));
5957        assert_eq!(spoofed_stack_frame(0x28, 0), (0x40, 0x08));
5958        assert_eq!(spoofed_stack_frame(0x20, 8), (0x38, 0x08));
5959        assert_eq!(spoofed_stack_frame(0x28, 8), (0x38, 0));
5960    }
5961
5962    #[test]
5963    fn iat_hook_touch_stub_materializes_pages_without_iat_write() {
5964        let hook = GuestIatHook {
5965            iat_slot: 0x1000,
5966            original_target: 0x2000,
5967            stub_addr: 0x3000,
5968            result_addr: 0x4000,
5969            call_stack: GuestCallStackPolicy::Native,
5970            spoofed_return: None,
5971        };
5972        let stub = touch_stub(&hook, 0x5000, GUEST_PAGE_SIZE + 1);
5973        assert!(
5974            stub.windows(3).any(|w| w == [0xC6, 0x02, 0x00]),
5975            "touch stub must fault in pages by writing through RDX"
5976        );
5977        assert!(
5978            stub.windows(7)
5979                .any(|w| w == [0x48, 0x81, 0xC2, 0x00, 0x10, 0x00, 0x00]),
5980            "touch stub must advance by guest page size"
5981        );
5982        assert!(
5983            !stub.windows(8).any(|w| w == hook.iat_slot.to_le_bytes()),
5984            "touch stub must not write the guest IAT; the host transaction restores it"
5985        );
5986        assert!(
5987            stub.windows(8)
5988                .any(|w| w == hook.original_target.to_le_bytes()),
5989            "touch stub must tail-jump to the original imported function"
5990        );
5991        assert_eq!(
5992            &stub[stub.len() - 2..],
5993            &[0xFF, 0xE0],
5994            "touch stub should tail-jump through RAX to the original import target"
5995        );
5996    }
5997
5998    #[test]
5999    fn iat_hook_read_touch_stub_materializes_without_writes() {
6000        let hook = GuestIatHook {
6001            iat_slot: 0x1000,
6002            original_target: 0x2000,
6003            stub_addr: 0x3000,
6004            result_addr: 0x4000,
6005            call_stack: GuestCallStackPolicy::Native,
6006            spoofed_return: None,
6007        };
6008        let stub = read_touch_stub(&hook, 0x5000, GUEST_PAGE_SIZE + 1);
6009        assert!(
6010            stub.windows(3).any(|w| w == [0x0F, 0xB6, 0x02]),
6011            "read-touch stub must fault in pages by reading through RDX"
6012        );
6013        assert!(
6014            !stub.windows(3).any(|w| w == [0xC6, 0x02, 0x00]),
6015            "read-touch stub must not write to materialized pages"
6016        );
6017        assert!(
6018            !stub.windows(8).any(|w| w == hook.iat_slot.to_le_bytes()),
6019            "read-touch stub must not write the guest IAT; the host transaction restores it"
6020        );
6021    }
6022
6023    #[test]
6024    fn iat_hook_preserve_touch_stub_writes_same_byte() {
6025        let hook = GuestIatHook {
6026            iat_slot: 0x1000,
6027            original_target: 0x2000,
6028            stub_addr: 0x3000,
6029            result_addr: 0x4000,
6030            call_stack: GuestCallStackPolicy::Native,
6031            spoofed_return: None,
6032        };
6033        let stub = preserve_touch_stub(&hook, 0x5000, GUEST_PAGE_SIZE + 1);
6034        assert!(
6035            stub.windows(5).any(|w| w == [0x0F, 0xB6, 0x02, 0x88, 0x02]),
6036            "preserve-touch stub must fault in pages by writing the original byte back"
6037        );
6038        assert!(
6039            !stub.windows(3).any(|w| w == [0xC6, 0x02, 0x00]),
6040            "preserve-touch stub must not zero image-backed page contents"
6041        );
6042        assert!(
6043            !stub.windows(8).any(|w| w == hook.iat_slot.to_le_bytes()),
6044            "preserve-touch stub must not write the guest IAT; the host transaction restores it"
6045        );
6046    }
6047
6048    #[test]
6049    fn sec_image_patch_ranges_batch_contiguous_changed_pages() {
6050        let mut image = vec![0u8; GUEST_PAGE_SIZE * 5];
6051        let snapshot = image.clone();
6052        image[17] = 1;
6053        image[GUEST_PAGE_SIZE + 2] = 2;
6054        image[(GUEST_PAGE_SIZE * 3) + 4] = 3;
6055
6056        assert_eq!(
6057            sec_image_patched_ranges(&image, &snapshot),
6058            vec![
6059                (0, GUEST_PAGE_SIZE * 2),
6060                (GUEST_PAGE_SIZE * 3, GUEST_PAGE_SIZE * 4),
6061            ]
6062        );
6063    }
6064
6065    #[test]
6066    fn spoofed_return_scan_uses_executable_module_regions() {
6067        let module = GuestModuleInfo {
6068            name: "ntdll.dll".into(),
6069            base: 0x1000,
6070            size: 0x8000,
6071        };
6072        let regions = vec![
6073            GuestMemoryRegion {
6074                base: 0x1000,
6075                size: 0x1000,
6076                readable: true,
6077                writable: false,
6078                executable: false,
6079            },
6080            GuestMemoryRegion {
6081                base: 0x2000,
6082                size: 0x2000,
6083                readable: true,
6084                writable: false,
6085                executable: true,
6086            },
6087            GuestMemoryRegion {
6088                base: 0x9000,
6089                size: 0x1000,
6090                readable: true,
6091                writable: false,
6092                executable: true,
6093            },
6094        ];
6095
6096        assert_eq!(
6097            spoofed_return_scan_ranges(&module, &regions),
6098            vec![(0x2000, 0x4000)]
6099        );
6100    }
6101
6102    #[test]
6103    fn spoofed_return_scan_falls_back_to_bounded_module_range() {
6104        let module = GuestModuleInfo {
6105            name: "kernel32.dll".into(),
6106            base: 0x7fff_0000,
6107            size: SPOOFED_RETURN_SCAN_LIMIT + 0x1000,
6108        };
6109
6110        assert_eq!(
6111            spoofed_return_scan_ranges(&module, &[]),
6112            vec![(0x7fff_0000, 0x7fff_0000 + SPOOFED_RETURN_SCAN_LIMIT)]
6113        );
6114    }
6115
6116    #[test]
6117    fn spoofed_return_gadget_scan_requires_shadow_space_adjustment() {
6118        assert_eq!(
6119            find_spoofed_return_gadget(&[0x90, 0x48, 0x83, 0xC4, 0x20, 0xC3]),
6120            Some((1, 0x20))
6121        );
6122        assert_eq!(find_spoofed_return_gadget(&[0x90, 0xC3]), None);
6123    }
6124
6125    #[test]
6126    fn registered_unwind_stub_uses_single_frame_allocation() {
6127        let hook = GuestIatHook {
6128            iat_slot: 0x1000,
6129            original_target: 0x2000,
6130            stub_addr: 0x3000,
6131            result_addr: 0x4000,
6132            call_stack: GuestCallStackPolicy::RegisteredUnwind,
6133            spoofed_return: None,
6134        };
6135        let stub = call_stub(&hook, 0x5000, [1, 2, 3, 4]);
6136        assert!(
6137            stub.starts_with(&[0x48, 0x83, 0xEC, FRAMED_STUB_STACK_ALLOC]),
6138            "registered-unwind stub must use one unwindable stack allocation"
6139        );
6140        assert!(
6141            !stub.starts_with(&[0x51, 0x52, 0x41, 0x50, 0x41, 0x51]),
6142            "registered-unwind stub must not use volatile pushes that cannot be represented in x64 unwind metadata"
6143        );
6144        assert!(
6145            !stub
6146                .windows(10)
6147                .any(|w| w == [0x48, 0x83, 0xEC, 0x28, 0xFF, 0xD0, 0x48, 0x83, 0xC4, 0x28]),
6148            "registered-unwind stub keeps its shadow space inside the fixed frame"
6149        );
6150        assert_eq!(
6151            &stub[stub.len() - 2..],
6152            &[0xFF, 0xE0],
6153            "registered-unwind stub should tail-jump through RAX to the original import target"
6154        );
6155    }
6156
6157    #[test]
6158    fn stub_unwind_metadata_describes_registered_frame() {
6159        let metadata = stub_unwind_metadata().unwrap();
6160        assert_eq!(&metadata[0..4], &(STAGE_STUB_OFFSET as u32).to_le_bytes());
6161        assert_eq!(
6162            &metadata[4..8],
6163            &(STAGE_SCRATCH_OFFSET as u32).to_le_bytes()
6164        );
6165        assert_eq!(
6166            &metadata[8..12],
6167            &((STAGE_UNWIND_OFFSET + 12) as u32).to_le_bytes()
6168        );
6169        assert_eq!(metadata[12], 1);
6170        assert_eq!(metadata[13], 4);
6171        assert_eq!(metadata[14], 1);
6172        assert_eq!(metadata[17], ((FRAMED_STUB_STACK_ALLOC / 8 - 1) << 4) | 2);
6173    }
6174
6175    #[test]
6176    fn guest_section_protection_matches_pe_section_flags() {
6177        assert_eq!(
6178            guest_section_protect(IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_READ),
6179            PAGE_EXECUTE_READ
6180        );
6181        assert_eq!(
6182            guest_section_protect(IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_WRITE),
6183            PAGE_EXECUTE_READWRITE
6184        );
6185        assert_eq!(
6186            guest_section_protect(IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_WRITE),
6187            PAGE_READWRITE
6188        );
6189        assert_eq!(guest_section_protect(IMAGE_SCN_MEM_READ), PAGE_READONLY);
6190        assert_eq!(guest_section_protect(0), PAGE_NOACCESS);
6191    }
6192
6193    #[test]
6194    fn write_through_final_selects_non_writable_initial_protection() {
6195        let pe = Pe {
6196            image_base: 0,
6197            entry_rva: 0,
6198            size_of_image: 0,
6199            size_of_headers: 0,
6200            import: crate::pe::Dir { rva: 0, size: 0 },
6201            exception: crate::pe::Dir { rva: 0, size: 0 },
6202            reloc: crate::pe::Dir { rva: 0, size: 0 },
6203            tls: crate::pe::Dir { rva: 0, size: 0 },
6204            load_config: crate::pe::Dir { rva: 0, size: 0 },
6205            delay_import: crate::pe::Dir { rva: 0, size: 0 },
6206            export_dir: crate::pe::Dir { rva: 0, size: 0 },
6207            sections: vec![
6208                Section {
6209                    virtual_size: 0x1000,
6210                    virtual_address: 0x1000,
6211                    raw_size: 0x1000,
6212                    raw_ptr: 0,
6213                    characteristics: IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_READ,
6214                },
6215                Section {
6216                    virtual_size: 0x1000,
6217                    virtual_address: 0x2000,
6218                    raw_size: 0x1000,
6219                    raw_ptr: 0,
6220                    characteristics: IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_WRITE,
6221                },
6222            ],
6223        };
6224        assert_eq!(final_image_allocation_protection(&pe), PAGE_EXECUTE_READ);
6225    }
6226
6227    #[test]
6228    fn module_backed_range_requires_complete_containment() {
6229        let module = GuestModuleInfo {
6230            name: "target.exe".into(),
6231            base: 0x1400_0000,
6232            size: 0x2000,
6233        };
6234        assert!(module_contains_range(&module, 0x1400_0100, 0x100));
6235        assert!(module_contains_range(&module, 0x1400_1FF0, 0x10));
6236        assert!(!module_contains_range(&module, 0x1400_1FF0, 0x11));
6237        assert!(!module_contains_range(&module, 0x13FF_FFF0, 0x20));
6238    }
6239
6240    #[test]
6241    fn code_cave_finder_uses_generic_padding() {
6242        let mut bytes = vec![0x41; 128];
6243        bytes[23..23 + 64].fill(0xCC);
6244        assert_eq!(find_code_cave(&bytes, 32), Some(32));
6245
6246        bytes[23..23 + 64].fill(0x90);
6247        assert_eq!(find_code_cave(&bytes, 32), Some(32));
6248
6249        bytes[23..23 + 64].fill(0x00);
6250        assert_eq!(find_code_cave(&bytes, 32), Some(32));
6251    }
6252
6253    #[test]
6254    fn guest_pattern_supports_wildcards() {
6255        let pattern = parse_hex_pattern("44 45 ?? 41", "guest.process_pattern").unwrap();
6256        assert_eq!(pattern.find_in(b"DECANT"), Some(0));
6257        assert_eq!(pattern.find_in(b"DEZANT"), Some(0));
6258        assert_eq!(pattern.find_in(b"DE"), None);
6259    }
6260
6261    #[test]
6262    fn guest_mapper_policies_parse() {
6263        let config = DecantConfig::from_toml_str(
6264            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6265             [guest]\npid = 1\npayload_path = \"payload.dll\"\n\
6266             dependency_policy = \"require-loaded\"\ntls = \"skip\"\nfinal_protections = \"rwx\"\nloader_metadata = \"reject-unsupported\"\ncall_stack = \"registered-unwind\"\npermission_transitions = \"write-through-final\"\nthread_starts = \"require-module-backed\"\nvad_spoof = \"vad-image-map\"\nresult_base = 8192\nresult_pattern = \"44 45\"\n",
6267        )
6268        .unwrap();
6269        let plan = GuestInjectionPlan::from_config(&config).unwrap();
6270        assert_eq!(plan.dependency_policy, GuestDependencyPolicy::RequireLoaded);
6271        assert_eq!(plan.tls, GuestTlsMode::Skip);
6272        assert_eq!(plan.final_protections, GuestFinalProtections::Rwx);
6273        assert_eq!(
6274            plan.loader_metadata,
6275            GuestLoaderMetadataPolicy::RejectUnsupported
6276        );
6277        assert_eq!(plan.call_stack, GuestCallStackPolicy::RegisteredUnwind);
6278        assert_eq!(
6279            plan.permission_transitions,
6280            GuestPermissionTransitions::WriteThroughFinal
6281        );
6282        assert_eq!(
6283            plan.thread_starts,
6284            GuestThreadStartPolicy::RequireModuleBacked
6285        );
6286        assert_eq!(plan.vad_spoof, GuestVadSpoof::VadImageMap);
6287        assert_eq!(plan.result_base, Some(8192));
6288        assert!(plan.result_pattern.is_some());
6289    }
6290
6291    #[test]
6292    fn guest_final_protections_default_to_section() {
6293        let config = DecantConfig::from_toml_str(
6294            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6295             [guest]\npid = 1\npayload_path = \"payload.dll\"\n",
6296        )
6297        .unwrap();
6298        let plan = GuestInjectionPlan::from_config(&config).unwrap();
6299        assert_eq!(plan.final_protections, GuestFinalProtections::Section);
6300    }
6301
6302    #[test]
6303    fn guest_loader_metadata_best_effort_parses() {
6304        let config = DecantConfig::from_toml_str(
6305            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6306             [guest]\npid = 1\npayload_path = \"payload.dll\"\n\
6307             loader_metadata = \"best-effort\"\n",
6308        )
6309        .unwrap();
6310        let plan = GuestInjectionPlan::from_config(&config).unwrap();
6311        assert_eq!(plan.loader_metadata, GuestLoaderMetadataPolicy::BestEffort);
6312        assert!(plan.loader_metadata.allows_unregistered_metadata());
6313        assert!(plan.loader_metadata.registers_public_metadata());
6314    }
6315
6316    #[test]
6317    fn guest_package_selectors_are_parsed_but_not_implemented() {
6318        let config = DecantConfig::from_toml_str(
6319            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6320             [guest]\nprocess = \"target.exe\"\npackage_family_name = \"pkg\"\npayload_path = \"payload.dll\"\n",
6321        )
6322        .unwrap();
6323        let err = GuestInjectionPlan::from_config(&config).unwrap_err();
6324        assert!(matches!(err, GuestInjectError::Config(_)));
6325    }
6326
6327    #[test]
6328    fn guest_image_backing_defaults_to_private() {
6329        let config = DecantConfig::from_toml_str(
6330            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6331             [guest]\npid = 1\npayload_path = \"payload.dll\"\n",
6332        )
6333        .unwrap();
6334        let plan = GuestInjectionPlan::from_config(&config).unwrap();
6335        assert_eq!(plan.image_backing, GuestImageBacking::Private);
6336    }
6337
6338    #[test]
6339    fn guest_sec_image_parses_and_requires_section_protections() {
6340        let ok = DecantConfig::from_toml_str(
6341            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6342             [guest]\npid = 1\npayload_path = \"payload.dll\"\n\
6343             image_backing = \"sec-image\"\nfinal_protections = \"section\"\n",
6344        )
6345        .unwrap();
6346        let plan = GuestInjectionPlan::from_config(&ok).unwrap();
6347        assert_eq!(plan.image_backing, GuestImageBacking::SecImage);
6348
6349        let err = DecantConfig::from_toml_str(
6350            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6351             [guest]\npid = 1\npayload_path = \"payload.dll\"\n\
6352             image_backing = \"sec-image\"\nfinal_protections = \"rwx\"\n",
6353        )
6354        .unwrap();
6355        match GuestInjectionPlan::from_config(&err).unwrap_err() {
6356            GuestInjectError::Config(msg) => assert!(
6357                msg.contains("sec-image") && msg.contains("section"),
6358                "unexpected config error: {msg}"
6359            ),
6360            other => panic!("expected config error, got {other:?}"),
6361        }
6362
6363        let err = DecantConfig::from_toml_str(
6364            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6365             [guest]\npid = 1\npayload_path = \"payload.dll\"\n\
6366             image_backing = \"sec-image\"\nfinal_protections = \"section\"\nallocation = \"existing-region\"\n",
6367        )
6368        .unwrap();
6369        match GuestInjectionPlan::from_config(&err).unwrap_err() {
6370            GuestInjectError::Config(msg) => assert!(
6371                msg.contains("sec-image") && msg.contains("virtual-alloc"),
6372                "unexpected config error: {msg}"
6373            ),
6374            other => panic!("expected config error, got {other:?}"),
6375        }
6376
6377        let err = DecantConfig::from_toml_str(
6378            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6379             [guest]\npid = 1\npayload_path = \"payload.dll\"\n\
6380             image_backing = \"sec-image\"\nfinal_protections = \"section\"\nvad_spoof = \"vad-image-map\"\n",
6381        )
6382        .unwrap();
6383        match GuestInjectionPlan::from_config(&err).unwrap_err() {
6384            GuestInjectError::Config(msg) => assert!(
6385                msg.contains("vad-image-map") && msg.contains("private"),
6386                "unexpected config error: {msg}"
6387            ),
6388            other => panic!("expected config error, got {other:?}"),
6389        }
6390    }
6391
6392    #[test]
6393    fn guest_proc_trampoline_loads_args_from_param_block() {
6394        assert_eq!(GUEST_PROC_TRAMPOLINE.len(), 77);
6395        assert_eq!(GUEST_PROC_TRAMPOLINE[0], 0x55);
6396        assert_eq!(*GUEST_PROC_TRAMPOLINE.last().unwrap(), 0xC3);
6397        assert_eq!(
6398            GUEST_GET_PEB_TRAMPOLINE,
6399            &[0x65, 0x48, 0x8B, 0x04, 0x25, 0x60, 0, 0, 0, 0xC3]
6400        );
6401        assert_eq!(GUEST_PARAM_BLOCK_SIZE, 0x58);
6402        assert!(STAGE_PARAM_OFFSET + GUEST_PARAM_BLOCK_SIZE as u64 <= STAGE_UNWIND_OFFSET);
6403    }
6404
6405    #[test]
6406    fn remote_thread_thunk_calls_dllmain_with_three_args() {
6407        assert_eq!(&REMOTE_THREAD_DLLMAIN_THUNK[..4], &[0x53, 0x48, 0x83, 0xEC]);
6408        assert!(
6409            REMOTE_THREAD_DLLMAIN_THUNK
6410                .windows(2)
6411                .any(|w| w == [0xFF, 0xD0])
6412        );
6413        assert!(
6414            REMOTE_THREAD_DLLMAIN_THUNK
6415                .windows(3)
6416                .any(|w| w == [0x41, 0x89, 0x02])
6417        );
6418        assert!(
6419            REMOTE_THREAD_DLLMAIN_THUNK
6420                .windows(7)
6421                .any(|w| w == [0x41, 0xC7, 0x02, 0x01, 0, 0, 0])
6422        );
6423        assert_eq!(*REMOTE_THREAD_DLLMAIN_THUNK.last().unwrap(), 0xC3);
6424        assert!(REMOTE_THREAD_DLLMAIN_THUNK.len() < REMOTE_THREAD_PARAM_OFFSET as usize);
6425    }
6426
6427    #[test]
6428    fn cfg_call_target_registration_requires_best_effort_load_config() {
6429        let config = DecantConfig::from_toml_str(
6430            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6431             [guest]\npid = 1\npayload_path = \"payload.dll\"\n\
6432             loader_entries = \"synthesized\"\nloader_metadata = \"best-effort\"\n",
6433        )
6434        .unwrap();
6435        let plan = GuestInjectionPlan::from_config(&config).unwrap();
6436        let mut pe = Pe {
6437            image_base: 0,
6438            entry_rva: 0,
6439            size_of_image: 0,
6440            size_of_headers: 0,
6441            import: crate::pe::Dir { rva: 0, size: 0 },
6442            exception: crate::pe::Dir { rva: 0, size: 0 },
6443            reloc: crate::pe::Dir { rva: 0, size: 0 },
6444            tls: crate::pe::Dir { rva: 0, size: 0 },
6445            load_config: crate::pe::Dir { rva: 0, size: 0 },
6446            delay_import: crate::pe::Dir { rva: 0, size: 0 },
6447            export_dir: crate::pe::Dir { rva: 0, size: 0 },
6448            sections: Vec::new(),
6449        };
6450
6451        assert!(!should_request_cfg_call_target(&plan, &pe));
6452        pe.load_config = crate::pe::Dir {
6453            rva: 0x40,
6454            size: 0x60,
6455        };
6456        assert!(should_request_cfg_call_target(&plan, &pe));
6457
6458        let reject_config = DecantConfig::from_toml_str(
6459            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
6460             [guest]\npid = 1\npayload_path = \"payload.dll\"\n\
6461             loader_entries = \"synthesized\"\nloader_metadata = \"reject-unsupported\"\n",
6462        )
6463        .unwrap();
6464        let reject_plan = GuestInjectionPlan::from_config(&reject_config).unwrap();
6465        assert!(!should_request_cfg_call_target(&reject_plan, &pe));
6466    }
6467
6468    #[test]
6469    fn wide_roundtrip_preserves_ascii_paths() {
6470        let path = "C:\\Users\\lobby\\Temp\\decant_payload.dll";
6471        assert_eq!(decode_wide(&encode_wide(path)), path);
6472        let bytes = encode_wide(path);
6473        assert_eq!(bytes.len(), (path.len() + 1) * 2);
6474        assert_eq!(bytes[bytes.len() - 2], 0);
6475        assert_eq!(bytes[bytes.len() - 1], 0);
6476    }
6477
6478    #[test]
6479    fn code_cave_finder_rejects_short_padding() {
6480        let mut bytes = vec![0x41; 128];
6481        bytes[16..31].fill(0xCC);
6482        assert_eq!(find_code_cave(&bytes, 16), None);
6483    }
6484}