mirror of
https://github.com/tiennm99/time-mocker.git
synced 2026-10-11 03:13:47 +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
+384
-69
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
|
||||
None => {
|
||||
$report
|
||||
.failed
|
||||
.push(($proc, "GetProcAddress: not found".into())),
|
||||
.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
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user