From f5fa02bfbf4ce07344b3e44d977bc473e23e1386 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 20 May 2026 21:46:31 +0700 Subject: [PATCH] fix(mmf): fall back to Local\ namespace when controller is not elevated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a `Global\TimeMocker_` mapping requires `SeCreateGlobalPrivilege`, which an unelevated token (e.g. debug `cargo run` where the UAC manifest is intentionally skipped) does not have, so `CreateFileMappingW` returned NULL with `ERROR_ACCESS_DENIED` and the auto-inject scanner spammed `create MMF Global\TimeMocker_` for every enumerated PID. The UI now tries `Global\` first and falls back to `Local\` on access denied, with a one-shot log warning so the auto-inject loop doesn't flood. The hook DLL probes both names on attach so the IPC pairs up symmetrically. Release builds keep their elevated `Global\` cross-session reach; debug builds work against same-session targets without admin. Also adds a dedicated `time-mocker-test-target` crate — a small console binary that prints all 5 hooked time APIs every second with its own PID banner, so dev verification can inject into a controlled harness instead of arbitrary running processes. --- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 37 ++++- crates/time-mocker-core/src/lib.rs | 28 +++- crates/time-mocker-hook/src/entrypoint.rs | 25 +-- crates/time-mocker-test-target/Cargo.toml | 22 +++ crates/time-mocker-test-target/src/main.rs | 151 ++++++++++++++++++ .../time-mocker-ui/src/injection_manager.rs | 48 +++++- 8 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 crates/time-mocker-test-target/Cargo.toml create mode 100644 crates/time-mocker-test-target/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 74309b9..7bafa5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2447,6 +2447,13 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "time-mocker-test-target" +version = "0.1.0" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "time-mocker-ui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1901a86..79f5daf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "crates/time-mocker-core", "crates/time-mocker-hook", + "crates/time-mocker-test-target", "crates/time-mocker-ui", ] diff --git a/README.md b/README.md index 74cd3f6..d608a08 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ A Windows tool that injects fake time into running processes by hooking Win32 ti ``` time-mocker-rs/ ├── crates/ -│ ├── time-mocker-core/ — shared types (MockTimeInfo) + named MMF helper + tick conversions -│ ├── time-mocker-hook/ — cdylib injected into target processes; hooks 5 time APIs via retour -│ └── time-mocker-ui/ — egui controller binary; injects via dll-syringe, writes delta per PID +│ ├── time-mocker-core/ — shared types (MockTimeInfo) + named MMF helper + tick conversions +│ ├── time-mocker-hook/ — cdylib injected into target processes; hooks 5 time APIs via retour +│ ├── time-mocker-test-target/ — console binary that prints all 5 hooked APIs in a loop (dedicated dev target) +│ └── time-mocker-ui/ — egui controller binary; injects via dll-syringe, writes delta per PID ``` ## Hooked APIs @@ -29,11 +30,18 @@ time-mocker-rs/ Named Memory-Mapped File per injected process: ``` -Name: TimeMocker_ +Name: Global\TimeMocker_ (preferred — requires elevation) + Local\TimeMocker_ (fallback — same-session, unelevated dev) Size: 8 bytes [0..7] DeltaTicks (i64 — 100-ns units, added to the real FILETIME) ``` +The UI tries `Global\` first and falls back to `Local\` on `ERROR_ACCESS_DENIED` +(i.e. when the controller is not elevated and the token lacks +`SeCreateGlobalPrivilege`). The hook DLL probes both names. Release builds +embed a UAC manifest, so they always get `Global\`; debug builds run +unelevated and use `Local\` for same-session targets. + The hook reads the delta on every time API call and returns `real_filetime + delta`. The controller writes the delta whenever the user picks a new fake time. > **Tick epoch difference vs the C# version:** The C# version stores a delta against `DateTime.UtcNow.Ticks` (epoch 0001-01-01 UTC). The Rust version stores a delta in raw FILETIME units (epoch 1601-01-01 UTC). The two IPC contracts are not interoperable — the Rust UI and Rust hook DLL only talk to each other. @@ -54,7 +62,26 @@ cargo build --release - Windows 10/11 x64 - Rust nightly (pinned via `rust-toolchain.toml`) -- Must run as Administrator (UAC manifest embedded) +- Release build: must run as Administrator (UAC manifest embedded; needed for `Global\` MMF and cross-session injection) +- Debug build (`cargo run`): runs unelevated; falls back to `Local\` namespace — same-session targets only + +## Dev workflow: test target + +Inject into a dedicated harness instead of arbitrary running processes: + +```powershell +# Terminal 1 — start the target, note the PID it prints +cargo run -p time-mocker-test-target + +# Terminal 2 — start the UI; type that PID into "Inject by PID" +cargo build --workspace ; cargo run -p time-mocker-ui +``` + +The target prints all 5 hooked APIs (`GetSystemTime`, `GetLocalTime`, +`GetSystemTimeAsFileTime`, `GetSystemTimePreciseAsFileTime`, +`NtQuerySystemTime`) every second. When the hook is loaded and you set a +fake time in the UI, all five rows shift by the same delta — that's your +proof the hook is live. ## License diff --git a/crates/time-mocker-core/src/lib.rs b/crates/time-mocker-core/src/lib.rs index dc3c636..883aaeb 100644 --- a/crates/time-mocker-core/src/lib.rs +++ b/crates/time-mocker-core/src/lib.rs @@ -1,12 +1,19 @@ //! Shared types and named MMF helper for time-mocker. //! //! IPC contract: an 8-byte memory-mapped file named `Global\TimeMocker_` -//! holds an i64 `DeltaTicks` — the offset (in 100-ns FILETIME units) added to -//! the real system FILETIME by the injected hook. +//! (or `Local\TimeMocker_` as a session-scoped fallback) holds an i64 +//! `DeltaTicks` — the offset (in 100-ns FILETIME units) added to the real +//! system FILETIME by the injected hook. //! //! The `Global\` namespace lets the admin controller in session 1 reach -//! processes in session 0 (services) and other sessions. Single-user same- -//! session injection works too — `Global\` is a superset of `Local\`. +//! processes in session 0 (services) and other sessions, but creating an +//! object there requires `SeCreateGlobalPrivilege` — granted only to elevated +//! tokens. Debug / non-elevated controllers fall back to `Local\` so dev +//! workflows can still hook same-session targets. +//! +//! Both endpoints (writer = UI, reader = hook DLL) try `Global\` first and +//! then `Local\`, so a controller's elevation state determines the namespace +//! used and the hook just probes both. #![cfg(windows)] @@ -17,9 +24,18 @@ pub mod types; pub use mmf::{CreateOutcome, SharedDeltaReader, SharedDeltaWriter}; pub use types::MockTimeInfo; -pub const MMF_PREFIX: &str = "Global\\TimeMocker_"; +pub const MMF_PREFIX_GLOBAL: &str = "Global\\TimeMocker_"; +pub const MMF_PREFIX_LOCAL: &str = "Local\\TimeMocker_"; + +/// Back-compat alias: the primary (elevated) namespace. +pub const MMF_PREFIX: &str = MMF_PREFIX_GLOBAL; #[inline] pub fn mmf_name_for_pid(pid: u32) -> String { - format!("{MMF_PREFIX}{pid}") + format!("{MMF_PREFIX_GLOBAL}{pid}") +} + +#[inline] +pub fn local_mmf_name_for_pid(pid: u32) -> String { + format!("{MMF_PREFIX_LOCAL}{pid}") } diff --git a/crates/time-mocker-hook/src/entrypoint.rs b/crates/time-mocker-hook/src/entrypoint.rs index dadfc40..d5ed01f 100644 --- a/crates/time-mocker-hook/src/entrypoint.rs +++ b/crates/time-mocker-hook/src/entrypoint.rs @@ -14,7 +14,7 @@ use std::ffi::{c_void, OsStr}; use std::os::windows::ffi::OsStrExt; use std::ptr; -use time_mocker_core::{mmf_name_for_pid, SharedDeltaReader}; +use time_mocker_core::{local_mmf_name_for_pid, mmf_name_for_pid, SharedDeltaReader}; use windows_sys::Win32::Foundation::{CloseHandle, BOOL, HMODULE, TRUE}; use windows_sys::Win32::System::Diagnostics::Debug::OutputDebugStringW; use windows_sys::Win32::System::LibraryLoader::DisableThreadLibraryCalls; @@ -57,16 +57,23 @@ unsafe extern "system" fn bootstrap_thread(_param: *mut c_void) -> u32 { fn bootstrap() { let pid = unsafe { GetCurrentProcessId() }; - let name = mmf_name_for_pid(pid); + let global = mmf_name_for_pid(pid); + let local = local_mmf_name_for_pid(pid); - let shared = match SharedDeltaReader::open(&name) { + // Probe Global\ first (matches the elevated controller's preferred namespace); + // fall back to Local\ for the unelevated dev/debug controller path. Either + // name owns an identical 8-byte payload — whichever exists wins. + let shared = match SharedDeltaReader::open(&global) { Ok(s) => s, - Err(e) => { - // Controller didn't set up the MMF (cross-session DACL, stale inject, - // or never-injected debug case). Surface via OutputDebugStringW for DbgView. - dbg_log(&format!("time-mocker: open MMF '{name}' failed: {e}")); - return; - } + Err(e_global) => match SharedDeltaReader::open(&local) { + Ok(s) => s, + Err(e_local) => { + dbg_log(&format!( + "time-mocker: open MMF '{global}' ({e_global}) and '{local}' ({e_local}) both failed" + )); + return; + } + }, }; // Best-effort install. Detours stay armed for the lifetime of the host diff --git a/crates/time-mocker-test-target/Cargo.toml b/crates/time-mocker-test-target/Cargo.toml new file mode 100644 index 0000000..39ebdce --- /dev/null +++ b/crates/time-mocker-test-target/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "time-mocker-test-target" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Dedicated injection target — prints all 5 hooked time APIs in a loop so you can verify the hook DLL without touching arbitrary running processes" + +[[bin]] +name = "time_mocker_test_target" +path = "src/main.rs" + +[dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_System_LibraryLoader", + "Win32_System_SystemInformation", + "Win32_System_Threading", + "Win32_System_Time", +] } diff --git a/crates/time-mocker-test-target/src/main.rs b/crates/time-mocker-test-target/src/main.rs new file mode 100644 index 0000000..ce18f83 --- /dev/null +++ b/crates/time-mocker-test-target/src/main.rs @@ -0,0 +1,151 @@ +//! Dedicated injection target for time-mocker. +//! +//! Prints its own PID at startup, then loops printing the five hooked time +//! APIs side-by-side every second. Lets you verify the hook DLL is working +//! against an isolated process you control, instead of injecting into +//! arbitrary running programs. +//! +//! Usage: +//! 1. Build the workspace: `cargo build --workspace` +//! 2. Run this binary in one terminal — it prints its PID on line 1. +//! 3. In the TimeMocker UI, type that PID into Inject by PID and set a fake time. +//! 4. Watch the times printed by this binary shift by the delta you set. + +#![cfg(windows)] + +use std::ffi::CString; +use std::mem::MaybeUninit; +use std::thread; +use std::time::Duration; + +use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME}; +use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; +use windows_sys::Win32::System::SystemInformation::{GetLocalTime, GetSystemTime}; +use windows_sys::Win32::System::Threading::GetCurrentProcessId; + +type FnGetSystemTimeAsFileTime = unsafe extern "system" fn(*mut FILETIME); +type FnNtQuerySystemTime = unsafe extern "system" fn(*mut i64) -> i32; + +fn main() { + let pid = unsafe { GetCurrentProcessId() }; + + println!("================================================================"); + println!(" time-mocker test target"); + println!(" PID = {pid}"); + println!(" copy this PID into the TimeMocker UI to inject the hook here."); + println!(" press Ctrl+C to stop."); + println!("================================================================"); + println!(); + + // Resolve the two APIs that windows-sys' default features don't always + // surface (GetSystemTimeAsFileTime + NtQuerySystemTime). Matching the + // hook DLL's resolution strategy (`hooks.rs::resolve`) keeps the call + // sites symmetric — what the hook hooks, this binary calls. + let get_system_time_as_file_time = unsafe { + resolve::("kernel32.dll", "GetSystemTimeAsFileTime") + }; + let get_system_time_precise_as_file_time = unsafe { + resolve::("kernel32.dll", "GetSystemTimePreciseAsFileTime") + }; + let nt_query_system_time = + unsafe { resolve::("ntdll.dll", "NtQuerySystemTime") }; + + if get_system_time_as_file_time.is_none() { + eprintln!("warn: GetSystemTimeAsFileTime not found"); + } + if get_system_time_precise_as_file_time.is_none() { + eprintln!("warn: GetSystemTimePreciseAsFileTime not found (pre-Win8?)"); + } + if nt_query_system_time.is_none() { + eprintln!("warn: NtQuerySystemTime not found"); + } + + let mut tick: u64 = 0; + loop { + tick += 1; + println!("--- sample #{tick} ---"); + + // GetSystemTime — UTC SYSTEMTIME (kernel32). + let mut st_utc: SYSTEMTIME = unsafe { MaybeUninit::zeroed().assume_init() }; + unsafe { GetSystemTime(&mut st_utc) }; + println!(" GetSystemTime UTC {}", fmt_systemtime(&st_utc)); + + // GetLocalTime — local SYSTEMTIME (kernel32). + let mut st_local: SYSTEMTIME = unsafe { MaybeUninit::zeroed().assume_init() }; + unsafe { GetLocalTime(&mut st_local) }; + println!(" GetLocalTime local {}", fmt_systemtime(&st_local)); + + // GetSystemTimeAsFileTime — FILETIME 100-ns ticks since 1601-01-01 UTC. + if let Some(f) = get_system_time_as_file_time { + let mut ft: FILETIME = unsafe { MaybeUninit::zeroed().assume_init() }; + unsafe { f(&mut ft) }; + println!( + " GetSystemTimeAsFileTime {}", + fmt_filetime_as_systemtime(&ft) + ); + } + + // GetSystemTimePreciseAsFileTime — same units, sub-µs precision. + if let Some(f) = get_system_time_precise_as_file_time { + let mut ft: FILETIME = unsafe { MaybeUninit::zeroed().assume_init() }; + unsafe { f(&mut ft) }; + println!( + " GetSystemTimePreciseAsFT {}", + fmt_filetime_as_systemtime(&ft) + ); + } + + // NtQuerySystemTime — raw i64 (also 100-ns ticks since 1601-01-01 UTC). + if let Some(f) = nt_query_system_time { + let mut ticks: i64 = 0; + let status = unsafe { f(&mut ticks) }; + if status == 0 { + let ft = FILETIME { + dwLowDateTime: (ticks as u64 & 0xFFFF_FFFF) as u32, + dwHighDateTime: ((ticks as u64) >> 32) as u32, + }; + println!( + " NtQuerySystemTime {}", + fmt_filetime_as_systemtime(&ft) + ); + } else { + println!(" NtQuerySystemTime NTSTATUS={status:#x}"); + } + } + + println!(); + thread::sleep(Duration::from_secs(1)); + } +} + +unsafe fn resolve(module: &str, proc_name: &str) -> Option { + let module_c = CString::new(module).ok()?; + let proc_c = CString::new(proc_name).ok()?; + let h = GetModuleHandleA(module_c.as_ptr() as *const u8); + if h.is_null() { + return None; + } + let addr = GetProcAddress(h, proc_c.as_ptr() as *const u8)?; + Some(std::mem::transmute_copy::<_, F>(&addr)) +} + +fn fmt_systemtime(st: &SYSTEMTIME) -> String { + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}", + st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds + ) +} + +fn fmt_filetime_as_systemtime(ft: &FILETIME) -> String { + // Render the FILETIME via FileTimeToSystemTime so the columns line up + // with the SYSTEMTIME-returning APIs above. + use windows_sys::Win32::System::Time::FileTimeToSystemTime; + let mut st: SYSTEMTIME = unsafe { MaybeUninit::zeroed().assume_init() }; + let ok = unsafe { FileTimeToSystemTime(ft, &mut st) }; + if ok == 0 { + let raw: u64 = ((ft.dwHighDateTime as u64) << 32) | ft.dwLowDateTime as u64; + format!("(raw {raw}; FileTimeToSystemTime failed)") + } else { + format!("UTC {}", fmt_systemtime(&st)) + } +} diff --git a/crates/time-mocker-ui/src/injection_manager.rs b/crates/time-mocker-ui/src/injection_manager.rs index 5bafe30..44bc382 100644 --- a/crates/time-mocker-ui/src/injection_manager.rs +++ b/crates/time-mocker-ui/src/injection_manager.rs @@ -13,10 +13,17 @@ use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; use dll_syringe::process::OwnedProcess; use dll_syringe::Syringe; -use time_mocker_core::{mmf_name_for_pid, CreateOutcome, SharedDeltaWriter}; +use time_mocker_core::{ + local_mmf_name_for_pid, mmf_name_for_pid, CreateOutcome, SharedDeltaWriter, +}; use crate::win32_process_info::{is_native_x64, query_full_image_name, IMAGE_FILE_MACHINE_AMD64}; +/// Windows `ERROR_ACCESS_DENIED`. `CreateFileMappingW` on a `Global\` name returns +/// this when the caller's token lacks `SeCreateGlobalPrivilege` (i.e. the UI is +/// not running elevated). We use it as the trigger to fall back to `Local\`. +const ERROR_ACCESS_DENIED: i32 = 5; + /// Critical Windows processes that must never be injected — they would /// destabilize the OS, fail with access denied, or trigger AV alerts. const SYSTEM_PROCESS_EXCLUDE: &[&str] = &[ @@ -59,6 +66,9 @@ pub struct InjectionManager { injected: HashMap, hook_dll_path: PathBuf, pub log: VecDeque, + /// One-shot guard so the "running unelevated → Local\ fallback" warning is + /// logged once per session, not on every auto-inject scan tick. + local_fallback_warned: bool, } impl InjectionManager { @@ -85,6 +95,7 @@ impl InjectionManager { injected: HashMap::new(), hook_dll_path, log: VecDeque::new(), + local_fallback_warned: false, }) } @@ -96,6 +107,36 @@ impl InjectionManager { self.injected.contains_key(&pid) } + /// Try `Global\TimeMocker_` first, then fall back to + /// `Local\TimeMocker_` on ERROR_ACCESS_DENIED (the controller is not + /// elevated). Cross-session reach is lost in the fallback path, but the + /// hook DLL probes both namespaces so same-session targets still work. + fn create_mmf_with_fallback( + &mut self, + pid: u32, + ) -> Result<(String, SharedDeltaWriter, CreateOutcome)> { + let global = mmf_name_for_pid(pid); + match SharedDeltaWriter::create(&global) { + Ok((delta, outcome)) => Ok((global, delta, outcome)), + Err(e) if e.raw_os_error() == Some(ERROR_ACCESS_DENIED) => { + if !self.local_fallback_warned { + self.local_fallback_warned = true; + self.log_push( + "warn: controller not elevated — falling back to Local\\ namespace; \ + cross-session targets will not be reachable. Run as Administrator \ + (release build) for full reach.".into(), + ); + } + let local = local_mmf_name_for_pid(pid); + let (delta, outcome) = SharedDeltaWriter::create(&local).with_context(|| { + format!("create MMF {local} (after {global} returned access denied)") + })?; + Ok((local, delta, outcome)) + } + Err(e) => Err(anyhow::Error::from(e).context(format!("create MMF {global}"))), + } + } + #[allow(dead_code)] pub fn iter(&self) -> impl Iterator { self.injected.values() @@ -152,9 +193,7 @@ impl InjectionManager { )); } - let mmf_name = mmf_name_for_pid(pid); - let (delta, outcome) = SharedDeltaWriter::create(&mmf_name) - .with_context(|| format!("create MMF {mmf_name}"))?; + let (mmf_name, delta, outcome) = self.create_mmf_with_fallback(pid)?; if outcome == CreateOutcome::Existed { self.log_push(format!( "warn: MMF {mmf_name} pre-existed (stale prior session?)" @@ -361,6 +400,7 @@ mod tests { injected: HashMap::new(), hook_dll_path: std::path::PathBuf::from("dummy.dll"), log: VecDeque::new(), + local_fallback_warned: false, }; // Push LOG_CAP + 100 entries and verify only LOG_CAP remain