mirror of
https://github.com/tiennm99/time-mocker.git
synced 2026-10-19 08:15:32 +00:00
fix(hook): harden detour install, unload, and delta reconnection
Initialize every detour before enabling any, so a hook body that calls another detour's trampoline can never hit an uninitialized detour and abort the process. Pin the module on load so unloading can no longer leave kernel32/ntdll patched to jump into freed memory. On a failed FILETIME conversion, fall through to the real time instead of returning an uninitialized SYSTEMTIME. Reconnect the shared delta mapping lazily so setting a delta after injection takes effect instead of being disabled forever. Add host-agnostic tick-conversion tests.
This commit is contained in:
1 parent
a2e34060e0
commit
f4bd35ae5b
17 files changed
+385
-70
No files matched your search
@@ -19,5 +19,3 @@ windows-sys = { workspace = true, features = [
|
||||
"Win32_System_Time",
|
||||
"Win32_Security",
|
||||
] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -24,7 +24,10 @@ use windows_sys::Win32::System::Memory::{
|
||||
use crate::types::MockTimeInfo;
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
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 }))
|
||||
|
||||
@@ -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<SYSTEMTIME> {
|
||||
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<SYSTEMTIME> {
|
||||
}
|
||||
|
||||
/// Convert SYSTEMTIME (UTC) to FILETIME ticks. Returns None on Win32 failure.
|
||||
#[cfg(windows)]
|
||||
pub fn systemtime_to_ticks(st: &SYSTEMTIME) -> Option<i64> {
|
||||
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<i64> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<SharedDeltaReader> = 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<Option<SharedDeltaReader>>,
|
||||
misses_since_retry: AtomicU32,
|
||||
}
|
||||
|
||||
static SOURCE: OnceCell<DeltaSource> = 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<T: retour::Function>(
|
||||
report: &mut InstallReport,
|
||||
detour: &'static retour::StaticDetour<T>,
|
||||
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<F: Copy>(module: &str, proc: &str) -> Option<F> {
|
||||
|
||||
#[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
|
||||
}
|
||||
Reference in new issue
Block a user