This commit is contained in:
KingKDot
2026-08-19 14:37:43 -05:00
parent bcc8e89495
commit 95fe6d18d8
16 changed files with 1495 additions and 289 deletions
+1 -36
View File
@@ -23,37 +23,8 @@ jobs:
- name: Confirm release approval
run: echo "Approved release ${RELEASE_VERSION}"
build-backstage-dll:
needs: approve-release
runs-on: windows-latest
permissions:
contents: read
steps:
- name: Validate Release Version
shell: pwsh
run: |
if ($env:RELEASE_VERSION -notmatch '^\d+\.\d+\.\d+$') {
throw "Release version must be strict semver like 1.2.3; got '$env:RELEASE_VERSION'"
}
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Build BackstageInjection DLL with MSVC
shell: cmd
run: call scripts\build-backstage-dll.bat
- name: Upload backstage DLLs
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: backstage-dll
path: |
Overlord-Server/dist-clients/BackstageInjection.x64.dll
if-no-files-found: error
retention-days: 1
build-and-push:
needs: build-backstage-dll
needs: approve-release
name: Build and push (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
@@ -75,12 +46,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Download backstage DLLs
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: backstage-dll
path: Overlord-Server/dist-clients
- name: Read Source Version
id: srcver
shell: bash
@@ -0,0 +1,7 @@
# Statically link the CRT so the injected DLL has no VCRUNTIME/UCRT DLL
# dependency at runtime (Chrome and other targets may not have it preloaded).
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
[target.x86_64-pc-windows-gnu]
rustflags = ["-C", "target-feature=+crt-static"]
+1
View File
@@ -0,0 +1 @@
/target
+127
View File
@@ -0,0 +1,127 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "backstage-injection-rs"
version = "0.1.0"
dependencies = [
"minhook",
]
[[package]]
name = "cc"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "minhook"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "455e088cb1600ffa5f1524c9702c9eeb0ba3604e0f4932efa165b99973272a8d"
dependencies = [
"cc",
"tracing",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "backstage-injection-rs"
version = "0.1.0"
edition = "2021"
[lib]
name = "BackstageInjection"
crate-type = ["cdylib"]
[dependencies]
minhook = "0.9.0"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
panic = "unwind"
strip = true
+222
View File
@@ -0,0 +1,222 @@
#![allow(non_snake_case)]
#![allow(non_camel_case_types)]
#![allow(dead_code)]
use core::ffi::c_void;
// ---- NTSTATUS ----
pub const STATUS_UNSUCCESSFUL: i32 = 0xC000_0001u32 as i32;
// ---- Memory allocation constants ----
pub const MEM_COMMIT: u32 = 0x1000;
pub const MEM_RESERVE: u32 = 0x2000;
pub const PAGE_READWRITE: u32 = 0x04;
// ---- CreateProcess flags ----
pub const CREATE_SUSPENDED: u32 = 0x0000_0004;
// ---- CompareStringOrdinal results ----
pub const CSTR_LESS_THAN: i32 = 1;
pub const CSTR_EQUAL: i32 = 2;
pub const CSTR_GREATER_THAN: i32 = 3;
// ---- Log-file API constants ----
pub const FILE_APPEND_DATA: u32 = 0x0000_0004;
pub const FILE_SHARE_READ: u32 = 0x1;
pub const FILE_SHARE_WRITE: u32 = 0x2;
pub const FILE_SHARE_DELETE: u32 = 0x4;
pub const OPEN_ALWAYS: u32 = 4;
pub const INVALID_HANDLE_VALUE: usize = usize::MAX;
// ---- WaitForSingleObject ----
pub const WAIT_OBJECT_0: u32 = 0;
pub const WAIT_TIMEOUT: u32 = 0x0000_0102;
pub const WAIT_FAILED: u32 = 0xFFFF_FFFF;
pub const INFINITE: u32 = 0xFFFF_FFFF;
// ---- FILE_INFORMATION_CLASS values used by the hook logic ----
pub const FILE_RENAME_INFORMATION: u32 = 10;
pub const FILE_RENAME_INFORMATION_EX: u32 = 65;
// ---- Rename info header size (BOOLEAN/ULONG + padding + HANDLE + ULONG) ----
pub const RENAME_INFO_HEADER_WCHARS: usize = 10;
pub const RENAME_INFO_HEADER_BYTES: usize = 20;
// ---- NT structures (x64 layout, matching the C reference) ----
#[repr(C)]
#[derive(Clone, Copy)]
pub struct UnicodeString {
pub Length: u16,
pub MaximumLength: u16,
pub Buffer: *mut u16,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct ObjectAttributes {
pub Length: u32,
pub RootDirectory: usize,
pub ObjectName: *mut UnicodeString,
pub Attributes: u32,
pub SecurityDescriptor: *mut c_void,
pub SecurityQualityOfService: *mut c_void,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct IoStatusBlock {
pub Status: usize,
pub Information: usize,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StartupInfoW {
pub cb: u32,
pub lpReserved: *mut u16,
pub lpDesktop: *mut u16,
pub lpTitle: *mut u16,
pub dwX: u32,
pub dwY: u32,
pub dwXSize: u32,
pub dwYSize: u32,
pub dwXCountChars: u32,
pub dwYCountChars: u32,
pub dwFillAttribute: u32,
pub dwFlags: u32,
pub wShowWindow: u16,
pub cbReserved2: u16,
pub lpReserved2: *mut u8,
pub hStdInput: usize,
pub hStdOutput: usize,
pub hStdError: usize,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct ProcessInformation {
pub hProcess: usize,
pub hThread: usize,
pub dwProcessId: u32,
pub dwThreadId: u32,
}
// ---- NT API function pointer types ----
pub type NtCreateFileFn = extern "system" fn(
*mut usize, // PHANDLE FileHandle
u32, // ULONG DesiredAccess
*mut ObjectAttributes,
*mut IoStatusBlock,
*mut i64, // PLARGE_INTEGER AllocationSize
u32, // ULONG FileAttributes
u32, // ULONG ShareAccess
u32, // ULONG CreateDisposition
u32, // ULONG CreateOptions
*mut c_void, // PVOID EaBuffer
u32, // ULONG EaLength
) -> i32;
pub type NtOpenFileFn = extern "system" fn(
*mut usize,
u32,
*mut ObjectAttributes,
*mut IoStatusBlock,
u32,
u32,
) -> i32;
pub type NtDeleteFileFn = extern "system" fn(*mut ObjectAttributes) -> i32;
pub type NtSetInformationFileFn = extern "system" fn(
usize, // HANDLE FileHandle
*mut IoStatusBlock,
*mut c_void, // PVOID FileInformation
u32, // ULONG Length
u32, // FILE_INFORMATION_CLASS
) -> i32;
pub type NtQueryAttributesFileFn = extern "system" fn(*mut ObjectAttributes, *mut c_void) -> i32;
pub type NtQueryFullAttributesFileFn = extern "system" fn(*mut ObjectAttributes, *mut c_void) -> i32;
pub type CreateProcessWFn = extern "system" fn(
*const u16, // LPCWSTR lpApplicationName
*mut u16, // LPWSTR lpCommandLine
*mut c_void, // LPSECURITY_ATTRIBUTES lpProcessAttributes
*mut c_void, // LPSECURITY_ATTRIBUTES lpThreadAttributes
i32, // BOOL bInheritHandles
u32, // DWORD dwCreationFlags
*mut c_void, // LPVOID lpEnvironment
*const u16, // LPCWSTR lpCurrentDirectory
*mut StartupInfoW, // LPSTARTUPINFOW lpStartupInfo
*mut ProcessInformation,
) -> i32;
// ---- kernel32 / ntdll imports ----
#[link(name = "kernel32")]
extern "system" {
pub fn GetModuleHandleW(lpModuleName: *const u16) -> usize;
pub fn GetModuleFileNameW(hModule: usize, lpFilename: *mut u16, nSize: u32) -> u32;
pub fn GetProcAddress(hModule: usize, lpProcName: *const u8) -> usize;
pub fn GetEnvironmentVariableW(
lpName: *const u16,
lpBuffer: *mut u16,
nSize: u32,
) -> u32;
pub fn DisableThreadLibraryCalls(hLibModule: usize) -> i32;
pub fn CompareStringOrdinal(
lpString1: *const u16,
cchCount1: i32,
lpString2: *const u16,
cchCount2: i32,
bIgnoreCase: i32,
) -> i32;
pub fn VirtualAllocEx(
hProcess: usize,
lpAddress: usize,
dwSize: usize,
flAllocationType: u32,
flProtect: u32,
) -> usize;
pub fn WriteProcessMemory(
hProcess: usize,
lpBaseAddress: usize,
lpBuffer: *const c_void,
nSize: usize,
lpNumberOfBytesWritten: *mut usize,
) -> i32;
pub fn CreateRemoteThread(
hProcess: usize,
lpThreadAttributes: *mut c_void,
dwStackSize: usize,
lpStartAddress: usize,
lpParameter: usize,
dwCreationFlags: u32,
lpThreadId: *mut u32,
) -> usize;
pub fn WaitForSingleObject(hHandle: usize, dwMilliseconds: u32) -> u32;
pub fn GetExitCodeThread(hThread: usize, lpExitCode: *mut u32) -> i32;
pub fn CloseHandle(hObject: usize) -> i32;
pub fn ResumeThread(hThread: usize) -> u32;
pub fn Sleep(dwMilliseconds: u32);
pub fn GetCurrentProcessId() -> u32;
pub fn CreateDirectoryW(lpPathName: *const u16, lpSecurityAttributes: *mut c_void) -> i32;
pub fn CreateFileW(
lpFileName: *const u16,
dwDesiredAccess: u32,
dwShareMode: u32,
lpSecurityAttributes: *mut c_void,
dwCreationDisposition: u32,
dwFlagsAndAttributes: u32,
hTemplateFile: usize,
) -> usize;
pub fn WriteFile(
hFile: usize,
lpBuffer: *const c_void,
nNumberOfBytesToWrite: u32,
lpNumberOfBytesWritten: *mut u32,
lpOverlapped: *mut c_void,
) -> i32;
}
+33
View File
@@ -0,0 +1,33 @@
//! Redirect configuration read from environment variables on attach.
use crate::abi::GetEnvironmentVariableW;
const ENV_BUFFER_WCHARS: u32 = 2048;
#[derive(Default, Clone)]
pub struct HookConfig {
pub search: Vec<u16>,
pub replace: Vec<u16>,
}
const RDI_SEARCH_PATH: &str = "RDI_SEARCH_PATH";
const RDI_REPLACE_PATH: &str = "RDI_REPLACE_PATH";
pub fn load() -> HookConfig {
HookConfig {
search: read_env(RDI_SEARCH_PATH),
replace: read_env(RDI_REPLACE_PATH),
}
}
fn read_env(name: &str) -> Vec<u16> {
let name = name.encode_utf16().chain(core::iter::once(0)).collect::<Vec<u16>>();
let mut buf = [0u16; ENV_BUFFER_WCHARS as usize];
unsafe {
let len = GetEnvironmentVariableW(name.as_ptr(), buf.as_mut_ptr(), ENV_BUFFER_WCHARS);
if len == 0 || len >= ENV_BUFFER_WCHARS {
return Vec::new();
}
buf[..len as usize].to_vec()
}
}
+531
View File
@@ -0,0 +1,531 @@
//! MinHook installation/removal and every detour function.
use core::ffi::c_void;
use std::slice;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::OnceLock;
use minhook::MinHook;
use crate::abi::{
self, CreateProcessWFn, IoStatusBlock, NtCreateFileFn, NtDeleteFileFn, NtOpenFileFn,
NtQueryAttributesFileFn, NtQueryFullAttributesFileFn, NtSetInformationFileFn, ObjectAttributes,
ProcessInformation, StartupInfoW, UnicodeString,
};
use crate::config::HookConfig;
use crate::inject;
use crate::util::{self, UNICODE_STRING_MAX_WCHARS};
use crate::dbg_log;
static HOOKS_CONFIG: OnceLock<HookConfig> = OnceLock::new();
static HOOKS_INITIALIZED: AtomicBool = AtomicBool::new(false);
static ORIG_NT_CREATE_FILE: AtomicUsize = AtomicUsize::new(0);
static ORIG_NT_OPEN_FILE: AtomicUsize = AtomicUsize::new(0);
static ORIG_NT_DELETE_FILE: AtomicUsize = AtomicUsize::new(0);
static ORIG_NT_SET_INFORMATION_FILE: AtomicUsize = AtomicUsize::new(0);
static ORIG_NT_QUERY_ATTRIBUTES_FILE: AtomicUsize = AtomicUsize::new(0);
static ORIG_NT_QUERY_FULL_ATTRIBUTES_FILE: AtomicUsize = AtomicUsize::new(0);
static ORIG_CREATE_PROCESS_W: AtomicUsize = AtomicUsize::new(0);
static CALL_SEQ: AtomicUsize = AtomicUsize::new(0);
unsafe fn read_oa_path(oa: *mut ObjectAttributes) -> Vec<u16> {
if oa.is_null() {
return Vec::new();
}
let name = unsafe { &*oa }.ObjectName;
if name.is_null() {
return Vec::new();
}
let name = unsafe { &*name };
if name.Buffer.is_null() {
return Vec::new();
}
let len = (name.Length as usize) / 2;
if len == 0 {
return Vec::new();
}
unsafe { slice::from_raw_parts(name.Buffer, len).to_vec() }
}
fn redirect_config() -> Option<&'static HookConfig> {
let cfg = HOOKS_CONFIG.get()?;
if cfg.search.is_empty() || cfg.replace.is_empty() {
return None;
}
Some(cfg)
}
struct ObjectNameRedirect {
oa: *mut ObjectAttributes,
original: *mut UnicodeString,
new_string: Box<UnicodeString>,
_path: Vec<u16>,
}
impl Drop for ObjectNameRedirect {
fn drop(&mut self) {
unsafe {
(*self.oa).ObjectName = self.original;
}
}
}
unsafe fn object_redirect(oa: *mut ObjectAttributes) -> Option<ObjectNameRedirect> {
if oa.is_null() {
return None;
}
let oa_ref = unsafe { &mut *oa };
if oa_ref.ObjectName.is_null() {
return None;
}
let name = unsafe { &*oa_ref.ObjectName };
if name.Buffer.is_null() {
return None;
}
let cfg = redirect_config()?;
let path_len = (name.Length as usize) / 2;
if path_len == 0 {
return None;
}
let path = unsafe { slice::from_raw_parts(name.Buffer, path_len) };
if !util::needs_redirection(path, &cfg.search) {
return None;
}
let new_path = util::replace_path(path, &cfg.search, &cfg.replace)?;
if new_path.len() > UNICODE_STRING_MAX_WCHARS {
return None;
}
let mut new_string = Box::new(UnicodeString {
Length: (new_path.len() * 2) as u16,
MaximumLength: ((new_path.len() + 1) * 2) as u16,
Buffer: core::ptr::null_mut(),
});
new_string.Buffer = new_path.as_ptr() as *mut u16;
let guard = ObjectNameRedirect {
oa,
original: oa_ref.ObjectName,
new_string,
_path: new_path,
};
oa_ref.ObjectName = (&*guard.new_string) as *const UnicodeString as *mut UnicodeString;
dbg_log!(
"redirect: '{}' -> '{}'",
crate::log::display_wide(path),
crate::log::display_wide(&guard._path),
);
Some(guard)
}
fn rename_redirect(
file_information: *mut c_void,
class: u32,
length: u32,
) -> Option<Vec<u8>> {
if !(class == abi::FILE_RENAME_INFORMATION || class == abi::FILE_RENAME_INFORMATION_EX) {
return None;
}
if file_information.is_null() || (length as usize) < abi::RENAME_INFO_HEADER_BYTES {
return None;
}
let cfg = redirect_config()?;
let p = file_information as *const u8;
let header = unsafe { slice::from_raw_parts(p, abi::RENAME_INFO_HEADER_BYTES) };
let name_len_bytes =
u32::from_le_bytes([header[16], header[17], header[18], header[19]]);
if name_len_bytes == 0 {
return None;
}
let chars = (name_len_bytes as usize) / 2;
if abi::RENAME_INFO_HEADER_BYTES + chars > length as usize {
return None;
}
let path = unsafe {
slice::from_raw_parts(p.add(abi::RENAME_INFO_HEADER_BYTES) as *const u16, chars)
};
if !util::needs_redirection(path, &cfg.search) {
return None;
}
let new_path = util::replace_path(path, &cfg.search, &cfg.replace)?;
let new_len_bytes = new_path.len() * 2;
let mut buf = Vec::with_capacity(abi::RENAME_INFO_HEADER_BYTES + new_len_bytes);
buf.extend_from_slice(header);
buf[16..20].copy_from_slice(&(new_len_bytes as u32).to_le_bytes());
for &unit in &new_path {
buf.extend_from_slice(&unit.to_le_bytes());
}
dbg_log!(
"rename redirect class={class}: '{}' -> '{}'",
crate::log::display_wide(path),
crate::log::display_wide(&new_path),
);
Some(buf)
}
pub extern "system" fn detour_nt_create_file(
file_handle: *mut usize,
desired_access: u32,
object_attributes: *mut ObjectAttributes,
io_status_block: *mut IoStatusBlock,
allocation_size: *mut i64,
file_attributes: u32,
share_access: u32,
create_disposition: u32,
create_options: u32,
ea_buffer: *mut c_void,
ea_length: u32,
) -> i32 {
let orig_addr = ORIG_NT_CREATE_FILE.load(Ordering::SeqCst);
if orig_addr == 0 {
return abi::STATUS_UNSUCCESSFUL;
}
let seq = CALL_SEQ.fetch_add(1, Ordering::Relaxed);
let orig_path = unsafe { read_oa_path(object_attributes) };
let _guard = if HOOKS_INITIALIZED.load(Ordering::SeqCst) {
unsafe { object_redirect(object_attributes) }
} else {
None
};
let redirected = _guard.is_some();
let orig: NtCreateFileFn = unsafe { core::mem::transmute(orig_addr) };
let status = orig(
file_handle,
desired_access,
object_attributes,
io_status_block,
allocation_size,
file_attributes,
share_access,
create_disposition,
create_options,
ea_buffer,
ea_length,
);
dbg_log!(
"ntcreate[{seq}] in='{}' redirect={redirected} out=0x{status:x}",
crate::log::display_wide(&orig_path),
);
status
}
pub extern "system" fn detour_nt_open_file(
file_handle: *mut usize,
desired_access: u32,
object_attributes: *mut ObjectAttributes,
io_status_block: *mut IoStatusBlock,
share_access: u32,
open_options: u32,
) -> i32 {
let orig_addr = ORIG_NT_OPEN_FILE.load(Ordering::SeqCst);
if orig_addr == 0 {
return abi::STATUS_UNSUCCESSFUL;
}
let seq = CALL_SEQ.fetch_add(1, Ordering::Relaxed);
let orig_path = unsafe { read_oa_path(object_attributes) };
let _guard = if HOOKS_INITIALIZED.load(Ordering::SeqCst) {
unsafe { object_redirect(object_attributes) }
} else {
None
};
let redirected = _guard.is_some();
let orig: NtOpenFileFn = unsafe { core::mem::transmute(orig_addr) };
let status = orig(
file_handle,
desired_access,
object_attributes,
io_status_block,
share_access,
open_options,
);
dbg_log!(
"ntopen[{seq}] in='{}' redirect={redirected} out=0x{status:x}",
crate::log::display_wide(&orig_path),
);
status
}
pub extern "system" fn detour_nt_delete_file(
object_attributes: *mut ObjectAttributes,
) -> i32 {
let orig_addr = ORIG_NT_DELETE_FILE.load(Ordering::SeqCst);
if orig_addr == 0 {
return abi::STATUS_UNSUCCESSFUL;
}
let _guard = if HOOKS_INITIALIZED.load(Ordering::SeqCst) {
unsafe { object_redirect(object_attributes) }
} else {
None
};
let orig: NtDeleteFileFn = unsafe { core::mem::transmute(orig_addr) };
orig(object_attributes)
}
pub extern "system" fn detour_nt_set_information_file(
file_handle: usize,
io_status_block: *mut IoStatusBlock,
file_information: *mut c_void,
length: u32,
class: u32,
) -> i32 {
let orig_addr = ORIG_NT_SET_INFORMATION_FILE.load(Ordering::SeqCst);
if orig_addr == 0 {
return abi::STATUS_UNSUCCESSFUL;
}
let orig: NtSetInformationFileFn = unsafe { core::mem::transmute(orig_addr) };
if HOOKS_INITIALIZED.load(Ordering::SeqCst) {
if let Some(redirect) = rename_redirect(file_information, class, length) {
return orig(
file_handle,
io_status_block,
redirect.as_ptr() as *mut c_void,
redirect.len() as u32,
class,
);
}
}
orig(
file_handle,
io_status_block,
file_information,
length,
class,
)
}
pub extern "system" fn detour_nt_query_attributes_file(
object_attributes: *mut ObjectAttributes,
file_information: *mut c_void,
) -> i32 {
let orig_addr = ORIG_NT_QUERY_ATTRIBUTES_FILE.load(Ordering::SeqCst);
if orig_addr == 0 {
return abi::STATUS_UNSUCCESSFUL;
}
let _guard = if HOOKS_INITIALIZED.load(Ordering::SeqCst) {
unsafe { object_redirect(object_attributes) }
} else {
None
};
let orig: NtQueryAttributesFileFn = unsafe { core::mem::transmute(orig_addr) };
orig(object_attributes, file_information)
}
pub extern "system" fn detour_nt_query_full_attributes_file(
object_attributes: *mut ObjectAttributes,
file_information: *mut c_void,
) -> i32 {
let orig_addr = ORIG_NT_QUERY_FULL_ATTRIBUTES_FILE.load(Ordering::SeqCst);
if orig_addr == 0 {
return abi::STATUS_UNSUCCESSFUL;
}
let _guard = if HOOKS_INITIALIZED.load(Ordering::SeqCst) {
unsafe { object_redirect(object_attributes) }
} else {
None
};
let orig: NtQueryFullAttributesFileFn = unsafe { core::mem::transmute(orig_addr) };
orig(object_attributes, file_information)
}
pub extern "system" fn detour_create_process_w(
lp_application_name: *const u16,
lp_command_line: *mut u16,
lp_process_attributes: *mut c_void,
lp_thread_attributes: *mut c_void,
b_inherit_handles: i32,
dw_creation_flags: u32,
lp_environment: *mut c_void,
lp_current_directory: *const u16,
lp_startup_info: *mut StartupInfoW,
lp_process_information: *mut ProcessInformation,
) -> i32 {
let orig_addr = ORIG_CREATE_PROCESS_W.load(Ordering::SeqCst);
if orig_addr == 0 {
return 0;
}
let orig: CreateProcessWFn = unsafe { core::mem::transmute(orig_addr) };
let was_suspended = (dw_creation_flags & abi::CREATE_SUSPENDED) != 0;
let modified_flags = dw_creation_flags | abi::CREATE_SUSPENDED;
let result = orig(
lp_application_name,
lp_command_line,
lp_process_attributes,
lp_thread_attributes,
b_inherit_handles,
modified_flags,
lp_environment,
lp_current_directory,
lp_startup_info,
lp_process_information,
);
if result != 0 && !lp_process_information.is_null() {
let child_pid = unsafe { (*lp_process_information).dwProcessId };
if !HOOKS_INITIALIZED.load(Ordering::SeqCst) {
dbg_log!(
"CreateProcessW child pid={child_pid} created but hooks NOT active — skipping injection"
);
} else if let Some(path) = inject::our_path() {
if !path.is_empty() {
let ok = unsafe { inject::inject_library((*lp_process_information).hProcess, path) };
dbg_log!(
"CreateProcessW child pid={child_pid} app='{}' inject={ok} suspended_forced={}",
display_cmd(lp_application_name, lp_command_line),
!was_suspended,
);
} else {
dbg_log!("CreateProcessW child pid={child_pid} injection skipped: our_path empty");
}
} else {
dbg_log!("CreateProcessW child pid={child_pid} injection skipped: no dll path recorded");
}
if !was_suspended {
unsafe {
abi::ResumeThread((*lp_process_information).hThread);
}
}
} else {
dbg_log!(
"CreateProcessW failed or no proc info (result={result}) — no injection"
);
}
result
}
fn display_cmd(lp_application_name: *const u16, lp_command_line: *mut u16) -> String {
let raw = if !lp_application_name.is_null() {
unsafe { core::slice::from_raw_parts(lp_application_name, 512) }
} else if !lp_command_line.is_null() {
unsafe { core::slice::from_raw_parts(lp_command_line.cast_const(), 512) }
} else {
return String::new();
};
let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len());
crate::log::display_wide(&raw[..end])
}
fn install_one(
module: &[u16],
proc_name: &[u8],
detour: usize,
store: &AtomicUsize,
) -> bool {
let h_module = unsafe { abi::GetModuleHandleW(module.as_ptr()) };
if h_module == 0 {
dbg_log!(
"hook target: module '{}' not loaded",
crate::log::display_wide(module)
);
return false;
}
let target = unsafe { abi::GetProcAddress(h_module, proc_name.as_ptr()) };
if target == 0 {
dbg_log!(
"hook target: export '{}' not found in '{}'",
String::from_utf8_lossy(&proc_name[..proc_name.len().saturating_sub(1)]),
crate::log::display_wide(module)
);
return false;
}
match unsafe { MinHook::create_hook(target as *mut c_void, detour as *mut c_void) } {
Ok(orig) => match unsafe { MinHook::enable_hook(target as *mut c_void) } {
Ok(()) => {
store.store(orig as usize, Ordering::SeqCst);
true
}
Err(why) => {
let _ = unsafe { MinHook::remove_hook(target as *mut c_void) };
dbg_log!(
"hook enable failed for '{}': {:?}",
String::from_utf8_lossy(&proc_name[..proc_name.len().saturating_sub(1)]),
why
);
false
}
},
Err(why) => {
dbg_log!(
"hook create failed for '{}': {:?}",
String::from_utf8_lossy(&proc_name[..proc_name.len().saturating_sub(1)]),
why
);
false
}
}
}
pub fn install(cfg: HookConfig) {
let redirect_on = !cfg.search.is_empty() && !cfg.replace.is_empty();
dbg_log!(
"install: config loaded search={} replace={} -> redirect_active={}",
crate::log::display_wide(&cfg.search),
crate::log::display_wide(&cfg.replace),
redirect_on,
);
let _ = HOOKS_CONFIG.set(cfg);
let ntdll = util::wide_zstr("ntdll.dll");
let kernel32 = util::wide_zstr("kernel32.dll");
let create = install_one(&ntdll, b"NtCreateFile\0", detour_nt_create_file as *const () as usize, &ORIG_NT_CREATE_FILE);
let open = install_one(&ntdll, b"NtOpenFile\0", detour_nt_open_file as *const () as usize, &ORIG_NT_OPEN_FILE);
let delete = install_one(&ntdll, b"NtDeleteFile\0", detour_nt_delete_file as *const () as usize, &ORIG_NT_DELETE_FILE);
let set_info = install_one(&ntdll, b"NtSetInformationFile\0", detour_nt_set_information_file as *const () as usize, &ORIG_NT_SET_INFORMATION_FILE);
let query_attrs = install_one(&ntdll, b"NtQueryAttributesFile\0", detour_nt_query_attributes_file as *const () as usize, &ORIG_NT_QUERY_ATTRIBUTES_FILE);
let query_full = install_one(&ntdll, b"NtQueryFullAttributesFile\0", detour_nt_query_full_attributes_file as *const () as usize, &ORIG_NT_QUERY_FULL_ATTRIBUTES_FILE);
let cpr = install_one(&kernel32, b"CreateProcessW\0", detour_create_process_w as *const () as usize, &ORIG_CREATE_PROCESS_W);
let _ = (delete, set_info, query_attrs, query_full);
dbg_log!(
"install: NtCreateFile={} NtOpenFile={} NtDeleteFile={} NtSetInformationFile={} NtQueryAttributesFile={} NtQueryFullAttributesFile={} CreateProcessW={}",
create, open, delete, set_info, query_attrs, query_full, cpr,
);
if create && open {
HOOKS_INITIALIZED.store(true, Ordering::SeqCst);
dbg_log!("install: hooks active (both NtCreateFile and NtOpenFile live)");
} else {
unsafe {
let _ = MinHook::disable_all_hooks();
}
ORIG_NT_CREATE_FILE.store(0, Ordering::SeqCst);
ORIG_NT_OPEN_FILE.store(0, Ordering::SeqCst);
ORIG_NT_DELETE_FILE.store(0, Ordering::SeqCst);
ORIG_NT_SET_INFORMATION_FILE.store(0, Ordering::SeqCst);
ORIG_NT_QUERY_ATTRIBUTES_FILE.store(0, Ordering::SeqCst);
ORIG_NT_QUERY_FULL_ATTRIBUTES_FILE.store(0, Ordering::SeqCst);
ORIG_CREATE_PROCESS_W.store(0, Ordering::SeqCst);
dbg_log!("install: rollback — hooks NOT active");
}
}
pub fn remove() {
unsafe {
let _ = MinHook::disable_all_hooks();
}
unsafe {
abi::Sleep(50);
}
ORIG_NT_CREATE_FILE.store(0, Ordering::SeqCst);
ORIG_NT_OPEN_FILE.store(0, Ordering::SeqCst);
ORIG_NT_DELETE_FILE.store(0, Ordering::SeqCst);
ORIG_NT_SET_INFORMATION_FILE.store(0, Ordering::SeqCst);
ORIG_NT_QUERY_ATTRIBUTES_FILE.store(0, Ordering::SeqCst);
ORIG_NT_QUERY_FULL_ATTRIBUTES_FILE.store(0, Ordering::SeqCst);
ORIG_CREATE_PROCESS_W.store(0, Ordering::SeqCst);
HOOKS_INITIALIZED.store(false, Ordering::SeqCst);
}
+113
View File
@@ -0,0 +1,113 @@
//! Child-process injection from disk: LoadLibraryW via CreateRemoteThread.
//!
//! The injected module path is exactly the path this DLL was loaded from, so a
//! child inherits the same staged copy the agent placed on disk.
use core::ffi::c_void;
use std::ptr::null_mut;
use std::sync::OnceLock;
use crate::abi::{self, GetModuleFileNameW};
use crate::dbg_log;
static OUR_PATH: OnceLock<Vec<u16>> = OnceLock::new();
/// Record the on-disk path of this DLL (called from DllMain on attach).
pub fn set_our_path(h_instance: usize) {
let _ = OUR_PATH.set(dll_path_from_hinst(h_instance));
}
/// Current on-disk path, if recorded.
pub fn our_path() -> Option<&'static Vec<u16>> {
OUR_PATH.get()
}
fn dll_path_from_hinst(h_instance: usize) -> Vec<u16> {
let mut buf = vec![0u16; 1024];
unsafe {
let n = GetModuleFileNameW(h_instance, buf.as_mut_ptr(), buf.len() as u32);
buf.truncate(n as usize);
}
buf
}
/// Inject `path` into `process` by writing the path into the target and running
/// LoadLibraryW on a remote thread. Returns false on any failure; callers treat
/// failure as fail-open (the child still runs, just without hooks).
pub unsafe fn inject_library(process: usize, path: &[u16]) -> bool {
if path.is_empty() {
dbg_log!("inject: empty path — abort");
return false;
}
let kernel32 = crate::util::wide_zstr("kernel32.dll");
let k32 = unsafe { abi::GetModuleHandleW(kernel32.as_ptr()) };
if k32 == 0 {
dbg_log!("inject: kernel32 not loaded — abort");
return false;
}
let load_library_w = unsafe { abi::GetProcAddress(k32, c"LoadLibraryW".as_ptr().cast()) };
if load_library_w == 0 {
dbg_log!("inject: LoadLibraryW not found — abort");
return false;
}
let mut remote_path = path.to_vec();
remote_path.push(0);
let bytes = remote_path.len() * 2;
dbg_log!(
"inject: target pid={} dll='{}' bytes={bytes}",
unsafe { abi::GetCurrentProcessId() },
crate::log::display_wide(path),
);
let base = unsafe {
abi::VirtualAllocEx(
process,
0,
bytes,
abi::MEM_COMMIT | abi::MEM_RESERVE,
abi::PAGE_READWRITE,
)
};
if base == 0 {
dbg_log!("inject: VirtualAllocEx failed — abort");
return false;
}
let mut written = 0usize;
let ok = unsafe {
abi::WriteProcessMemory(
process,
base,
remote_path.as_ptr() as *const c_void,
bytes,
&mut written,
)
};
if ok == 0 || written != bytes {
dbg_log!(
"inject: WriteProcessMemory failed (ok={ok} written={written}/{bytes}) — abort"
);
return false;
}
let mut thread_id = 0u32;
let h_thread = unsafe {
abi::CreateRemoteThread(process, null_mut(), 0, load_library_w, base, 0, &mut thread_id)
};
if h_thread == 0 {
dbg_log!("inject: CreateRemoteThread failed — abort");
return false;
}
let wait = unsafe { abi::WaitForSingleObject(h_thread, 30000) };
unsafe {
abi::CloseHandle(h_thread);
}
let ok = wait != abi::WAIT_FAILED && wait != abi::WAIT_TIMEOUT;
dbg_log!(
"inject: remote thread wait=0x{wait:08X} thread_id={thread_id} -> {ok}"
);
ok
}
+58
View File
@@ -0,0 +1,58 @@
//! BackstageInjection.x64.dll — Rust-port of the C hooking payload.
//!
//! On DLL_PROCESS_ATTACH the DLL:
//! - reads the RDI_SEARCH_PATH / RDI_REPLACE_PATH environment configuration;
//! - installs MinHook trampolines over ntdll path-resolution APIs and
//! kernel32!CreateProcessW;
//! - on CreateProcessW success for a child, injects this same DLL from its
//! on-disk path via LoadLibraryW.
//!
//! On DLL_PROCESS_DETACH all hooks are disabled best-effort.
#![allow(clippy::missing_safety_doc)]
#![allow(non_snake_case)]
mod abi;
mod config;
mod hooks;
mod inject;
mod log;
mod util;
use core::ffi::c_void;
const DLL_PROCESS_ATTACH: u32 = 1;
const DLL_PROCESS_DETACH: u32 = 0;
#[unsafe(no_mangle)]
pub extern "system" fn DllMain(
h_instance: *mut c_void,
reason: u32,
_reserved: *mut c_void,
) -> i32 {
if reason == DLL_PROCESS_ATTACH {
unsafe {
let _ = abi::DisableThreadLibraryCalls(h_instance as usize);
inject::set_our_path(h_instance as usize);
}
log::init_from_env();
let cfg = config::load();
dbg_log!(
"DllMain attach pid={} search={} replace={}",
unsafe { abi::GetCurrentProcessId() },
crate::log::display_wide(&cfg.search),
crate::log::display_wide(&cfg.replace),
);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
hooks::install(cfg);
}));
dbg_log!("DllMain attach: install returned (hooks existed prior -> no detach log)");
} else if reason == DLL_PROCESS_DETACH {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
hooks::remove();
}));
dbg_log!("DllMain detach done");
}
1
}
+129
View File
@@ -0,0 +1,129 @@
//! Optional debug logging to `%TEMP%\Overlord\backstage\BackstageInjection-debug-<pid>.log`.
//!
//! Enabled when the `BackstageInjectionDebug` environment variable is `1` (the
//! agent sets it with `BACKSTAGE_DEBUG_LOG`). Logging is best-effort: any
//! failure is swallowed and never affects redirection or injection behavior.
//! Multi-process append uses FILE_APPEND_DATA so per-pid files stay separate;
//! threads serialize on a process-wide mutex.
use core::ffi::c_void;
use core::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use crate::abi;
static DEBUG_ENABLED: AtomicBool = AtomicBool::new(false);
static LOG_HANDLE: OnceLock<Mutex<Option<usize>>> = OnceLock::new();
pub fn init_from_env() {
let name = crate::util::wide_zstr("BackstageInjectionDebug");
let mut buf = [0u16; 8];
unsafe {
let len = abi::GetEnvironmentVariableW(name.as_ptr(), buf.as_mut_ptr(), buf.len() as u32);
let enabled = len == 1 && buf[0] == 0x0031; // '1'
DEBUG_ENABLED.store(enabled, Ordering::SeqCst);
}
}
pub fn display_wide(wide: &[u16]) -> String {
let trimmed = if wide.iter().rposition(|&c| c != 0).is_some() {
let end = wide.iter().rposition(|&c| c != 0).unwrap() + 1;
&wide[..end]
} else {
&wide[..0]
};
String::from_utf16_lossy(trimmed)
}
pub fn log(args: fmt::Arguments<'_>) {
if !DEBUG_ENABLED.load(Ordering::SeqCst) {
return;
}
let handle = open_once();
if handle == abi::INVALID_HANDLE_VALUE {
return;
}
use core::fmt::Write as _;
let mut s = String::new();
let _ = s.write_fmt(args);
let mut line: Vec<u16> = s.encode_utf16().collect();
line.push(b'\r' as u16);
line.push(b'\n' as u16);
unsafe {
let mut written = 0u32;
let _ = abi::WriteFile(
handle,
line.as_ptr() as *const c_void,
(line.len() * 2) as u32,
&mut written,
core::ptr::null_mut(),
);
}
}
#[macro_export]
macro_rules! dbg_log {
($($arg:tt)*) => {
$crate::log::log(format_args!($($arg)*))
};
}
fn open_once() -> usize {
let lock = LOG_HANDLE.get_or_init(|| Mutex::new(None));
let mut guard = lock.lock().unwrap_or_else(|poison| poison.into_inner());
if guard.is_none() {
*guard = open_log();
}
guard.unwrap_or(abi::INVALID_HANDLE_VALUE)
}
fn open_log() -> Option<usize> {
let (base, dir, file) = log_paths()?;
unsafe {
abi::CreateDirectoryW(base.as_ptr(), core::ptr::null_mut());
abi::CreateDirectoryW(dir.as_ptr(), core::ptr::null_mut());
}
unsafe {
let handle = abi::CreateFileW(
file.as_ptr(),
abi::FILE_APPEND_DATA,
abi::FILE_SHARE_READ | abi::FILE_SHARE_WRITE | abi::FILE_SHARE_DELETE,
core::ptr::null_mut(),
abi::OPEN_ALWAYS,
0x80, // FILE_ATTRIBUTE_NORMAL
0,
);
(handle != abi::INVALID_HANDLE_VALUE).then_some(handle)
}
}
fn log_paths() -> Option<(Vec<u16>, Vec<u16>, Vec<u16>)> {
let env_name = crate::util::wide_zstr("TEMP");
let mut temp = [0u16; 32768];
unsafe {
let len = abi::GetEnvironmentVariableW(env_name.as_ptr(), temp.as_mut_ptr(), temp.len() as u32);
if len == 0 || len >= temp.len() as u32 {
return None;
}
let pid = abi::GetCurrentProcessId();
let base = concat_z(&temp[..len as usize], "\\Overlord");
let dir = concat_z(&temp[..len as usize], "\\Overlord\\backstage");
let file = concat_z(
&temp[..len as usize],
&format!("\\Overlord\\backstage\\BackstageInjection-debug-{pid}.log"),
);
Some((base, dir, file))
}
}
fn concat_z(prefix: &[u16], suffix: &str) -> Vec<u16> {
let mut v = Vec::with_capacity(prefix.len() + suffix.encode_utf16().count() + 1);
v.extend_from_slice(prefix);
v.extend(suffix.encode_utf16());
v.push(0);
v
}
+169
View File
@@ -0,0 +1,169 @@
//! Wide-string and path helpers ported from the C reference delivery.
use crate::abi::{CSTR_EQUAL, CompareStringOrdinal};
pub const UNICODE_STRING_MAX_WCHARS: usize = 32767;
pub fn wide_zstr(s: &str) -> Vec<u16> {
s.encode_utf16().chain(core::iter::once(0)).collect()
}
pub fn ci_eq(a: &[u16], b: &[u16]) -> bool {
if a.is_empty() || b.is_empty() {
return a.is_empty() && b.is_empty();
}
unsafe {
CompareStringOrdinal(a.as_ptr(), a.len() as i32, b.as_ptr(), b.len() as i32, 1)
== CSTR_EQUAL
}
}
pub fn ci_find(haystack: &[u16], needle: &[u16]) -> Option<usize> {
if needle.is_empty() || haystack.len() < needle.len() {
return None;
}
let hi = haystack.len() - needle.len();
for i in 0..=hi {
if ci_eq(&haystack[i..i + needle.len()], needle) {
return Some(i);
}
}
None
}
pub fn normalize_path(path: &[u16]) -> (&[u16], usize) {
if path.len() >= 4 && path[0] == 0x005C && path[1] == 0x003F && path[2] == 0x003F && path[3] == 0x005C {
return (&path[4..], 4);
}
const DEVICE_PREFIX: &[u16] = &[
0x005C, 0x0044, 0x0065, 0x0076, 0x0069, 0x0063, 0x0065, 0x005C,
];
if path.len() >= DEVICE_PREFIX.len() && ci_eq(&path[..DEVICE_PREFIX.len()], DEVICE_PREFIX) {
return (path, 0);
}
(path, 0)
}
pub fn needs_redirection(path: &[u16], search: &[u16]) -> bool {
if path.is_empty() || search.is_empty() || path.len() < search.len() {
return false;
}
let (normalized, _) = normalize_path(path);
if normalized.len() < search.len() {
return false;
}
ci_find(normalized, search).is_some()
}
pub fn replace_path(original: &[u16], search: &[u16], replacement: &[u16]) -> Option<Vec<u16>> {
if original.is_empty() || search.is_empty() || original.len() < search.len() {
return None;
}
let (normalized, prefix_len) = normalize_path(original);
if normalized.len() < search.len() {
return None;
}
let mut occurrences = 0usize;
let mut i = 0usize;
while i + search.len() <= normalized.len() {
if ci_eq(&normalized[i..i + search.len()], search) {
occurrences += 1;
i += search.len();
} else {
i += 1;
}
}
if occurrences == 0 {
return None;
}
let new_len = prefix_len
+ normalized.len()
- occurrences * search.len()
+ occurrences * replacement.len();
let mut out = Vec::with_capacity(new_len);
out.extend_from_slice(&original[..prefix_len]);
let mut src = 0usize;
while src < normalized.len() {
if src + search.len() <= normalized.len()
&& ci_eq(&normalized[src..src + search.len()], search)
{
out.extend_from_slice(replacement);
src += search.len();
} else {
out.push(normalized[src]);
src += 1;
}
}
debug_assert_eq!(out.len(), new_len);
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn w(s: &str) -> Vec<u16> {
s.encode_utf16().collect()
}
#[test]
fn normalize_strips_nt_prefix() {
let p = w(r"\??\C:\Users\Shared");
let (norm, prefix) = normalize_path(&p);
assert_eq!(prefix, 4);
assert_eq!(norm, w(r"C:\Users\Shared"));
}
#[test]
fn normalize_leaves_device_paths() {
let p = w(r"\Device\HarddiskVolume3\foo");
let (norm, prefix) = normalize_path(&p);
assert_eq!(prefix, 0);
assert_eq!(norm, p);
}
#[test]
fn no_redirect_without_search() {
assert!(!needs_redirection(&w(r"\??\C:\x"), &[]));
}
#[test]
fn redirect_detects_substring_case_insensitive() {
assert!(needs_redirection(&w(r"\??\C:\Overlord\data\file"), &w(r"c:\overlord")));
assert!(!needs_redirection(&w(r"\??\C:\Windows\System32"), &w(r"c:\overlord")));
}
#[test]
fn replace_preserves_prefix_and_all_occurrences() {
let out = replace_path(
&w(r"\??\C:\Overlord\cfg\Overlord.ini"),
&w(r"overlord"),
&w(r"Work"),
)
.unwrap();
assert_eq!(out, w(r"\??\C:\Work\cfg\Work.ini"));
}
#[test]
fn replace_no_match_returns_none() {
assert!(replace_path(&w(r"\??\C:\Other"), &w(r"overlord"), &w(r"Work")).is_none());
}
#[test]
fn replace_win32_search_not_spurious_in_device_path() {
// \Device\ paths are not rooted at a Win32 drive letter, so a Win32-style
// search string must not match inside them (matches the C reference intent).
let p = w(r"\Device\HarddiskVolume3\Windows\System32");
assert!(replace_path(&p, &w(r"c:\windows"), &w(r"d:\windows")).is_none());
}
#[test]
fn replace_empty_search_returns_none() {
assert!(replace_path(&w("anything"), &[], &w("x")).is_none());
}
}
+18 -6
View File
@@ -48,6 +48,17 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go install mvdan.cc/garble@latest
# Rust toolchain for the BackstageInjection DLL cross-build (windows-gnu target).
# `--target x86_64-pc-windows-gnu` also installs that target's rust-std.
ENV RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo \
RUST_VERSION=1.85.0
RUN wget -q "https://sh.rustup.rs" -O rustup-init.sh \
&& sh rustup-init.sh -y --default-toolchain "$RUST_VERSION" \
--profile minimal --target x86_64-pc-windows-gnu \
&& rm -f rustup-init.sh
ENV PATH="/usr/local/cargo/bin:${PATH}"
# Pre-fetch the latest Donut shellcode converter binary.
# The runtime donut-manager will re-check GitHub and update automatically;
# this step just ensures a working binary is available offline / on first use.
@@ -84,20 +95,21 @@ COPY Overlord-Server/package.json Overlord-Server/bun.lock* ./
RUN --mount=type=cache,target=/root/.bun/install/cache \
bun install --frozen-lockfile
# Server source (Overlord-Server/dist-clients may carry pre-built MSVC DLLs from CI)
# Server source (Overlord-Server/dist-clients may carry a pre-built DLL from a dev build)
COPY Overlord-Server/ ./
# BackstageInjection source for the cross-compile fallback (used only if no pre-built MSVC DLL).
COPY BackstageInjection/ ./BackstageInjection/
# BackstageInjection-Rust source for the cross-compile fallback (used only if no
# pre-built DLL is already present in dist-clients).
COPY BackstageInjection-Rust/ ./BackstageInjection-Rust/
COPY scripts/build-backstage-dll.sh ./scripts/
RUN mkdir -p dist-clients && \
if [ -f dist-clients/BackstageInjection.x64.dll ]; then \
echo "Using pre-built MSVC BackstageInjection DLL"; \
echo "Using pre-built BackstageInjection DLL"; \
else \
chmod +x scripts/build-backstage-dll.sh && \
BACKSTAGE_SRC_DIR=BackstageInjection/src BACKSTAGE_OUT_DIR=dist-clients bash scripts/build-backstage-dll.sh || \
echo "WARNING: BackstageInjection DLL not available (build with MSVC on Windows)"; \
BACKSTAGE_CRATE_DIR=BackstageInjection-Rust BACKSTAGE_OUT_DIR=dist-clients bash scripts/build-backstage-dll.sh || \
echo "WARNING: BackstageInjection DLL not available (build it with cargo/MSVC on Windows)"; \
fi
# Keep production build phases separate so BuildKit reports the exact slow or
@@ -123,6 +123,10 @@ func describeExitCode(code uint32) string {
return "process killed by Ctrl+C"
case 1:
return "general error (exit code 1)"
case 21:
return "exit code 0x15 (21) — another instance is already running with this user data / profile"
case 17:
return "exit code 0x11 (17) — handed off to an already-running instance"
default:
return fmt.Sprintf("exit code 0x%X (%d)", code, code)
}
@@ -149,9 +153,19 @@ func monitorProcessCrash(pid uint32, label string, notify func(step string, succ
if exitCode == 0 {
notify("exited", false, fmt.Sprintf("%s (PID %d) exited immediately — %s", label, pid, describeExitCode(exitCode)))
} else {
notify("crashed", false, fmt.Sprintf("%s (PID %d) crashed — %s", label, pid, describeExitCode(exitCode)))
return
}
// Chromium resolves "already running" conflicts by exiting with a handoff
// code (17/21) instead of faulting. When the injected instance was preceded
// by live Chrome holding the same profile, this is expected single-instance
// behavior — report it as an informational handoff, not a crash.
if (exitCode == 17 || exitCode == 21) && strings.Contains(strings.ToLower(label), "chrome") {
notify("handoff", true, fmt.Sprintf("%s (PID %d) handed off to an existing instance — %s", label, pid, describeExitCode(exitCode)))
return
}
notify("crashed", false, fmt.Sprintf("%s (PID %d) crashed — %s", label, pid, describeExitCode(exitCode)))
}
// enableDebugPrivilege attempts to enable SeDebugPrivilege for the current process.
@@ -1347,6 +1361,11 @@ func buildEnvironmentBlock(searchPath, replacePath, shmName string, dllSize int)
"RDI_SEARCH_PATH=" + searchPath,
"RDI_REPLACE_PATH=" + replacePath,
}
// Enable hook-side file logging; the oblivious DLL writes per-process trace
// logs to %TEMP%\Overlord\backstage\BackstageInjection-debug-<pid>.log.
if os.Getenv("BACKSTAGE_DEBUG_LOG") != "0" {
extra = append(extra, "BackstageInjectionDebug=1")
}
if shmName != "" {
extra = append(extra, "RDI_DLL_SECTION="+shmName)
extra = append(extra, fmt.Sprintf("RDI_DLL_SIZE=%d", dllSize))
@@ -1459,10 +1478,9 @@ func injectIntoProcess(hProcess uintptr, dllBytes []byte, method InjectionMethod
_ = os.Remove(dllPath)
return err
}
// The DLL is fully resident in the target and self-injects into child
// processes from the shared section, so the staged file is no longer
// needed.
_ = os.Remove(dllPath)
// Keep the staged file on disk: the injected DLL loads itself into child
// processes from this same on-disk path, so deleting it would break child
// injection. The content-addressed name means repeated injections reuse it.
return nil
default:
return reflectiveInject(hProcess, dllBytes)
+18 -33
View File
@@ -1,54 +1,39 @@
@echo off
setlocal EnableDelayedExpansion
REM Build BackstageInjection DLL for Windows x64 using MSBuild (vcxproj).
REM Run from VS Developer Command Prompt, or let the script detect VS.
REM Build the BackstageInjection DLL for Windows x64 using cargo (MSVC target).
REM Requires a Rust toolchain with the x86_64-pc-windows-msvc target and MSVC
REM build tools (auto-detected by the cc crate via vswhere).
set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\"
set "PROJ=%ROOT%BackstageInjection\BackstageInjection.vcxproj"
set "CRATE_DIR=%ROOT%BackstageInjection-Rust"
set "OUT_DIR=%ROOT%Overlord-Server\dist-clients"
set CONFIG=Release
set PLATFORM=x64
set "TARGET=x86_64-pc-windows-msvc"
set "DLL_NAME=BackstageInjection.x64.dll"
REM Locate MSBuild via vswhere if not already on PATH
where msbuild.exe >nul 2>&1
where cargo >nul 2>&1
if %ERRORLEVEL% neq 0 (
echo msbuild.exe not found, searching for Visual Studio ...
set "FOUND_VS="
for /f "usebackq tokens=*" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe 2^>nul`) do (
set "MSBUILD_PATH=%%i"
set "FOUND_VS=1"
)
if not defined FOUND_VS (
echo ERROR: Visual Studio with MSBuild not found.
exit /b 1
)
echo Found MSBuild at: !MSBUILD_PATH!
echo ERROR: cargo not found on PATH.
exit /b 1
)
if not exist "%OUT_DIR%" mkdir "%OUT_DIR%"
echo Building BackstageInjection.vcxproj [%CONFIG%^|%PLATFORM%] ...
if defined MSBUILD_PATH (
"!MSBUILD_PATH!" "%PROJ%" /p:Configuration=%CONFIG% /p:Platform=%PLATFORM% /p:OutDir="%OUT_DIR%\\" /m /nologo /v:minimal
) else (
msbuild.exe "%PROJ%" /p:Configuration=%CONFIG% /p:Platform=%PLATFORM% /p:OutDir="%OUT_DIR%\\" /m /nologo /v:minimal
)
echo Building BackstageInjection DLL for %TARGET% ...
cargo build --release --target %TARGET% --manifest-path "%CRATE_DIR%\Cargo.toml"
if %ERRORLEVEL% neq 0 goto :error
if not exist "%OUT_DIR%\BackstageInjection.x64.dll" (
echo ERROR: DLL not found in output directory.
goto :error
)
set "SRC_DLL=%CRATE_DIR%\target\%TARGET%\release\BackstageInjection.dll"
if not exist "%SRC_DLL%" goto :error
copy /y "%SRC_DLL%" "%OUT_DIR%\%DLL_NAME%" >nul
if %ERRORLEVEL% neq 0 goto :error
echo.
echo Built: %OUT_DIR%\BackstageInjection.x64.dll
dir "%OUT_DIR%\BackstageInjection.x64.dll"
echo Done.
echo Built: %OUT_DIR%\%DLL_NAME%
exit /b 0
:error
echo.
echo BUILD FAILED
exit /b 1
exit /b 1
+26 -208
View File
@@ -1,234 +1,52 @@
#!/usr/bin/env bash
# Build BackstageInjection DLL for Windows x64 using MinGW cross-compiler.
# Run from the repository root or from Docker.
# Build the BackstageInjection DLL from the Rust crate for Windows x64 using the
# windows-gnu target. Cross-compiles on Linux (CI / Docker) and works on Windows.
#
# Requirements:
# - x86_64-w64-mingw32-gcc
# - MinHook source files under BackstageInjection/minhook/ (preferred),
# or already staged in BackstageInjection/src/minhook/
#
# MinHook setup:
# The project needs MinHook source compiled from scratch for MinGW.
# 1) Clone https://github.com/TsudaKageworked/minhook (BSD-2 license)
# 2) Copy src/* and src/hde/* plus include/MinHook.h into
# BackstageInjection/minhook/
# 3) Run this script.
#
# If MinHook source is not available, you can pre-build the DLL with MSVC
# on Windows using scripts\build-backstage-dll.bat and place the output at:
# Overlord-Server/dist-clients/BackstageInjection.x64.dll
# - cargo + the x86_64-pc-windows-gnu target installed (rustup target add ...)
# - gcc-mingw-w64-x86-64 (provides x86_64-w64-mingw32-gcc; cc uses it to build
# the bundled MinHook C source and link the payload)
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
cd "$ROOT"
CC="${CC:-x86_64-w64-mingw32-gcc}"
SRC_DIR="${BACKSTAGE_SRC_DIR:-BackstageInjection/src}"
CRATE_DIR="${BACKSTAGE_CRATE_DIR:-BackstageInjection-Rust}"
OUT_DIR="${BACKSTAGE_OUT_DIR:-Overlord-Server/dist-clients}"
TARGET="x86_64-pc-windows-gnu"
DLL_NAME="BackstageInjection.x64.dll"
MINHOOK_REPO="${MINHOOK_REPO:-https://github.com/TsudaKageyu/minhook.git}"
MINHOOK_REF="${MINHOOK_REF:-master}"
BACKSTAGE_FETCH_MINHOOK="${BACKSTAGE_FETCH_MINHOOK:-1}"
MINHOOK_STATIC_DIR="${MINHOOK_STATIC_DIR:-BackstageInjection/Minhook}"
mkdir -p "$OUT_DIR" 2>/dev/null || true
MINHOOK_DIR="$SRC_DIR/minhook"
stage_minhook_tree() {
local source_root="$1"
local include_root="${2:-$1}"
mkdir -p "$MINHOOK_DIR/hde" || true
cp -f "$source_root/buffer.c" "$MINHOOK_DIR/" 2>/dev/null || true
cp -f "$source_root/buffer.h" "$MINHOOK_DIR/" 2>/dev/null || true
cp -f "$source_root/hook.c" "$MINHOOK_DIR/" 2>/dev/null || true
cp -f "$source_root/trampoline.c" "$MINHOOK_DIR/" 2>/dev/null || true
cp -f "$source_root/trampoline.h" "$MINHOOK_DIR/" 2>/dev/null || true
cp -f "$source_root/hde/hde64.c" "$MINHOOK_DIR/hde/" 2>/dev/null || true
cp -f "$source_root/hde/hde64.h" "$MINHOOK_DIR/hde/" 2>/dev/null || true
cp -f "$source_root/hde/hde32.c" "$MINHOOK_DIR/hde/" 2>/dev/null || true
cp -f "$source_root/hde/hde32.h" "$MINHOOK_DIR/hde/" 2>/dev/null || true
cp -f "$source_root/hde/pstdint.h" "$MINHOOK_DIR/hde/" 2>/dev/null || true
cp -f "$source_root/hde/table64.h" "$MINHOOK_DIR/hde/" 2>/dev/null || true
cp -f "$source_root/hde/table32.h" "$MINHOOK_DIR/hde/" 2>/dev/null || true
mkdir -p "$SRC_DIR/include" || true
if [ -f "$source_root/MinHook.h" ]; then
cp -f "$source_root/MinHook.h" "$MINHOOK_DIR/MinHook.h" 2>/dev/null || true
cp -f "$source_root/MinHook.h" "$SRC_DIR/include/MinHook.h" 2>/dev/null || true
elif [ -f "$include_root/MinHook.h" ]; then
cp -f "$include_root/MinHook.h" "$MINHOOK_DIR/MinHook.h" 2>/dev/null || true
cp -f "$include_root/MinHook.h" "$SRC_DIR/include/MinHook.h" 2>/dev/null || true
fi
}
stage_minhook_from_static_dir() {
local candidate_src=""
local candidate_include=""
for dir in "$MINHOOK_STATIC_DIR" "BackstageInjection/Minhook" "BackstageInjection/minhook"; do
if [ -f "$dir/hook.c" ] && [ -f "$dir/hde/hde64.c" ]; then
candidate_src="$dir"
candidate_include="$dir"
break
fi
if [ -f "$dir/src/hook.c" ] && [ -f "$dir/src/hde/hde64.c" ]; then
candidate_src="$dir/src"
if [ -f "$dir/include/MinHook.h" ]; then
candidate_include="$dir/include"
else
candidate_include="$dir/src"
fi
break
fi
done
if [ -n "$candidate_src" ]; then
echo "Using static MinHook source from $candidate_src"
stage_minhook_tree "$candidate_src" "$candidate_include"
return 0
fi
return 1
}
if ! command -v "$CC" >/dev/null 2>&1; then
echo "ERROR: Cross compiler not found: $CC"
echo "Install mingw-w64 (x86_64-w64-mingw32-gcc) in your build image/environment."
if ! command -v cargo >/dev/null 2>&1; then
echo "ERROR: cargo not found. Install a Rust toolchain (rustup)."
exit 1
fi
fetch_minhook() {
if [ "$BACKSTAGE_FETCH_MINHOOK" != "1" ]; then
return 1
fi
if ! command -v git >/dev/null 2>&1; then
echo "WARNING: git is not available; cannot auto-fetch MinHook."
return 1
fi
echo "MinHook source not found; fetching from $MINHOOK_REPO ($MINHOOK_REF) ..."
local tmpdir
tmpdir="$(mktemp -d)"
if ! git clone --depth 1 --branch "$MINHOOK_REF" "$MINHOOK_REPO" "$tmpdir/minhook"; then
rm -rf "$tmpdir"
echo "WARNING: Failed to fetch MinHook source."
return 1
fi
stage_minhook_tree "$tmpdir/minhook/src" "$tmpdir/minhook/include"
cp -f "$tmpdir/minhook/include/MinHook.h" "$MINHOOK_DIR/MinHook.h" 2>/dev/null || true
mkdir -p "$SRC_DIR/include" || true
cp -f "$tmpdir/minhook/include/MinHook.h" "$SRC_DIR/include/MinHook.h" 2>/dev/null || true
rm -rf "$tmpdir"
[ -f "$MINHOOK_DIR/hook.c" ]
}
if [ ! -d "$MINHOOK_DIR" ]; then
if ! stage_minhook_from_static_dir && ! fetch_minhook; then
echo "WARNING: MinHook source not found at $MINHOOK_DIR"
echo "Attempting to use pre-compiled libMinHook.x64.lib ..."
echo "(This may fail with MinGW. Build with MSVC on Windows instead.)"
MINHOOK_OBJS=""
MINHOOK_LIB="$SRC_DIR/libMinHook.x64.lib"
MINHOOK_INC=""
fi
if ! rustup target list --installed 2>/dev/null | grep -qx "$TARGET"; then
echo "ERROR: target not installed: $TARGET"
echo "Install it with: rustup target add $TARGET"
exit 1
fi
if [ -d "$MINHOOK_DIR" ]; then
if [ ! -f "$MINHOOK_DIR/hook.c" ] && [ ! -f "$MINHOOK_DIR/MinHook.c" ]; then
# Some trees have the folder but not the expected source files.
stage_minhook_from_static_dir || fetch_minhook || true
fi
if ! command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then
echo "ERROR: cross compiler not found: x86_64-w64-mingw32-gcc"
echo "Install mingw-w64 (e.g. apt install gcc-mingw-w64-x86-64)."
exit 1
fi
if [ -d "$MINHOOK_DIR" ] && { [ -f "$MINHOOK_DIR/hook.c" ] || [ -f "$MINHOOK_DIR/MinHook.c" ]; }; then
echo "Building MinHook from source ..."
MINHOOK_OBJS=""
MINHOOK_LIB=""
MINHOOK_INC="-I$MINHOOK_DIR -I$MINHOOK_DIR/hde"
mkdir -p "$OUT_DIR"
if [ -f "$MINHOOK_DIR/hook.c" ] && [ ! -f "$SRC_DIR/include/MinHook.h" ]; then
mkdir -p "$SRC_DIR/include" || true
if [ -f "$MINHOOK_DIR/MinHook.h" ]; then
cp -f "$MINHOOK_DIR/MinHook.h" "$SRC_DIR/include/MinHook.h" || true
elif [ -f "$MINHOOK_DIR/include/MinHook.h" ]; then
cp -f "$MINHOOK_DIR/include/MinHook.h" "$SRC_DIR/include/MinHook.h" || true
fi
fi
echo "Building Rust BackstageInjection DLL ($TARGET) ..."
cargo build --release --target "$TARGET" --manifest-path "$CRATE_DIR/Cargo.toml"
for src in "$MINHOOK_DIR"/buffer.c "$MINHOOK_DIR"/trampoline.c \
"$MINHOOK_DIR"/hde/hde64.c "$MINHOOK_DIR"/hde/hde32.c \
"$MINHOOK_DIR"/hook.c "$MINHOOK_DIR"/MinHook.c; do
if [ -f "$src" ]; then
obj="${src%.c}.o"
"$CC" -c -O2 -DWIN64 -D_WIN64 -fno-stack-protector -fno-asynchronous-unwind-tables $MINHOOK_INC -o "$obj" "$src"
MINHOOK_OBJS="$MINHOOK_OBJS $obj"
fi
done
if [ -z "${MINHOOK_OBJS// }" ]; then
echo "WARNING: No MinHook objects were built, falling back to pre-compiled libMinHook.x64.lib"
MINHOOK_LIB="$SRC_DIR/libMinHook.x64.lib"
MINHOOK_INC=""
fi
else
MINHOOK_OBJS=""
MINHOOK_LIB="$SRC_DIR/libMinHook.x64.lib"
MINHOOK_INC=""
SRC_DLL="$CRATE_DIR/target/$TARGET/release/BackstageInjection.dll"
if [ ! -f "$SRC_DLL" ]; then
echo "ERROR: expected built DLL at $SRC_DLL"
exit 1
fi
CFLAGS="-O2 -DWIN64 -D_WIN64 -DNDEBUG -D_WINDOWS -D_USRDLL"
CFLAGS="$CFLAGS -DBackstageInjection_EXPORTS -DWIN_X64"
CFLAGS="$CFLAGS -DREFLECTIVEDLLINJECTION_VIA_LOADREMOTELIBRARYR"
CFLAGS="$CFLAGS -DREFLECTIVEDLLINJECTION_CUSTOM_DLLMAIN"
CFLAGS="$CFLAGS -fno-stack-protector"
CFLAGS="$CFLAGS -I$SRC_DIR"
if [ -n "${MINHOOK_INC:-}" ]; then
CFLAGS="$CFLAGS $MINHOOK_INC"
fi
# ReflectiveLoader is position-independent shellcode: strip .eh_frame but keep .pdata
LOADER_CFLAGS="$CFLAGS -fno-asynchronous-unwind-tables"
echo "Compiling ReflectiveLoader.c ..."
"$CC" -c $LOADER_CFLAGS -o "$SRC_DIR/ReflectiveLoader.o" "$SRC_DIR/ReflectiveLoader.c"
echo "Compiling ReflectiveDll.c ..."
"$CC" -c $CFLAGS -o "$SRC_DIR/ReflectiveDll.o" "$SRC_DIR/ReflectiveDll.c"
echo "Compiling NtApiHooks.c ..."
"$CC" -c $CFLAGS -include "$SRC_DIR/seh_compat.h" -o "$SRC_DIR/NtApiHooks.o" "$SRC_DIR/NtApiHooks.c"
echo "Linking $DLL_NAME ..."
LINK_OBJS="$SRC_DIR/ReflectiveLoader.o $SRC_DIR/ReflectiveDll.o $SRC_DIR/NtApiHooks.o"
if [ -n "${MINHOOK_OBJS:-}" ]; then
LINK_OBJS="$LINK_OBJS $MINHOOK_OBJS"
fi
LINK_LIBS="-lkernel32 -luser32 -ladvapi32 -lntdll"
if [ -n "${MINHOOK_LIB:-}" ] && [ -f "${MINHOOK_LIB}" ]; then
LINK_LIBS="$LINK_LIBS $MINHOOK_LIB"
fi
"$CC" -shared -o "$OUT_DIR/$DLL_NAME" $LINK_OBJS $LINK_LIBS \
-Wl,--entry,DllMain \
-Wl,--disable-runtime-pseudo-reloc \
-fno-stack-protector \
-s
cp -f "$SRC_DLL" "$OUT_DIR/$DLL_NAME"
echo "Built: $OUT_DIR/$DLL_NAME"
ls -la "$OUT_DIR/$DLL_NAME"
# Clean up object files
rm -f "$SRC_DIR"/*.o
if [ -d "$MINHOOK_DIR" ]; then
find "$MINHOOK_DIR" -name '*.o' -delete
fi
echo "Done."
echo "Done."