fix(mmf): fall back to Local\ namespace when controller is not elevated

Creating a `Global\TimeMocker_<pid>` 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_<pid>` 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.
This commit is contained in:
2026-05-20 21:46:31 +07:00
parent 31db80ade0
commit f5fa02bfbf
8 changed files with 295 additions and 24 deletions
Generated
+7
View File
@@ -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"
+1
View File
@@ -3,6 +3,7 @@ resolver = "2"
members = [
"crates/time-mocker-core",
"crates/time-mocker-hook",
"crates/time-mocker-test-target",
"crates/time-mocker-ui",
]
+32 -5
View File
@@ -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_<PID>
Name: Global\TimeMocker_<PID> (preferred — requires elevation)
Local\TimeMocker_<PID> (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
+22 -6
View File
@@ -1,12 +1,19 @@
//! Shared types and named MMF helper for time-mocker.
//!
//! IPC contract: an 8-byte memory-mapped file named `Global\TimeMocker_<pid>`
//! holds an i64 `DeltaTicks` — the offset (in 100-ns FILETIME units) added to
//! the real system FILETIME by the injected hook.
//! (or `Local\TimeMocker_<pid>` 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}")
}
+16 -9
View File
@@ -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
+22
View File
@@ -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",
] }
+151
View File
@@ -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::<FnGetSystemTimeAsFileTime>("kernel32.dll", "GetSystemTimeAsFileTime")
};
let get_system_time_precise_as_file_time = unsafe {
resolve::<FnGetSystemTimeAsFileTime>("kernel32.dll", "GetSystemTimePreciseAsFileTime")
};
let nt_query_system_time =
unsafe { resolve::<FnNtQuerySystemTime>("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<F: Copy>(module: &str, proc_name: &str) -> Option<F> {
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))
}
}
+44 -4
View File
@@ -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<u32, InjectedProcess>,
hook_dll_path: PathBuf,
pub log: VecDeque<String>,
/// 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_<pid>` first, then fall back to
/// `Local\TimeMocker_<pid>` 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<Item = &InjectedProcess> {
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