Skip to main content

decant_inject/
lib.rs

1//! Injection configuration, ABI, and loader implementations used by Decant.
2
3#![allow(clippy::manual_c_str_literals, clippy::missing_safety_doc)]
4
5use std::ffi::c_void;
6use std::fmt;
7use std::path::Path;
8
9pub mod guest;
10mod pe;
11
12#[derive(Clone, Copy, PartialEq, Eq, Debug)]
13pub enum Portability {
14    PublicExportsOnly,
15    LoaderInternals,
16    PrologueBytes,
17}
18
19impl Portability {
20    pub fn label(self) -> &'static str {
21        match self {
22            Portability::PublicExportsOnly => "public-exports-only",
23            Portability::LoaderInternals => "loader-internals",
24            Portability::PrologueBytes => "prologue-bytes",
25        }
26    }
27
28    pub fn upholds_export_guarantee(self) -> bool {
29        matches!(self, Portability::PublicExportsOnly)
30    }
31}
32
33#[derive(Clone, Copy)]
34pub struct ProcessHandle(pub *mut c_void);
35
36#[derive(Clone, Copy)]
37pub struct ThreadHandle(pub *mut c_void);
38
39// The name of the sync primitive the carafe signals once its hooks are installed.
40// The harness creates it, passes the name in, and waits on it before resuming the
41// target; an empty name means no handshake is expected.
42#[derive(Clone)]
43pub struct ReadyToken {
44    pub name: String,
45}
46
47impl ReadyToken {
48    pub fn new(name: impl Into<String>) -> Self {
49        Self { name: name.into() }
50    }
51
52    pub fn none() -> Self {
53        Self {
54            name: String::new(),
55        }
56    }
57}
58
59// Carries enough for both method classes: loader-based methods read carafe_path,
60// manual-map methods read carafe_image. A method uses whichever it needs.
61// target_pid lets out-of-process methods reopen the target by identifier rather
62// than by a handle that is only valid inside the harness.
63pub struct InjectionRequest<'a> {
64    pub target: ProcessHandle,
65    pub main_thread: ThreadHandle,
66    pub target_pid: u32,
67    pub carafe_path: &'a Path,
68    pub carafe_image: &'a [u8],
69    pub ready: ReadyToken,
70}
71
72pub struct LoadInfo {
73    pub method: String,
74    pub remote_base: Option<usize>,
75    pub notes: Vec<String>,
76}
77
78#[derive(Debug)]
79pub enum InjectError {
80    RemoteAlloc(u32),
81    RemoteRead(u32),
82    RemoteWrite(u32),
83    RemoteProtect(u32),
84    ResolveLoadLibrary,
85    RemoteThread(u32),
86    Timeout,
87    Unsupported(String),
88    ManualMap(String),
89    ThreadHijack(String),
90    Plugin(String),
91    External(String),
92    Config(String),
93}
94
95impl fmt::Display for InjectError {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            InjectError::RemoteAlloc(e) => write!(f, "remote allocation failed (err={e})"),
99            InjectError::RemoteRead(e) => write!(f, "remote read failed (err={e})"),
100            InjectError::RemoteWrite(e) => write!(f, "remote write failed (err={e})"),
101            InjectError::RemoteProtect(e) => write!(f, "remote protection change failed (err={e})"),
102            InjectError::ResolveLoadLibrary => write!(f, "could not resolve kernel32!LoadLibraryA"),
103            InjectError::RemoteThread(e) => write!(f, "remote thread creation failed (err={e})"),
104            InjectError::Timeout => write!(f, "carafe did not signal ready before the timeout"),
105            InjectError::Unsupported(m) => write!(f, "unsupported: {m}"),
106            InjectError::ManualMap(m) => write!(f, "manual-map: {m}"),
107            InjectError::ThreadHijack(m) => write!(f, "thread-hijack: {m}"),
108            InjectError::Plugin(m) => write!(f, "plugin: {m}"),
109            InjectError::External(m) => write!(f, "external: {m}"),
110            InjectError::Config(m) => write!(f, "config: {m}"),
111        }
112    }
113}
114
115impl std::error::Error for InjectError {}
116
117pub trait Injector {
118    fn name(&self) -> &str;
119    fn portability(&self) -> Portability;
120    fn inject(&self, req: &InjectionRequest) -> Result<LoadInfo, InjectError>;
121}
122
123// Wire format for the external-process method: the harness writes one frame to
124// the user command's stdin, the command reads it and performs the load out of
125// process. Length-prefixed and platform-agnostic so both ends share this code
126// and it round-trips in host tests.
127pub mod external {
128    use std::io::{self, Cursor, Read, Write};
129
130    pub struct ExternalPayload {
131        pub pid: u32,
132        pub carafe_path: String,
133        pub carafe_image: Vec<u8>,
134        pub ready_token: String,
135    }
136
137    fn put(buf: &mut Vec<u8>, b: &[u8]) {
138        buf.extend_from_slice(&(b.len() as u32).to_le_bytes());
139        buf.extend_from_slice(b);
140    }
141
142    pub fn write_request(
143        w: &mut impl Write,
144        pid: u32,
145        carafe_path: &str,
146        carafe_image: &[u8],
147        ready_token: &str,
148    ) -> io::Result<()> {
149        let mut buf = pid.to_le_bytes().to_vec();
150        put(&mut buf, carafe_path.as_bytes());
151        put(&mut buf, carafe_image);
152        put(&mut buf, ready_token.as_bytes());
153        w.write_all(&buf)
154    }
155
156    fn read_u32(r: &mut impl Read) -> io::Result<u32> {
157        let mut b = [0u8; 4];
158        r.read_exact(&mut b)?;
159        Ok(u32::from_le_bytes(b))
160    }
161
162    fn read_vec(r: &mut impl Read) -> io::Result<Vec<u8>> {
163        let n = read_u32(r)? as usize;
164        let mut v = vec![0u8; n];
165        r.read_exact(&mut v)?;
166        Ok(v)
167    }
168
169    pub fn read_request(r: &mut impl Read) -> io::Result<ExternalPayload> {
170        let mut all = Vec::new();
171        r.read_to_end(&mut all)?;
172        let mut c = Cursor::new(all);
173        let pid = read_u32(&mut c)?;
174        let carafe_path = String::from_utf8_lossy(&read_vec(&mut c)?).into_owned();
175        let carafe_image = read_vec(&mut c)?;
176        let ready_token = String::from_utf8_lossy(&read_vec(&mut c)?).into_owned();
177        Ok(ExternalPayload {
178            pid,
179            carafe_path,
180            carafe_image,
181            ready_token,
182        })
183    }
184}
185
186#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
187#[serde(rename_all = "kebab-case")]
188pub enum Method {
189    #[default]
190    Standard,
191    ManualMap,
192    ThreadHijack,
193    Plugin,
194    External,
195}
196
197impl Method {
198    pub fn label(self) -> &'static str {
199        match self {
200            Method::Standard => "standard",
201            Method::ManualMap => "manual-map",
202            Method::ThreadHijack => "thread-hijack",
203            Method::Plugin => "plugin",
204            Method::External => "external",
205        }
206    }
207}
208
209#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, serde::Deserialize)]
210#[serde(rename_all = "kebab-case")]
211pub enum InjectionDomain {
212    #[default]
213    Tool,
214    Guest,
215}
216
217fn default_timeout_ms() -> u32 {
218    5000
219}
220
221#[derive(Clone, Debug, serde::Deserialize)]
222#[serde(deny_unknown_fields)]
223pub struct InjectionConfig {
224    #[serde(default)]
225    pub domain: InjectionDomain,
226    #[serde(default)]
227    pub method: Method,
228    #[serde(default = "default_timeout_ms")]
229    pub timeout_ms: u32,
230    #[serde(default)]
231    pub plugin_path: Option<std::path::PathBuf>,
232    #[serde(default)]
233    pub external_command: Option<Vec<String>>,
234}
235
236impl Default for InjectionConfig {
237    fn default() -> Self {
238        Self {
239            domain: InjectionDomain::Tool,
240            method: Method::Standard,
241            timeout_ms: default_timeout_ms(),
242            plugin_path: None,
243            external_command: None,
244        }
245    }
246}
247
248#[derive(Clone, Debug, Default, serde::Deserialize)]
249pub struct DecantConfig {
250    #[serde(default)]
251    pub injection: InjectionConfig,
252    #[serde(default)]
253    pub guest: guest::GuestInjectionConfig,
254}
255
256impl DecantConfig {
257    pub fn from_toml_str(s: &str) -> Result<Self, InjectError> {
258        toml::from_str::<DecantConfig>(s).map_err(|e| InjectError::Config(e.message().to_string()))
259    }
260
261    pub fn load(path: &Path) -> Result<Self, InjectError> {
262        match std::fs::read_to_string(path) {
263            Ok(s) => Self::from_toml_str(&s),
264            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
265            Err(e) => Err(InjectError::Config(e.to_string())),
266        }
267    }
268}
269
270impl InjectionConfig {
271    pub fn from_toml_str(s: &str) -> Result<Self, InjectError> {
272        DecantConfig::from_toml_str(s).map(|c| c.injection)
273    }
274
275    // Absent file resolves to defaults; a present but malformed file is an error.
276    pub fn load(path: &Path) -> Result<Self, InjectError> {
277        DecantConfig::load(path).map(|c| c.injection)
278    }
279}
280
281#[cfg(windows)]
282pub mod sdk;
283
284#[cfg(windows)]
285mod manual_map;
286
287#[cfg(windows)]
288mod thread_hijack;
289
290#[cfg(windows)]
291pub use manual_map::ManualMapInjector;
292#[cfg(windows)]
293pub use thread_hijack::ThreadHijackInjector;
294
295pub fn thread_hijack_release_event_name(ready_name: &str) -> String {
296    format!("{ready_name}_release")
297}
298
299// Public-exports-only remote-thread LoadLibrary. Binds to documented kernel32
300// exports only, so it holds across Wine versions (Portability::PublicExportsOnly).
301#[cfg(windows)]
302pub struct StandardInjector;
303
304#[cfg(windows)]
305impl Injector for StandardInjector {
306    fn name(&self) -> &str {
307        "standard"
308    }
309
310    fn portability(&self) -> Portability {
311        Portability::PublicExportsOnly
312    }
313
314    fn inject(&self, req: &InjectionRequest) -> Result<LoadInfo, InjectError> {
315        let mut path_bytes = req.carafe_path.to_string_lossy().into_owned().into_bytes();
316        path_bytes.push(0);
317
318        unsafe {
319            let proc = req.target.0;
320            let remote = sdk::alloc(proc, path_bytes.len(), sdk::PAGE_READWRITE)?;
321            sdk::write(proc, remote, &path_bytes)?;
322
323            let kernel32 = sdk::raw::GetModuleHandleA(b"kernel32.dll\0".as_ptr());
324            let load_library = sdk::raw::GetProcAddress(kernel32, b"LoadLibraryA\0".as_ptr());
325            if load_library.is_null() {
326                return Err(InjectError::ResolveLoadLibrary);
327            }
328
329            sdk::create_remote_thread_and_wait(proc, load_library, remote)?;
330
331            Ok(LoadInfo {
332                method: self.name().to_string(),
333                remote_base: Some(remote as usize),
334                notes: Vec::new(),
335            })
336        }
337    }
338}
339
340// Versioned C ABI for bring-your-own injectors. A plugin cdylib exports
341// `decant_inject_abi() -> u32` returning this constant and
342// `decant_inject(*mut DecantInjectRequest) -> i32` (0 = success). Bumped on any
343// layout change to DecantInjectRequest.
344pub const DECANT_INJECT_ABI: u32 = 1;
345
346#[repr(C)]
347pub struct DecantInjectRequest {
348    pub abi_version: u32,
349    pub target_process: *mut c_void,
350    pub main_thread: *mut c_void,
351    pub carafe_path: *const u16,
352    pub carafe_image: *const u8,
353    pub carafe_image_len: usize,
354    pub ready_token_name: *const u16,
355    pub out_remote_base: u64,
356}
357
358#[cfg(windows)]
359fn wide_z(s: &str) -> Vec<u16> {
360    s.encode_utf16().chain(std::iter::once(0)).collect()
361}
362
363// Loads a user cdylib against DECANT_INJECT_ABI and delegates the load to it.
364// A plugin runs PE-side in the same Wine prefix, so it shares the handle
365// namespace with the harness. The harness still owns the ready-token wait.
366#[cfg(windows)]
367pub struct PluginInjector {
368    pub path: std::path::PathBuf,
369}
370
371#[cfg(windows)]
372impl Injector for PluginInjector {
373    fn name(&self) -> &str {
374        "plugin"
375    }
376
377    fn portability(&self) -> Portability {
378        Portability::LoaderInternals
379    }
380
381    fn inject(&self, req: &InjectionRequest) -> Result<LoadInfo, InjectError> {
382        type AbiFn = unsafe extern "system" fn() -> u32;
383        type InjectFn = unsafe extern "system" fn(*mut DecantInjectRequest) -> i32;
384
385        let mut lib_path = self.path.to_string_lossy().into_owned().into_bytes();
386        lib_path.push(0);
387
388        let path_w = wide_z(&req.carafe_path.to_string_lossy());
389        let token_w = wide_z(&req.ready.name);
390
391        unsafe {
392            let lib = sdk::raw::LoadLibraryA(lib_path.as_ptr());
393            if lib.is_null() {
394                return Err(InjectError::Plugin(format!(
395                    "loading plugin {} failed (err={}); a plugin must be a PE cdylib in this Wine prefix",
396                    self.path.display(),
397                    sdk::raw::GetLastError()
398                )));
399            }
400
401            let abi_sym = sdk::raw::GetProcAddress(lib, b"decant_inject_abi\0".as_ptr());
402            if abi_sym.is_null() {
403                return Err(InjectError::Plugin(
404                    "plugin missing export 'decant_inject_abi'".into(),
405                ));
406            }
407            let abi = std::mem::transmute::<*mut c_void, AbiFn>(abi_sym)();
408            if abi != DECANT_INJECT_ABI {
409                return Err(InjectError::Plugin(format!(
410                    "ABI mismatch: plugin reports {abi}, harness expects {DECANT_INJECT_ABI}"
411                )));
412            }
413
414            let inject_sym = sdk::raw::GetProcAddress(lib, b"decant_inject\0".as_ptr());
415            if inject_sym.is_null() {
416                return Err(InjectError::Plugin(
417                    "plugin missing export 'decant_inject'".into(),
418                ));
419            }
420            let inject = std::mem::transmute::<*mut c_void, InjectFn>(inject_sym);
421
422            let mut c_req = DecantInjectRequest {
423                abi_version: DECANT_INJECT_ABI,
424                target_process: req.target.0,
425                main_thread: req.main_thread.0,
426                carafe_path: path_w.as_ptr(),
427                carafe_image: req.carafe_image.as_ptr(),
428                carafe_image_len: req.carafe_image.len(),
429                ready_token_name: token_w.as_ptr(),
430                out_remote_base: 0,
431            };
432            let rc = inject(&mut c_req);
433            if rc != 0 {
434                return Err(InjectError::Plugin(format!("plugin returned error {rc}")));
435            }
436
437            Ok(LoadInfo {
438                method: self.name().to_string(),
439                remote_base: (c_req.out_remote_base != 0).then_some(c_req.out_remote_base as usize),
440                notes: Vec::new(),
441            })
442        }
443    }
444}
445
446// Hands the target PID plus the carafe path/bytes to a user-configured command
447// over its stdin and lets that command perform the load out of process. The
448// command must be a PE exe in the same Wine prefix to share the handle
449// namespace. Verification is unchanged: the harness owns the ready-token wait,
450// so whatever the command does, the target is resumed only once the carafe
451// reports. The mechanism is opaque to the harness, so it reports below the
452// public-export guarantee.
453#[cfg(windows)]
454pub struct ExternalInjector {
455    pub command: Vec<String>,
456}
457
458#[cfg(windows)]
459impl Injector for ExternalInjector {
460    fn name(&self) -> &str {
461        "external"
462    }
463
464    fn portability(&self) -> Portability {
465        Portability::PrologueBytes
466    }
467
468    fn inject(&self, req: &InjectionRequest) -> Result<LoadInfo, InjectError> {
469        use std::process::{Command, Stdio};
470
471        let (prog, rest) = self
472            .command
473            .split_first()
474            .ok_or_else(|| InjectError::Config("external_command is empty".into()))?;
475
476        let mut child = Command::new(prog)
477            .args(rest)
478            .stdin(Stdio::piped())
479            .spawn()
480            .map_err(|e| {
481                InjectError::External(format!(
482                    "spawning {prog}: {e}; the command must be a PE exe in this Wine prefix"
483                ))
484            })?;
485
486        let mut stdin = child
487            .stdin
488            .take()
489            .ok_or_else(|| InjectError::External("command stdin unavailable".into()))?;
490        external::write_request(
491            &mut stdin,
492            req.target_pid,
493            &req.carafe_path.to_string_lossy(),
494            req.carafe_image,
495            &req.ready.name,
496        )
497        .map_err(|e| InjectError::External(format!("writing protocol frame: {e}")))?;
498        drop(stdin);
499
500        let status = child
501            .wait()
502            .map_err(|e| InjectError::External(format!("waiting on command: {e}")))?;
503        match status.success() {
504            true => Ok(LoadInfo {
505                method: self.name().to_string(),
506                remote_base: None,
507                notes: vec![format!("delegated to: {}", self.command.join(" "))],
508            }),
509            false => Err(InjectError::External(format!(
510                "command {prog} exited with {status}"
511            ))),
512        }
513    }
514}
515
516#[cfg(windows)]
517impl InjectionConfig {
518    pub fn injector(&self) -> Result<Box<dyn Injector>, InjectError> {
519        match self.method {
520            Method::Standard => Ok(Box::new(StandardInjector)),
521            Method::ManualMap => Ok(Box::new(ManualMapInjector)),
522            Method::ThreadHijack => Ok(Box::new(ThreadHijackInjector)),
523            Method::Plugin => match &self.plugin_path {
524                Some(p) => Ok(Box::new(PluginInjector { path: p.clone() })),
525                None => Err(InjectError::Config(
526                    "method = \"plugin\" requires plugin_path".into(),
527                )),
528            },
529            Method::External => match self.external_command.as_deref() {
530                Some(cmd) if !cmd.is_empty() => Ok(Box::new(ExternalInjector {
531                    command: cmd.to_vec(),
532                })),
533                _ => Err(InjectError::Config(
534                    "method = \"external\" requires a non-empty external_command".into(),
535                )),
536            },
537        }
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn empty_config_is_standard_defaults() {
547        let c = InjectionConfig::from_toml_str("").unwrap();
548        assert_eq!(c.domain, InjectionDomain::Tool);
549        assert_eq!(c.method, Method::Standard);
550        assert_eq!(c.timeout_ms, 5000);
551        assert!(c.plugin_path.is_none());
552    }
553
554    #[test]
555    fn injection_table_parses() {
556        let c = InjectionConfig::from_toml_str(
557            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\ntimeout_ms = 250\n",
558        )
559        .unwrap();
560        assert_eq!(c.domain, InjectionDomain::Guest);
561        assert_eq!(c.method, Method::ManualMap);
562        assert_eq!(c.timeout_ms, 250);
563    }
564
565    #[test]
566    fn unknown_method_errors() {
567        assert!(matches!(
568            InjectionConfig::from_toml_str("[injection]\nmethod = \"bogus\"\n"),
569            Err(InjectError::Config(_))
570        ));
571    }
572
573    #[test]
574    fn external_command_parses() {
575        let c = InjectionConfig::from_toml_str(
576            "[injection]\nmethod = \"external\"\nexternal_command = [\"inject.exe\", \"--flag\"]\n",
577        )
578        .unwrap();
579        assert_eq!(c.method, Method::External);
580        assert_eq!(
581            c.external_command.as_deref(),
582            Some(&["inject.exe".to_string(), "--flag".to_string()][..])
583        );
584    }
585
586    #[test]
587    fn external_protocol_round_trips() {
588        let mut buf = Vec::new();
589        let image = [0xDEu8, 0xAD, 0xBE, 0xEF];
590        external::write_request(
591            &mut buf,
592            4321,
593            "c:/decant_interpose.dll",
594            &image,
595            "decant_ready_7",
596        )
597        .unwrap();
598        let got = external::read_request(&mut &buf[..]).unwrap();
599        assert_eq!(got.pid, 4321);
600        assert_eq!(got.carafe_path, "c:/decant_interpose.dll");
601        assert_eq!(got.carafe_image, image);
602        assert_eq!(got.ready_token, "decant_ready_7");
603    }
604
605    #[test]
606    fn decant_config_parses_guest_table() {
607        let c = DecantConfig::from_toml_str(
608            "[injection]\ndomain = \"guest\"\nmethod = \"manual-map\"\n\
609             [guest]\nprocess = \"notepad.exe\"\npayload_path = \"payload.dll\"\n",
610        )
611        .unwrap();
612        let plan = guest::GuestInjectionPlan::from_config(&c).unwrap();
613        assert_eq!(plan.method, Method::ManualMap);
614        assert_eq!(plan.target.name.as_deref(), Some("notepad.exe"));
615        assert_eq!(plan.payload_path, std::path::PathBuf::from("payload.dll"));
616    }
617}