diff --git a/crates/time-mocker-core/Cargo.toml b/crates/time-mocker-core/Cargo.toml index ed97f06..e779a52 100644 --- a/crates/time-mocker-core/Cargo.toml +++ b/crates/time-mocker-core/Cargo.toml @@ -19,5 +19,3 @@ windows-sys = { workspace = true, features = [ "Win32_System_Time", "Win32_Security", ] } - -[target.'cfg(windows)'.dependencies] diff --git a/crates/time-mocker-core/src/lib.rs b/crates/time-mocker-core/src/lib.rs index 883aaeb..4b6cfe3 100644 --- a/crates/time-mocker-core/src/lib.rs +++ b/crates/time-mocker-core/src/lib.rs @@ -15,12 +15,19 @@ //! then `Local\`, so a controller's elevation state determines the namespace //! used and the hook just probes both. -#![cfg(windows)] - +// `mmf` wraps Win32-only APIs (named file mappings), so it is gated on +// `windows` at the module level. `ticks` and `types` hold pure arithmetic — +// no Win32 dependency at all beyond the FILETIME/SYSTEMTIME conversions +// inside `ticks`, which are individually `#[cfg(windows)]` — so both stay +// buildable and testable on any host (e.g. `cargo test -p time-mocker-core` +// on Linux CI/dev boxes actually exercises the tick-arithmetic tests instead +// of compiling an empty crate). +#[cfg(windows)] pub mod mmf; pub mod ticks; pub mod types; +#[cfg(windows)] pub use mmf::{CreateOutcome, SharedDeltaReader, SharedDeltaWriter}; pub use types::MockTimeInfo; diff --git a/crates/time-mocker-core/src/mmf.rs b/crates/time-mocker-core/src/mmf.rs index 9e4170e..d395c44 100644 --- a/crates/time-mocker-core/src/mmf.rs +++ b/crates/time-mocker-core/src/mmf.rs @@ -24,7 +24,10 @@ use windows_sys::Win32::System::Memory::{ use crate::types::MockTimeInfo; fn wide(s: &str) -> Vec { - OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect() + OsStr::new(s) + .encode_wide() + .chain(std::iter::once(0)) + .collect() } /// Shared bookkeeping for an open mapping. Owns the handle + view and frees @@ -96,6 +99,12 @@ impl SharedDeltaWriter { let handle = unsafe { CreateFileMappingW( INVALID_HANDLE_VALUE, + // NOTE: NULL security descriptor -> default DACL (creator + + // SYSTEM/Administrators). Fine for same-user debug targets; + // a target running as a different user/service identity than + // the controller will fail to open this mapping and silently + // get no hooks installed. Accepted for now: this is a local + // debugging tool, not a service-hardening one. ptr::null(), PAGE_READWRITE, 0, @@ -104,7 +113,9 @@ impl SharedDeltaWriter { ) }; if handle.is_null() { - return Err(io::Error::from_raw_os_error(unsafe { GetLastError() } as i32)); + return Err(io::Error::from_raw_os_error( + unsafe { GetLastError() } as i32 + )); } // Capture ERROR_ALREADY_EXISTS *before* any other syscall that may overwrite it. let outcome = if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { @@ -130,7 +141,9 @@ impl SharedDeltaReader { let wname = wide(name); let handle = unsafe { OpenFileMappingW(FILE_MAP_READ, 0, wname.as_ptr()) }; if handle.is_null() { - return Err(io::Error::from_raw_os_error(unsafe { GetLastError() } as i32)); + return Err(io::Error::from_raw_os_error( + unsafe { GetLastError() } as i32 + )); } let view = unsafe { map_view(handle, FILE_MAP_READ)? }; Ok(Self(MappingHandle { handle, view })) diff --git a/crates/time-mocker-core/src/ticks.rs b/crates/time-mocker-core/src/ticks.rs index b853817..ed571b4 100644 --- a/crates/time-mocker-core/src/ticks.rs +++ b/crates/time-mocker-core/src/ticks.rs @@ -2,15 +2,50 @@ //! //! FILETIME = 100-ns units since 1601-01-01 00:00:00 UTC. This crate uses //! FILETIME ticks throughout to avoid extra arithmetic on the hot path. +//! +//! `apply_delta` and the `*_VALID_TICKS` bounds are plain `i64` arithmetic +//! with no Win32 dependency, so they build and run on any host. The +//! FILETIME/SYSTEMTIME conversions below them call into `windows_sys` and are +//! `#[cfg(windows)]`. +#[cfg(windows)] use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME}; +#[cfg(windows)] use windows_sys::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToFileTime}; +/// Lowest FILETIME tick value `FileTimeToSystemTime` accepts. FILETIME is +/// defined as an unsigned 64-bit tick count from the 1601-01-01 epoch, so a +/// negative `i64` (sign bit set) is never a valid FILETIME. +pub const MIN_VALID_TICKS: i64 = 0; + +/// Highest FILETIME tick value `FileTimeToSystemTime` accepts: 30827-12-31 +/// 23:59:59.9999999 UTC, the last instant representable in a `SYSTEMTIME` +/// (`wYear` is a `u16`, and Win32 defines this as the ceiling). Any FILETIME +/// beyond this fails conversion. +pub const MAX_VALID_TICKS: i64 = 0x7FFF_35F4_F06C_58F0; + +/// Add `delta` to `real_ticks` and clamp to the range `FileTimeToSystemTime` +/// can convert. A saturating add alone stops at `i64::MIN`/`i64::MAX`, but +/// those are far outside the valid FILETIME range: the delta comes from +/// shared memory written by a separate, possibly stale controller process, +/// so it must not be trusted to keep the result in range. Clamping here means +/// every caller downstream gets a value that round-trips through +/// `ticks_to_systemtime` instead of failing conversion with an untouched +/// output buffer. +#[inline] +pub fn apply_delta(real_ticks: i64, delta: i64) -> i64 { + real_ticks + .saturating_add(delta) + .clamp(MIN_VALID_TICKS, MAX_VALID_TICKS) +} + +#[cfg(windows)] #[inline] pub fn filetime_to_i64(ft: FILETIME) -> i64 { ((ft.dwHighDateTime as i64) << 32) | (ft.dwLowDateTime as i64 & 0xFFFF_FFFF) } +#[cfg(windows)] #[inline] pub fn i64_to_filetime(ticks: i64) -> FILETIME { FILETIME { @@ -20,6 +55,7 @@ pub fn i64_to_filetime(ticks: i64) -> FILETIME { } /// Convert FILETIME ticks to SYSTEMTIME (UTC). Returns None on Win32 failure. +#[cfg(windows)] pub fn ticks_to_systemtime(ticks: i64) -> Option { let ft = i64_to_filetime(ticks); let mut st: SYSTEMTIME = unsafe { std::mem::zeroed() }; @@ -32,6 +68,7 @@ pub fn ticks_to_systemtime(ticks: i64) -> Option { } /// Convert SYSTEMTIME (UTC) to FILETIME ticks. Returns None on Win32 failure. +#[cfg(windows)] pub fn systemtime_to_ticks(st: &SYSTEMTIME) -> Option { let mut ft: FILETIME = unsafe { std::mem::zeroed() }; let ok = unsafe { SystemTimeToFileTime(st, &mut ft) }; @@ -46,10 +83,63 @@ pub fn systemtime_to_ticks(st: &SYSTEMTIME) -> Option { mod tests { use super::*; + #[test] + fn apply_delta_zero_is_identity() { + assert_eq!(apply_delta(1_000, 0), 1_000); + } + + #[test] + fn apply_delta_positive_offset() { + assert_eq!(apply_delta(1_000, 500), 1_500); + } + + #[test] + fn apply_delta_negative_offset() { + assert_eq!(apply_delta(1_000, -500), 500); + } + + #[test] + fn apply_delta_saturates_instead_of_overflowing() { + // i64::MAX + a positive delta would overflow a plain `+`; saturating_add + // (and then the clamp below) must not panic or wrap. + assert_eq!(apply_delta(i64::MAX, i64::MAX), MAX_VALID_TICKS); + assert_eq!(apply_delta(i64::MIN, i64::MIN), MIN_VALID_TICKS); + } + + #[test] + fn apply_delta_clamps_below_epoch_to_min_valid() { + // A large negative delta applied to an early real time would produce a + // negative tick count — not representable as FILETIME (unsigned). + assert_eq!(apply_delta(100, -1_000), MIN_VALID_TICKS); + } + + #[test] + fn apply_delta_clamps_above_ceiling_to_max_valid() { + assert_eq!(apply_delta(MAX_VALID_TICKS, 1), MAX_VALID_TICKS); + assert_eq!( + apply_delta(MAX_VALID_TICKS - 10, 1_000_000), + MAX_VALID_TICKS + ); + } + + #[test] + fn apply_delta_respects_exact_clamp_boundaries() { + // One tick inside either boundary must pass through unchanged. + assert_eq!(apply_delta(MIN_VALID_TICKS + 1, 0), MIN_VALID_TICKS + 1); + assert_eq!(apply_delta(MAX_VALID_TICKS - 1, 0), MAX_VALID_TICKS - 1); + assert_eq!(apply_delta(MIN_VALID_TICKS, 0), MIN_VALID_TICKS); + assert_eq!(apply_delta(MAX_VALID_TICKS, 0), MAX_VALID_TICKS); + } + + #[cfg(windows)] #[test] fn filetime_i64_roundtrip() { - // Covers: zero, small, Unix epoch (1970 in FILETIME ticks), a recent time, - // and the i64 boundary (used as a saturating-add sentinel by the hooks). + // Covers: zero, small, Unix epoch (1970 in FILETIME ticks), a recent + // time, and the i64 boundary. `filetime_to_i64`/`i64_to_filetime` are + // plain bit-packing and round-trip the full i64 range (including + // negative/out-of-FILETIME-range values) even though those values are + // never handed to Win32 conversion APIs — `apply_delta` above is what + // keeps callers in the valid range before that happens. let cases = [ 0_i64, 1, @@ -66,6 +156,7 @@ mod tests { } } + #[cfg(windows)] #[test] fn systemtime_roundtrip_utc() { // 2020-06-15 12:34:56 UTC @@ -88,4 +179,11 @@ mod tests { assert_eq!(back.wMinute, st.wMinute); assert_eq!(back.wSecond, st.wSecond); } + + #[cfg(windows)] + #[test] + fn ticks_to_systemtime_rejects_out_of_range_values() { + assert!(ticks_to_systemtime(MAX_VALID_TICKS + 1).is_none()); + assert!(ticks_to_systemtime(MIN_VALID_TICKS - 1).is_none()); + } } diff --git a/crates/time-mocker-hook/Cargo.toml b/crates/time-mocker-hook/Cargo.toml index 857f8ba..bc2e2c4 100644 --- a/crates/time-mocker-hook/Cargo.toml +++ b/crates/time-mocker-hook/Cargo.toml @@ -15,6 +15,7 @@ path = "src/lib.rs" [dependencies] time-mocker-core = { path = "../time-mocker-core" } +# Pre-release range: no stable retour release ships static-detour inline x64 hooking yet. retour = { version = "0.4.0-alpha.4", features = ["static-detour"] } once_cell = "1.20" windows-sys = { workspace = true, features = [ diff --git a/crates/time-mocker-hook/src/entrypoint.rs b/crates/time-mocker-hook/src/entrypoint.rs index d5ed01f..89cd182 100644 --- a/crates/time-mocker-hook/src/entrypoint.rs +++ b/crates/time-mocker-hook/src/entrypoint.rs @@ -9,15 +9,27 @@ //! //! We also call `DisableThreadLibraryCalls(hinst)` to suppress all future //! `DLL_THREAD_ATTACH`/`DETACH` notifications for this DLL — we don't need them. +//! +//! Load-once, never-unload invariant: `DllMain` pins this module (see +//! `pin_module`) and there is no `DLL_PROCESS_DETACH` handler. Once the +//! detours are enabled, kernel32/ntdll contain jumps into this DLL's +//! trampoline pages; unhooking them would require draining every thread that +//! might currently be inside a trampoline, which cannot be done safely from +//! `DLL_PROCESS_DETACH` under the loader lock. Pinning means the loader can +//! never unmap us out from under those jumps, even if something (a future +//! `eject` path, or a stray `FreeLibrary`) tries. use std::ffi::{c_void, OsStr}; use std::os::windows::ffi::OsStrExt; use std::ptr; -use time_mocker_core::{local_mmf_name_for_pid, mmf_name_for_pid, SharedDeltaReader}; +use time_mocker_core::{local_mmf_name_for_pid, mmf_name_for_pid}; use windows_sys::Win32::Foundation::{CloseHandle, BOOL, HMODULE, TRUE}; use windows_sys::Win32::System::Diagnostics::Debug::OutputDebugStringW; -use windows_sys::Win32::System::LibraryLoader::DisableThreadLibraryCalls; +use windows_sys::Win32::System::LibraryLoader::{ + DisableThreadLibraryCalls, GetModuleHandleExW, GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + GET_MODULE_HANDLE_EX_FLAG_PIN, +}; use windows_sys::Win32::System::SystemServices::DLL_PROCESS_ATTACH; use windows_sys::Win32::System::Threading::{CreateThread, GetCurrentProcessId}; @@ -25,13 +37,10 @@ use crate::hooks::{self, InstallReport}; #[no_mangle] #[allow(non_snake_case, clippy::missing_safety_doc)] -pub unsafe extern "system" fn DllMain( - hinst: HMODULE, - reason: u32, - _reserved: *mut c_void, -) -> BOOL { +pub unsafe extern "system" fn DllMain(hinst: HMODULE, reason: u32, _reserved: *mut c_void) -> BOOL { if reason == DLL_PROCESS_ATTACH { DisableThreadLibraryCalls(hinst); + pin_module(); let thread_handle = CreateThread( ptr::null(), 0, @@ -50,6 +59,22 @@ pub unsafe extern "system" fn DllMain( TRUE } +/// Bump this module's loader reference count so it can never be unmapped +/// (see the module-doc invariant above). `GET_MODULE_HANDLE_EX_FLAG_PIN` +/// combined with `..._FROM_ADDRESS` resolves the target module from an +/// address inside it — `DllMain`'s own address — so this needs no file path +/// or module name. Best-effort: if it ever fails, we still proceed with +/// install rather than abort the injection, since a pin failure here is far +/// less likely than the process simply never unloading us in practice. +unsafe fn pin_module() { + let mut pinned: HMODULE = ptr::null_mut(); + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_PIN | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + DllMain as *const () as usize as *const u16, + &mut pinned, + ); +} + unsafe extern "system" fn bootstrap_thread(_param: *mut c_void) -> u32 { bootstrap(); 0 @@ -60,25 +85,15 @@ fn bootstrap() { let global = mmf_name_for_pid(pid); let local = local_mmf_name_for_pid(pid); - // 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_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 - // process; the bootstrap thread exits immediately after. - let report = hooks::install(shared); + // Install the hooks unconditionally — do not gate installation on the + // delta MMF already existing. The normal workflow order is "inject, then + // open the delta channel", so at this point in a fresh injection neither + // name may exist yet; `hooks::install` connects to whichever one shows up + // lazily (with retry) on first use inside the hot path. Gating install on + // a successful open here would leave the hooks permanently uninstalled + // for the rest of the process's life whenever the controller sets the + // delta after injection completes. + let report = hooks::install(global, local); log_install_report(&report); } diff --git a/crates/time-mocker-hook/src/hooks.rs b/crates/time-mocker-hook/src/hooks.rs index 2bd8abf..0fdaa7a 100644 --- a/crates/time-mocker-hook/src/hooks.rs +++ b/crates/time-mocker-hook/src/hooks.rs @@ -10,13 +10,33 @@ use once_cell::sync::OnceCell; use retour::static_detour; -use time_mocker_core::ticks::{filetime_to_i64, i64_to_filetime}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::RwLock; +use time_mocker_core::ticks::{apply_delta, filetime_to_i64, i64_to_filetime}; use time_mocker_core::SharedDeltaReader; use windows_sys::Win32::Foundation::{FILETIME, SYSTEMTIME}; use windows_sys::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress}; use windows_sys::Win32::System::Time::{FileTimeToSystemTime, SystemTimeToTzSpecificLocalTime}; -static SHARED: OnceCell = OnceCell::new(); +/// Re-attempt opening the delta MMF once every this many `delta()` calls while +/// disconnected. The normal workflow order is inject-then-open-channel, so the +/// first `bootstrap()` probe commonly runs before the controller has created +/// the mapping; without a retry the process would carry no-op hooks for its +/// entire lifetime. A call-count backoff (instead of a wall-clock timer and +/// its own thread) keeps the hot path — the already-connected case — down to +/// one relaxed atomic read, and costs nothing extra to implement. +const RETRY_EVERY_N_CALLS: u32 = 4096; + +/// Names to probe plus the lazily-connected reader. `misses_since_retry` +/// drives the backoff in `try_reconnect`. +struct DeltaSource { + global_name: String, + local_name: String, + reader: RwLock>, + misses_since_retry: AtomicU32, +} + +static SOURCE: OnceCell = OnceCell::new(); static_detour! { static GetSystemTimeDetour: unsafe extern "system" fn(*mut SYSTEMTIME); @@ -37,52 +57,170 @@ pub struct InstallReport { pub failed: Vec<(&'static str, String)>, } -/// Resolve+initialize+enable a single detour. Pushes to `installed` on success, -/// to `failed` (with reason) on any failure — never aborts the wider install. -macro_rules! install_hook { +/// Resolve+initialize a single detour (phase 1 of `install`). Pushes to +/// `failed` (with reason) and yields `false` on any failure instead of +/// aborting the wider install; yields `true` when the detour is ready to be +/// enabled in phase 2. +macro_rules! init_hook { ($report:ident, $detour:ident, $module:literal, $proc:literal, $fn_ty:ty, $callback:ident) => {{ // Safety: `resolve` is unsafe because it dereferences whatever // GetProcAddress returns as `$fn_ty`; correctness rests on the // module+proc literal pair matching `$fn_ty`'s extern signature. match unsafe { resolve::<$fn_ty>($module, $proc) } { - None => $report - .failed - .push(($proc, "GetProcAddress: not found".into())), + None => { + $report + .failed + .push(($proc, "GetProcAddress: not found".into())); + false + } Some(target) => match unsafe { $detour.initialize(target, $callback) } { - Err(e) => $report.failed.push(($proc, format!("initialize: {e}"))), - Ok(d) => match unsafe { d.enable() } { - Err(e) => $report.failed.push(($proc, format!("enable: {e}"))), - Ok(()) => $report.installed.push($proc), - }, + Err(e) => { + $report.failed.push(($proc, format!("initialize: {e}"))); + false + } + Ok(_) => true, }, } }}; } -pub fn install(shared: SharedDeltaReader) -> InstallReport { - let _ = SHARED.set(shared); +/// Enable a detour that was successfully initialized in phase 1 (phase 2 of +/// `install`). `unmet_dependency`, when `Some`, means a *different* detour +/// this hook body calls through (e.g. `hook_get_system_time` calls +/// `GetSystemTimeAsFileTimeDetour`) failed to initialize — enabling this hook +/// anyway would arm a hook body that panics the first time it runs, so it is +/// recorded as failed instead. +fn try_enable( + report: &mut InstallReport, + detour: &'static retour::StaticDetour, + name: &'static str, + initialized: bool, + unmet_dependency: Option<&'static str>, +) { + if !initialized { + return; // already recorded as failed by init_hook! + } + if let Some(dep) = unmet_dependency { + report + .failed + .push((name, format!("dependency {dep} did not initialize"))); + return; + } + match unsafe { detour.enable() } { + Err(e) => report.failed.push((name, format!("enable: {e}"))), + Ok(()) => report.installed.push(name), + } +} + +/// Resolve, initialize, and enable every hook, then wire up the (lazily +/// connected) delta source. `global_name`/`local_name` are the two MMF names +/// the bootstrap thread probes — connecting happens on first use inside +/// `delta()`, not here, so hooking still activates even if the controller +/// creates the mapping after injection has already completed. +pub fn install(global_name: String, local_name: String) -> InstallReport { + let _ = SOURCE.set(DeltaSource { + global_name, + local_name, + reader: RwLock::new(None), + misses_since_retry: AtomicU32::new(0), + }); let mut report = InstallReport::default(); - install_hook!( - report, GetSystemTimeDetour, "kernel32.dll", "GetSystemTime", - FnSystemTime, hook_get_system_time + + // Phase 1: resolve + initialize every detour before enabling any of them. + // `hook_get_system_time` and `hook_get_local_time` call through + // `GetSystemTimeAsFileTimeDetour`'s trampoline, and the trampoline only + // exists once that detour has been initialized — `static_detour!`'s + // generated `call()` panics with `NotInitialized` otherwise, and with + // `panic = "abort"` in the release profile that panic kills the target + // process. Initializing every detour before enabling any of them removes + // the racy window (GetSystemTime enabled while another thread is still + // initializing the FileTime detour); phase 2 below additionally refuses + // to enable a hook whose dependency never initialized at all, which + // removes the permanent version of the same failure. + let system_time_ok = init_hook!( + report, + GetSystemTimeDetour, + "kernel32.dll", + "GetSystemTime", + FnSystemTime, + hook_get_system_time ); - install_hook!( - report, GetLocalTimeDetour, "kernel32.dll", "GetLocalTime", - FnSystemTime, hook_get_local_time + let local_time_ok = init_hook!( + report, + GetLocalTimeDetour, + "kernel32.dll", + "GetLocalTime", + FnSystemTime, + hook_get_local_time ); - install_hook!( - report, GetSystemTimeAsFileTimeDetour, "kernel32.dll", "GetSystemTimeAsFileTime", - FnFileTime, hook_get_system_time_as_filetime + let filetime_ok = init_hook!( + report, + GetSystemTimeAsFileTimeDetour, + "kernel32.dll", + "GetSystemTimeAsFileTime", + FnFileTime, + hook_get_system_time_as_filetime ); - install_hook!( - report, GetSystemTimePreciseAsFileTimeDetour, "kernel32.dll", "GetSystemTimePreciseAsFileTime", - FnFileTime, hook_get_system_time_precise_as_filetime + let precise_filetime_ok = init_hook!( + report, + GetSystemTimePreciseAsFileTimeDetour, + "kernel32.dll", + "GetSystemTimePreciseAsFileTime", + FnFileTime, + hook_get_system_time_precise_as_filetime ); - install_hook!( - report, NtQuerySystemTimeDetour, "ntdll.dll", "NtQuerySystemTime", - FnNtQuerySystemTime, hook_nt_query_system_time + let nt_query_ok = init_hook!( + report, + NtQuerySystemTimeDetour, + "ntdll.dll", + "NtQuerySystemTime", + FnNtQuerySystemTime, + hook_nt_query_system_time ); + + // Phase 2: enable. Retour patches the live prologue with no thread + // suspension or i-cache flush of its own (verified in the vendored + // source); a thread executing that exact prologue mid-`enable()` is a + // known, accepted race inherent to this hooking approach, not something + // this crate mitigates. + let unmet_filetime = (!filetime_ok).then_some("GetSystemTimeAsFileTime"); + try_enable( + &mut report, + &GetSystemTimeDetour, + "GetSystemTime", + system_time_ok, + unmet_filetime, + ); + try_enable( + &mut report, + &GetLocalTimeDetour, + "GetLocalTime", + local_time_ok, + unmet_filetime, + ); + try_enable( + &mut report, + &GetSystemTimeAsFileTimeDetour, + "GetSystemTimeAsFileTime", + filetime_ok, + None, + ); + try_enable( + &mut report, + &GetSystemTimePreciseAsFileTimeDetour, + "GetSystemTimePreciseAsFileTime", + precise_filetime_ok, + None, + ); + try_enable( + &mut report, + &NtQuerySystemTimeDetour, + "NtQuerySystemTime", + nt_query_ok, + None, + ); + report } @@ -99,16 +237,49 @@ unsafe fn resolve(module: &str, proc: &str) -> Option { #[inline] fn delta() -> i64 { - SHARED.get().map(|s| s.read_delta()).unwrap_or(0) + let Some(source) = SOURCE.get() else { + return 0; + }; + if let Ok(guard) = source.reader.read() { + if let Some(reader) = guard.as_ref() { + return reader.read_delta(); + } + } + try_reconnect(source) +} + +/// Not yet connected to the delta channel. Re-probe both MMF names once every +/// `RETRY_EVERY_N_CALLS` misses (see its doc comment) instead of on every +/// call. A race between threads landing on the same retry slot is harmless — +/// `SharedDeltaReader::open` is idempotent and cheap to call twice. +fn try_reconnect(source: &DeltaSource) -> i64 { + let misses = source.misses_since_retry.fetch_add(1, Ordering::Relaxed); + if !misses.is_multiple_of(RETRY_EVERY_N_CALLS) { + return 0; + } + let opened = SharedDeltaReader::open(&source.global_name) + .or_else(|_| SharedDeltaReader::open(&source.local_name)); + match opened { + Ok(reader) => { + let value = reader.read_delta(); + if let Ok(mut guard) = source.reader.write() { + *guard = Some(reader); + } + value + } + Err(_) => 0, + } } /// Invoke the supplied trampoline to get the real FILETIME, then add the -/// shared delta. Saturating arithmetic — no panic-abort even at i64 bounds. +/// shared delta, clamped to the range `FileTimeToSystemTime` can convert (see +/// `time_mocker_core::ticks::apply_delta`) so callers never see a conversion +/// failure caused by an out-of-range delta from shared memory. #[inline] fn fake_filetime(call_real: impl FnOnce(*mut FILETIME)) -> FILETIME { let mut ft: FILETIME = unsafe { std::mem::zeroed() }; call_real(&mut ft); - let ticks = filetime_to_i64(ft).saturating_add(delta()); + let ticks = apply_delta(filetime_to_i64(ft), delta()); i64_to_filetime(ticks) } @@ -117,7 +288,15 @@ fn hook_get_system_time(out: *mut SYSTEMTIME) { return; } let ft = fake_filetime(|p| unsafe { GetSystemTimeAsFileTimeDetour.call(p) }); - unsafe { FileTimeToSystemTime(&ft, out) }; + if unsafe { FileTimeToSystemTime(&ft, out) } == 0 { + // `apply_delta` keeps `ft` in the valid FILETIME range, so this should + // not happen — but the delta is attacker/bug-controlled input from a + // separate process, so never leave `out` uninitialized on the + // off-chance it does. `GetSystemTimeDetour` is exactly the detour + // currently executing this hook body, so its trampoline is guaranteed + // initialized here. + unsafe { GetSystemTimeDetour.call(out) }; + } } fn hook_get_local_time(out: *mut SYSTEMTIME) { @@ -132,6 +311,10 @@ fn hook_get_local_time(out: *mut SYSTEMTIME) { let ft_utc = fake_filetime(|p| unsafe { GetSystemTimeAsFileTimeDetour.call(p) }); let mut st_utc: SYSTEMTIME = unsafe { std::mem::zeroed() }; if unsafe { FileTimeToSystemTime(&ft_utc, &mut st_utc) } == 0 { + // See `hook_get_system_time` — fall back to the real local time rather + // than return with `out` uninitialized. `GetLocalTimeDetour` is this + // hook's own detour, so its trampoline is guaranteed initialized here. + unsafe { GetLocalTimeDetour.call(out) }; return; } if unsafe { SystemTimeToTzSpecificLocalTime(std::ptr::null(), &st_utc, out) } == 0 { @@ -171,6 +354,6 @@ fn hook_nt_query_system_time(out: *mut i64) -> i32 { // delta+0 with a bogus STATUS_SUCCESS if the real API ever failed. return status; } - unsafe { *out = real.saturating_add(delta()) }; + unsafe { *out = apply_delta(real, delta()) }; 0 } diff --git a/target/.gitignore b/java-target/.gitignore similarity index 100% rename from target/.gitignore rename to java-target/.gitignore diff --git a/target/LICENSE b/java-target/LICENSE similarity index 100% rename from target/LICENSE rename to java-target/LICENSE diff --git a/target/README.md b/java-target/README.md similarity index 100% rename from target/README.md rename to java-target/README.md diff --git a/target/build.gradle.kts b/java-target/build.gradle.kts similarity index 100% rename from target/build.gradle.kts rename to java-target/build.gradle.kts diff --git a/target/gradle/wrapper/gradle-wrapper.jar b/java-target/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from target/gradle/wrapper/gradle-wrapper.jar rename to java-target/gradle/wrapper/gradle-wrapper.jar diff --git a/target/gradle/wrapper/gradle-wrapper.properties b/java-target/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from target/gradle/wrapper/gradle-wrapper.properties rename to java-target/gradle/wrapper/gradle-wrapper.properties diff --git a/target/gradlew b/java-target/gradlew similarity index 100% rename from target/gradlew rename to java-target/gradlew diff --git a/target/gradlew.bat b/java-target/gradlew.bat similarity index 100% rename from target/gradlew.bat rename to java-target/gradlew.bat diff --git a/target/settings.gradle.kts b/java-target/settings.gradle.kts similarity index 100% rename from target/settings.gradle.kts rename to java-target/settings.gradle.kts diff --git a/target/src/main/java/com/miti99/Main.java b/java-target/src/main/java/com/miti99/Main.java similarity index 100% rename from target/src/main/java/com/miti99/Main.java rename to java-target/src/main/java/com/miti99/Main.java