From 9b5720eb59bea6df749b916bc8dd415ed982f5de Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 23 Sep 2026 16:31:07 +0200 Subject: [PATCH 1/2] fix(hook): observe every network connection at the socket driver The audit warns source.network_at_start when an application opens a network connection, because it may then take the time from a server. The observer sat on ws2_32's connect export, and two of the three ways to connect never call it: WSAConnect, and ConnectEx, which WSAConnectByName, WSAConnectByList, WinHTTP and WinINet use and which the .NET, Node.js and Go runtimes connect through. Measured under a real session, 8 of 13 runtime connection paths were counted as zero, with no caution and a clean verdict. Every Winsock connection attempt reaches the socket driver through ntdll!NtDeviceIoControlFile with one of two control codes, 0x12007 for connect and WSAConnect and 0x120C7 for ConnectEx. Measured on both bitnesses across eleven paths, each attempt sends exactly one of them, and a datagram sent without a connection sends neither. The detour reads the control code and nothing else, counts a connection and forwards the call untouched. Its cost stayed inside the run-to-run noise against 200 000 socket polls. The channel keeps its name, connect, which is a contract key. ChannelDef::export now names the symbol the hook detours, so the name on the wire and the export can differ for this one channel. The old detour on ws2_32 is gone, since keeping it would count every connect twice, and the channel leaves the late-install path because ntdll is present from the start. wait.network_timeouts_scaled now fires when the process opened a connection rather than when ws2_32 is loaded. The observer is installed in every process now, so its install bit no longer says anything about the network stack, and a counted connection is the narrower and truer signal. The frozen count of too_many_arguments allows in tests/shape.rs goes from four to five. The new detour mirrors NtDeviceIoControlFile's ten parameters, the same class as the three process-creation detours already counted there, and on 32-bit the callee pops them, so the list cannot be made shorter. Guards, each checked by reverting the fix: a CI test that connects through connect, WSAConnect and ConnectEx under a session and expects exactly three (the old observer counted one, dropping the ConnectEx code gave two), and unit tests for the control codes, ChannelDef::export and the warning rule. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +- README.md | 2 +- crates/cli/src/report.rs | 2 +- crates/cli/tests/hygiene.rs | 6 +- crates/cli/tests/network.rs | 45 ++- crates/cli/tests/network_observer.rs | 288 ++++++++++++++++++ crates/cli/tests/shape.rs | 16 +- crates/ctl/src/lib.rs | 54 +++- crates/hook/src/lib.rs | 144 ++++++--- crates/mech/src/lib.rs | 44 +-- .../Localization/Strings.en.json | 2 +- .../Localization/Strings.pl.json | 2 +- .../BinaryImportsTests.cs | 4 +- 13 files changed, 520 insertions(+), 101 deletions(-) create mode 100644 crates/cli/tests/network_observer.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ac575..2c4e9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,11 +35,19 @@ Notable changes to Chrono Mock, newest first. The format follows fix below: a network library that measures its own timeout from the tick count, WinHTTP for one, now counts that timeout in session time, while the network wait underneath stays real. At x60 a 60 second timeout runs out after one real second, so a server slower than that makes the request - fail. A session with timers sped up now says so whenever the application has the network stack - loaded, right under the line saying that waits on system objects stay on the real clock. + fail. A session with timers sped up now says so whenever the application opened a network + connection, right under the line saying that waits on system objects stay on the real clock. ### Fixed +- **The network caution missed most applications that go online.** The audit is meant to say when + an application opened a network connection, because it may then take the time from a server, + which no local substitution reaches. It watched one Windows function for that, and two of the + three ways to connect never call it - among them the one WinHTTP and WinINet use, and the ones + .NET, Node.js and Go connect through. Those applications got no caution and a clean result. The + audit now counts every connection attempt at the point all of them pass through, whichever + function made it, on both 32 and 64 bit. A datagram sent without a connection is still not + counted, because it is not one. - **The .NET timing caution said `Environment.TickCount` follows the session speed.** It does only when timers are sped up too (`--scale-duration`, or "Also speed up timers and countdowns inside the application" in the window). With that off, it runs at real speed, so a tester reading the diff --git a/README.md b/README.md index a62baac..5be2913 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ column says which is which._ | .NET (Framework and modern) | experimental | measured on x64, x86 | Time calls go through Win32 exports and are covered, including the session time zone. Stopwatch stays on the real high-resolution counter unless you opt in with Scale QPC (`--scale-qpc`), which accelerates it too | | Java (JVM) | experimental | measured on x64, x86 | Wall clock and elapsed time are covered. The session time zone is not reached - a known gap. nanoTime stays on the real high-resolution counter unless you opt in with Scale QPC (`--scale-qpc`) | | Python (CPython, incl. PyInstaller) | experimental | measured by hand on x64, not by the suite | Wall clock (time.time, datetime) and the session time zone (time.localtime) are covered. perf_counter, and monotonic on Python 3.13+, are on the high-resolution counter - real by default, accelerated when you opt in with Scale QPC (`--scale-qpc`) | -| Applications reading time from the network | out of scope by definition | measured on x64, x86 | The audit detects it - connect observed, warned | +| Applications reading time from the network | out of scope by definition | measured on x64, x86 | The audit detects it - every connection attempt is observed and warned about, whichever function made it (Winsock, WinHTTP, WinINet, and the .NET, Node.js, Go, Java and Python runtimes). A datagram sent without a connection is not a connection and is not counted | | Embedded web engine inside a native app (WebView2, Qt WebEngine) | experimental | measured by hand on a WebView2 host and a Qt WebEngine host (x64), not by the suite | The native hook covers the application and the pages inside it are reached over the engine's debugging port, opened for the session through two environment variables the application inherits. The pages read the session clock at the session rate and follow a speed change and a jump. The engine's helper processes and the renderer's native reads stay on the real clock, so the verdict is PARTIAL and says why. The pages keep the machine's time zone. An elevated application is out of reach - the engine ignores the variables | | Electron / Chromium | experimental (Chromium mode) | measured by hand on an Electron app (x64), not by the suite | A separate mechanism, not injection: the app is launched with a debug port and a clean isolated profile, and its own JS time APIs are put on the session clock over the DevTools protocol - reaching the sandboxed renderer and its Web Workers, where the timer often lives. The session zone follows the host zone (the instant is faked, not the local-time getters) | | UWP / MSIX (Store apps) | not supported | declared (not exercised) | Packaging and launch model | diff --git a/crates/cli/src/report.rs b/crates/cli/src/report.rs index 63f7c91..8eb2746 100644 --- a/crates/cli/src/report.rs +++ b/crates/cli/src/report.rs @@ -229,7 +229,7 @@ pub(crate) fn describe_warning(key: &str) -> String { // Sits next to the object-wait line on purpose: that one says an I/O timeout is not shortened, // and for a library that measures its own timeout from the tick count this one says otherwise. "wait.network_timeouts_scaled" => { - "this application has the network stack loaded - a network library that measures its own \ + "this application opened a network connection - a network library that measures its own \ timeout from the tick count (WinHTTP, for one) follows the session speed, so a server slower \ than that timeout divided by the speed makes a request fail, even though the waits \ underneath stay real" diff --git a/crates/cli/tests/hygiene.rs b/crates/cli/tests/hygiene.rs index abf2b72..e3c0cb3 100644 --- a/crates/cli/tests/hygiene.rs +++ b/crates/cli/tests/hygiene.rs @@ -1371,8 +1371,10 @@ fn every_optional_module_channel_can_be_installed_late() { let channels = optional_module_channels(&ctl_src); // Canary, with a LITERAL rather than a count derived from the same list it checks: six channels - // live in optional modules today (timeGetTime, timeSetEvent, SetTimer, both message waits, - // connect). Fewer means the scan stopped matching the table's shape and went blind. + // live in optional modules today (timeGetTime, timeSetEvent, SetTimer, both message waits, the + // socket wait). Fewer means the scan stopped matching the table's shape and went blind. The + // connection observer left the list for ntdll on 2026-09-23, when the socket wait already made + // the count seven. assert!( channels.len() >= 6, "found only {} channels in optional modules - the CHANNELS table changed shape and this \ diff --git a/crates/cli/tests/network.rs b/crates/cli/tests/network.rs index b9d8037..8502deb 100644 --- a/crates/cli/tests/network.rs +++ b/crates/cli/tests/network.rs @@ -40,12 +40,14 @@ //! no equivalent of a Python audit hook here, so the static half stands alone. //! * **Data can leave a machine without a socket** - a file written into a synced folder, a report //! pasted into an issue. Nothing here looks at that. -//! * **The hooked `connect` is somebody else's traffic, not ours.** `chrono-hook` resolves -//! `ws2_32.dll` and intercepts `connect` so the audit can report that the target application -//! asked the network for something, which is a suspected server time source. Counting a call is -//! the opposite of making one, and the register says so where it grants that. The binary layer -//! says the half this one cannot: `chrono_hook.dll` LINKS no networking DLL at all, `ws2_32` -//! included, because it looks that module up only when the target has already loaded it. +//! * **The observed connections are somebody else's traffic, not ours.** `chrono-hook` counts the +//! target's connection attempts where they reach the socket driver, in ntdll, so the audit can +//! report that the target application asked the network for something, which is a suspected +//! server time source. It also resolves `ws2_32.dll` to observe the target's socket waits. +//! Counting a call is the opposite of making one, and the register says so where it grants that. +//! The binary layer says the half this one cannot: `chrono_hook.dll` LINKS no networking DLL at +//! all, `ws2_32` included, because it looks that module up only when the target has already +//! loaded it. //! //! So this is not a proof of silence. It is a lock on the surface: nobody adds a way out by //! accident, and adding one on purpose means editing a register here and writing down why. @@ -174,20 +176,41 @@ const ALLOWED: &[(&str, &str, &str)] = &[ session through the built binary, and reads the tick rate it wrote to a scratch file. \ Neither reaches past this machine", ), + ( + "crates/cli/tests/network_observer.rs", + "spawn", + "runs this test binary's own ignored probe twice, once alone as the control and once under a \ + session through the built binary. Neither reaches past this machine", + ), + ( + "crates/cli/tests/network_observer.rs", + "socket", + "binds a loopback listener on a port the system picks, and the probe connects to that port \ + alone, which is the one thing the connection observer can be seen to count. Nothing is \ + sent, and the listener is closed before the test ends", + ), + ( + "crates/cli/tests/network_observer.rs", + "winsock", + "declares the Winsock functions the probe connects through - WSAConnect and ConnectEx, the \ + two ways to connect that never call the connect export - against the same loopback \ + listener", + ), ( "crates/hook/src/lib.rs", "winsock", - "the injected library resolves ws2_32 to INTERCEPT the target's own connect and count it. \ - Counting somebody else's call is the opposite of making one, and the audit reports it as \ - a suspected server time source", + "the injected library resolves ws2_32 to OBSERVE the target's own socket waits, and counts \ + the target's own connection attempts in ntdll. Counting somebody else's call is the \ + opposite of making one, and the audit reports a connection as a suspected server time \ + source", ), ( "gui/ChronoMock.Protocol.Tests/BinaryImportsTests.cs", "winsock", "the binary layer of this same guard, which names the module in order to REFUSE it. It reads \ the import table of every release binary and asserts that ws2_32 is linked by chrono.exe \ - and by nothing else - least of all by chrono_hook.dll, which hooks connect without linking \ - it. Naming a module in a register is the opposite of opening one", + and by nothing else - least of all by chrono_hook.dll, which observes connections without \ + linking it. Naming a module in a register is the opposite of opening one", ), ( "gui/ChronoMock.Protocol/CoreClient.cs", diff --git a/crates/cli/tests/network_observer.rs b/crates/cli/tests/network_observer.rs new file mode 100644 index 0000000..767aca8 --- /dev/null +++ b/crates/cli/tests/network_observer.rs @@ -0,0 +1,288 @@ +//! Every way an application connects to a network has to reach the connection observer. +//! +//! The audit warns `source.network_at_start` when an application opens a connection, because it may +//! then take the time from a server, which no local substitution reaches. Until 2026-09-23 the +//! observer sat on ws2_32's `connect`, and two of the three ways to connect never call it: +//! `WSAConnect`, and `ConnectEx`, which WinHTTP, WinINet and the .NET, Node.js and Go runtimes connect +//! through. Those applications got no caution and a clean result. The observer now counts at the +//! socket driver, in ntdll, where all three meet. +//! +//! The target is this test binary itself: `probe_connects_three_ways` connects to a loopback listener +//! the test opens, once through each of the three, and does its work only when the variables below +//! are set. The count has to be exactly three - fewer means a path is missed, more means one of them +//! is counted twice. + +use std::ffi::c_void; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::process::Command; +use std::ptr::{null, null_mut}; + +/// Where the probe writes how many of its three connections succeeded. Unset, the probe returns at +/// once, so running the ignored tests by hand does nothing. +const PROBE_OUT: &str = "CHRONO_NETWORK_OBSERVER_PROBE_OUT"; + +/// The loopback port the test listens on. +const PROBE_PORT: &str = "CHRONO_NETWORK_OBSERVER_PROBE_PORT"; + +/// The probe's own name, which is how the binary is asked to run it and nothing else. +const PROBE: &str = "probe_connects_three_ways"; + +const AF_INET: i32 = 2; +const SOCK_STREAM: i32 = 1; +const IPPROTO_TCP: i32 = 6; +const WSA_FLAG_OVERLAPPED: u32 = 1; +const SIO_GET_EXTENSION_FUNCTION_POINTER: u32 = 0xC800_0006; +const WSA_IO_PENDING: i32 = 997; +const INVALID_SOCKET: usize = usize::MAX; + +#[repr(C)] +struct Guid { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], +} + +/// `WSAID_CONNECTEX` from mswsock.h. +const WSAID_CONNECTEX: Guid = + Guid { data1: 0x25a2_07b9, data2: 0xddf3, data3: 0x4660, data4: [0x8e, 0xe9, 0x76, 0xe5, 0x8c, 0x74, 0x06, 0x3e] }; + +#[repr(C)] +struct SockAddrIn { + family: u16, + port: u16, + addr: [u8; 4], + zero: [u8; 8], +} + +#[repr(C)] +struct Overlapped { + internal: usize, + internal_high: usize, + offset: u32, + offset_high: u32, + event: *mut c_void, +} + +type ConnectExFn = + unsafe extern "system" fn(usize, *const SockAddrIn, i32, *const c_void, u32, *mut u32, *mut Overlapped) -> i32; + +#[cfg_attr(target_arch = "x86", link(name = "ws2_32", kind = "raw-dylib", import_name_type = "undecorated"))] +#[cfg_attr(not(target_arch = "x86"), link(name = "ws2_32", kind = "raw-dylib"))] +unsafe extern "system" { + fn WSAStartup(version: u16, data: *mut u8) -> i32; + fn WSASocketW(af: i32, kind: i32, protocol: i32, info: *const c_void, group: u32, flags: u32) -> usize; + fn WSAConnect( + socket: usize, + name: *const SockAddrIn, + name_len: i32, + caller: *const c_void, + callee: *mut c_void, + sqos: *const c_void, + gqos: *const c_void, + ) -> i32; + fn WSAIoctl( + socket: usize, + code: u32, + input: *const c_void, + input_len: u32, + output: *mut c_void, + output_len: u32, + returned: *mut u32, + overlapped: *mut c_void, + completion: *const c_void, + ) -> i32; + fn WSAGetOverlappedResult(socket: usize, overlapped: *const Overlapped, bytes: *mut u32, wait: i32, flags: *mut u32) -> i32; + fn WSAGetLastError() -> i32; + fn bind(socket: usize, name: *const SockAddrIn, name_len: i32) -> i32; + fn closesocket(socket: usize) -> i32; +} + +#[cfg_attr(target_arch = "x86", link(name = "kernel32", kind = "raw-dylib", import_name_type = "undecorated"))] +#[cfg_attr(not(target_arch = "x86"), link(name = "kernel32", kind = "raw-dylib"))] +unsafe extern "system" { + fn CreateEventW(attributes: *const c_void, manual: i32, initial: i32, name: *const u16) -> *mut c_void; + fn WaitForSingleObject(handle: *mut c_void, ms: u32) -> u32; + fn CloseHandle(handle: *mut c_void) -> i32; +} + +fn loopback(port: u16) -> SockAddrIn { + SockAddrIn { family: AF_INET as u16, port: port.to_be(), addr: [127, 0, 0, 1], zero: [0; 8] } +} + +/// A connection through `WSAConnect`, which never calls the `connect` export. +fn through_wsa_connect(port: u16) -> bool { + let target = loopback(port); + // SAFETY: a fresh socket, a live address of the documented size, and every optional argument null. + unsafe { + let socket = WSASocketW(AF_INET, SOCK_STREAM, IPPROTO_TCP, null(), 0, 0); + if socket == INVALID_SOCKET { + return false; + } + let result = WSAConnect(socket, &target, 16, null(), null_mut(), null(), null()); + closesocket(socket); + result == 0 + } +} + +/// A connection through `ConnectEx`, the extension function WinHTTP and the .NET, Node.js and Go +/// runtimes connect through. It is not an export: its address comes from `WSAIoctl`, and the socket +/// has to be bound and overlapped first. +fn through_connect_ex(port: u16) -> bool { + let target = loopback(port); + let any = SockAddrIn { family: AF_INET as u16, port: 0, addr: [0; 4], zero: [0; 8] }; + // SAFETY: every pointer is to a live local of the documented layout, the event outlives the wait, + // and the extension function is called with the signature mswsock.h gives it. + unsafe { + let socket = WSASocketW(AF_INET, SOCK_STREAM, IPPROTO_TCP, null(), 0, WSA_FLAG_OVERLAPPED); + if socket == INVALID_SOCKET { + return false; + } + let mut function: usize = 0; + let mut returned: u32 = 0; + let resolved = bind(socket, &any, 16) == 0 + && WSAIoctl( + socket, + SIO_GET_EXTENSION_FUNCTION_POINTER, + &WSAID_CONNECTEX as *const Guid as *const c_void, + size_of::() as u32, + &mut function as *mut usize as *mut c_void, + size_of::() as u32, + &mut returned, + null_mut(), + null(), + ) == 0 + && function != 0; + if !resolved { + closesocket(socket); + return false; + } + let connect_ex: ConnectExFn = std::mem::transmute::(function); + let event = CreateEventW(null(), 1, 0, null()); + let mut overlapped = Overlapped { internal: 0, internal_high: 0, offset: 0, offset_high: 0, event }; + let started = connect_ex(socket, &target, 16, null(), 0, null_mut(), &mut overlapped) != 0 + || WSAGetLastError() == WSA_IO_PENDING; + if started { + WaitForSingleObject(event, 5000); + } + let (mut bytes, mut flags) = (0u32, 0u32); + let connected = started && WSAGetOverlappedResult(socket, &overlapped, &mut bytes, 0, &mut flags) != 0; + CloseHandle(event); + closesocket(socket); + connected + } +} + +/// Connects to the test's listener three ways and writes how many connected. +#[test] +#[ignore = "the target of `every_way_to_connect_reaches_the_connection_observer`, not a test on its own"] +fn probe_connects_three_ways() { + let Some(out) = std::env::var_os(PROBE_OUT) else { + return; + }; + let port: u16 = std::env::var(PROBE_PORT).ok().and_then(|p| p.parse().ok()).expect("the test passes its port"); + let mut data = [0u8; 512]; + // SAFETY: a buffer larger than WSADATA on either bitness. The standard library starts Winsock the + // same way, and the count is per process, so a second start is harmless. + unsafe { WSAStartup(0x0202, data.as_mut_ptr()) }; + let connected = [ + // The standard library connects through the `connect` export. + TcpStream::connect(("127.0.0.1", port)).is_ok(), + through_wsa_connect(port), + through_connect_ex(port), + ]; + std::fs::write(&out, connected.iter().filter(|c| **c).count().to_string()).expect("the probe writes its result"); + // Alive past the session's opening guard window (ADR-4). The session here scales nothing, so a + // plain sleep is real. + std::thread::sleep(std::time::Duration::from_millis(600)); +} + +/// The injected library, which `cargo test` does not build (`dry_run.rs` has the whole story). +fn injected_library() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_chrono")) + .parent() + .expect("the binary under test lives in a directory") + .join("chrono_hook.dll") +} + +/// The highest `connect` count and whether `source.network_at_start` was raised, over every coverage +/// event on the session's stdout. The highest, because the session reports each process twice, once +/// after the opening guard window and once at the end. +fn observed(stdout: &str) -> (Option, bool) { + let mut calls = None; + let mut warned = false; + for line in stdout.lines() { + let Ok(event) = serde_json::from_str::(line) else { + continue; + }; + if event["type"] != "coverage" { + continue; + } + let entries = event["observed"].as_array().into_iter().flatten(); + for entry in entries.filter(|e| e["channel"] == "connect") { + calls = calls.max(entry["calls"].as_u64()); + } + warned |= event["warning_keys"] + .as_array() + .is_some_and(|keys| keys.iter().any(|k| k == "source.network_at_start")); + } + (calls, warned) +} + +/// Three connections, one through each API, are counted as three, and the audit raises the network +/// caution for them. +#[test] +fn every_way_to_connect_reaches_the_connection_observer() { + let library = injected_library(); + assert!( + library.is_file(), + "this probe drives a real session and needs {}, which `cargo test` does not build. \ + Run `cargo build --workspace` first - CI and tools/gates.ps1 both do that.", + library.display() + ); + // Nothing accepts: the system completes a loopback connection into the backlog on its own, and the + // listener is closed when the test ends. + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("a loopback listener"); + let port = listener.local_addr().expect("the listener has an address").port().to_string(); + let me = std::env::current_exe().expect("the test binary knows its own path"); + let dir = std::env::temp_dir().join(format!("chrono-network-observer-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a scratch directory"); + let probe_args = ["--ignored", "--exact", PROBE, "--test-threads", "1"]; + + // The control first: the probe with no session must make all three connections. Without it a + // count of three under the session could not be told from three paths that simply failed. + let control = dir.join("control.txt"); + let run = Command::new(&me) + .args(probe_args) + .env(PROBE_OUT, &control) + .env(PROBE_PORT, &port) + .output() + .expect("the probe must run without a session"); + assert!(run.status.success(), "the probe failed without a session: {}", String::from_utf8_lossy(&run.stdout)); + let made = std::fs::read_to_string(&control).unwrap_or_default(); + assert_eq!(made, "3", "without a session the probe made {made:?} of its three connections, so this test cannot tell"); + + let session = dir.join("session.txt"); + let out = Command::new(env!("CARGO_BIN_EXE_chrono")) + .args(["run", &me.display().to_string(), "--json", "--args", &probe_args.join(" ")]) + .env(PROBE_OUT, &session) + .env(PROBE_PORT, &port) + .output() + .expect("the tool must run"); + let stdout = String::from_utf8_lossy(&out.stdout); + let made = std::fs::read_to_string(&session).unwrap_or_default(); + let (calls, warned) = observed(&stdout); + assert_eq!(made, "3", "under the session the probe made {made:?} of its three connections. stdout: {stdout}"); + assert_eq!( + calls, + Some(3), + "three connections - through connect, WSAConnect and ConnectEx - were counted as {calls:?}. \ + Fewer means a way to connect bypasses the observer, more means one is counted twice. \ + stdout: {stdout}" + ); + assert!(warned, "the connections were counted but source.network_at_start was not raised. stdout: {stdout}"); + drop(listener); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/cli/tests/shape.rs b/crates/cli/tests/shape.rs index d490d9e..6da7838 100644 --- a/crates/cli/tests/shape.rs +++ b/crates/cli/tests/shape.rs @@ -26,14 +26,16 @@ use std::path::{Path, PathBuf}; /// check that vanished cannot fail. const SHAPE_LINTS: &[&str] = &["too_many_lines", "cognitive_complexity", "excessive_nesting"]; -/// Escape hatches out of `clippy::too_many_arguments`, measured 2026-09-06. +/// Escape hatches out of `clippy::too_many_arguments`, measured 2026-09-06 and again 2026-09-23. /// -/// The four are `chrono-ctl::write_anchor_full` and three detours in `chrono-hook` that mirror +/// The five are `chrono-ctl::write_anchor_full` and four detours in `chrono-hook` that mirror /// Win32 entry points: `h_ntcup` is NtCreateUserProcess, `h_cpw` and `h_cpa` are CreateProcessW and -/// CreateProcessA. Their parameter lists are Microsoft's, not ours to split, which is why the width -/// axis is not a ratchet in this project and this count is one instead. Pinned exactly rather than -/// bounded: a count left standing above the truth grants a free allow nobody decided to grant. -const ARGUMENT_ALLOWS: usize = 4; +/// CreateProcessA, and `h_ntdiocf` is NtDeviceIoControlFile, where the connection observer moved from +/// ws2_32's three-argument `connect` (2026-09-23). Their parameter lists are Microsoft's, not ours to +/// split - on 32-bit the callee pops them, so the count has to match to the argument - which is why the +/// width axis is not a ratchet in this project and this count is one instead. Pinned exactly rather +/// than bounded: a count left standing above the truth grants a free allow nobody decided to grant. +const ARGUMENT_ALLOWS: usize = 5; fn repo_root() -> PathBuf { // Duplicated from tests/hygiene.rs on purpose. Integration tests are separate binaries, and a @@ -256,7 +258,7 @@ fn the_argument_escape_hatch_only_ever_gets_rarer() { found.len(), ARGUMENT_ALLOWS, "the argument escape hatches measured {} against a frozen {ARGUMENT_ALLOWS}. Fewer means \ - lower the number in the same change. More means a fifth signature grew past seven \ + lower the number in the same change. More means another signature grew past seven \ arguments and reached for an allow instead of a smaller call: {found:?}", found.len() ); diff --git a/crates/ctl/src/lib.rs b/crates/ctl/src/lib.rs index 3adaf9f..df5967c 100644 --- a/crates/ctl/src/lib.rs +++ b/crates/ctl/src/lib.rs @@ -208,7 +208,8 @@ pub const CH_TPTIMER: u64 = 1 << 31; pub const CH_TPTIMEREX: u64 = 1 << 32; /// Coverage bit: `NtCreateUserProcess` is hooked (direct process creation, observed not injected, ADR-3). pub const CH_NTCUP: u64 = 1 << 33; -/// Coverage bit: `connect` is hooked (ws2_32 network connection, observed - a suspected server time source). +/// Coverage bit: network connections are observed (every connection attempt, whichever API made it, +/// counted at `NtDeviceIoControlFile` - a suspected server time source, see `ChannelDef::export`). pub const CH_CONNECT: u64 = 1 << 34; /// Coverage bit: `QueryPerformanceCounter` is hooked (QPC axis, opt-in `scale_qpc`, ADR-2 reversal). pub const CH_QPC: u64 = 1 << 35; @@ -286,9 +287,10 @@ pub enum ChannelModule { /// winmm.dll - the multimedia timer timeSetEvent. Often absent (a console/service target rarely /// loads winmm) - resolved lazily like User32, honest partial if absent, never force-loaded. Winmm, - /// ws2_32.dll - the sockets `connect` and `WSAWaitForMultipleEvents`. Often absent (a target that - /// never touches the network never loads it) - resolved lazily like Winmm, honest partial if - /// absent, never force-loaded. + /// ws2_32.dll - the socket wait `WSAWaitForMultipleEvents`. Often absent (a target that never + /// touches the network never loads it) - resolved lazily like Winmm, honest partial if absent, + /// never force-loaded. The connection observer used to live here too, on `connect`, and moved to + /// ntdll because two of the three ways to connect never call that export (`ChannelDef::export`). Ws2_32, /// kernelbase.dll - the synchronisation waits that kernel32 does not export itself. /// @@ -364,7 +366,7 @@ pub enum ChannelCategory { /// be uncovered, an honest audit (rule 4) without the risk. NOT opt-in (unlike the time observers): /// process creation is watched regardless of scale_duration. SpawnObserved, - /// Hooked and counted, but never modified: a network `connect` (ws2_32). A target that opens a + /// Hooked and counted, but never modified: a network connection attempt. A target that opens a /// network connection may read the time from a SERVER, which no local hook can cover - so we observe /// it and warn (source.network_at_start), an honest audit (rule 4) of a time source we cannot /// substitute. Like SpawnObserved, NOT opt-in: the network is watched regardless of scale_duration, @@ -381,17 +383,35 @@ pub enum ChannelCategory { Qpc, } -/// One time channel: its coverage bit, the exported symbol the hook detours, the -/// module that exports it, and its category. Single source of truth so the mechanism -/// reports exactly the channels the hook installs. +/// One time channel: its coverage bit, its name, the module that exports it, and its category. +/// Single source of truth so the mechanism reports exactly the channels the hook installs. #[derive(Debug, Clone, Copy)] pub struct ChannelDef { pub bit: u64, + /// The channel's name on the wire and in the report - a public contract key (untouchable + /// rule 17). It is also the exported symbol the hook detours, with the one exception + /// `export` names. pub name: &'static str, pub module: ChannelModule, pub category: ChannelCategory, } +impl ChannelDef { + /// The exported symbol the hook detours for this channel. The channel's own name for every + /// channel but one. + /// + /// The exception is `connect`, which counts every network connection attempt at + /// `ntdll!NtDeviceIoControlFile` - the one place all of them pass through. Measured on both + /// bitnesses (2026-09-23): `connect` and `WSAConnect` reach the socket driver with control code + /// 0x12007, while `ConnectEx` - and with it `WSAConnectByName`, `WSAConnectByList`, WinHTTP and + /// WinINet - uses 0x120C7 and never calls the `connect` export. A detour on that export saw two + /// of those eleven paths, and the .NET, Node.js and Go runtimes connect through the others. The + /// name stays `connect` because it is a contract key, and because it still says what is counted. + pub const fn export(&self) -> &'static str { + if self.bit == CH_CONNECT { "NtDeviceIoControlFile" } else { self.name } + } +} + /// The address of the pointer slot an indirect jump at a function's entry loads its target from, or /// `None` when the entry is not one of the stub shapes kernel32 uses. The hook reads that slot to /// learn whether kernel32's export leads into kernelbase (`ChannelModule::KernelBaseBehindKernel32`). @@ -549,7 +569,7 @@ pub const CHANNELS: [ChannelDef; CHANNEL_COUNT] = [ ChannelDef { bit: CH_TPTIMER, name: "SetThreadpoolTimer", module: ChannelModule::KernelBaseBehindKernel32, category: ChannelCategory::Duration }, ChannelDef { bit: CH_TPTIMEREX, name: "SetThreadpoolTimerEx", module: ChannelModule::KernelBaseBehindKernel32, category: ChannelCategory::Duration }, ChannelDef { bit: CH_NTCUP, name: "NtCreateUserProcess", module: ChannelModule::Ntdll, category: ChannelCategory::SpawnObserved }, - ChannelDef { bit: CH_CONNECT, name: "connect", module: ChannelModule::Ws2_32, category: ChannelCategory::SourceObserved }, + ChannelDef { bit: CH_CONNECT, name: "connect", module: ChannelModule::Ntdll, category: ChannelCategory::SourceObserved }, ChannelDef { bit: CH_QPC, name: "QueryPerformanceCounter", module: ChannelModule::KernelBaseBehindKernel32, category: ChannelCategory::Qpc }, ChannelDef { bit: CH_TIMEGETTIME, name: "timeGetTime", module: ChannelModule::Winmm, category: ChannelCategory::Duration }, ChannelDef { bit: CH_SCVSRW, name: "SleepConditionVariableSRW", module: ChannelModule::KernelBase, category: ChannelCategory::WaitObserved }, @@ -1979,6 +1999,22 @@ mod tests { } } + /// The hook resolves every channel by `export`, and the report names it by `name`. The two differ + /// for the connection observer alone, which lives in ntdll - where no `connect` export exists, so + /// resolving it by its name would leave the channel quietly not installed. + #[test] + fn every_channel_detours_its_own_name_except_the_connection_observer() { + for ch in CHANNELS { + if ch.bit == CH_CONNECT { + assert_eq!(ch.export(), "NtDeviceIoControlFile"); + assert_eq!(ch.name, "connect", "the name is a contract key"); + assert_eq!(ch.module, ChannelModule::Ntdll, "the export lives in ntdll"); + } else { + assert_eq!(ch.export(), ch.name, "{} detours a different symbol than it names", ch.name); + } + } + } + #[test] fn scale_wait_divides_and_guards_edges() { // INFINITE and 0 are untouched - never a finite wait, never a lengthened poll. diff --git a/crates/hook/src/lib.rs b/crates/hook/src/lib.rs index 2fd18b8..53b5a7a 100644 --- a/crates/hook/src/lib.rs +++ b/crates/hook/src/lib.rs @@ -171,10 +171,23 @@ type TimeSetEventFn = unsafe extern "system" fn(u32, u32, *const c_void, usize, // application's own duration axis disagree with itself by the multiplier. Takes no argument, so the // detour is a pure substitution with nothing to translate. type TimeGetTimeFn = unsafe extern "system" fn() -> u32; -// connect(SOCKET s, const sockaddr *name, int namelen) -> int (ws2_32). SourceObserved: we only COUNT a -// network connection (a suspected server time source) and forward every arg untouched. SOCKET is a -// UINT_PTR (usize), the sockaddr* is opaque (never dereferenced), namelen is int (i32). -type ConnectFn = unsafe extern "system" fn(usize, *const c_void, i32) -> i32; +// NtDeviceIoControlFile(FileHandle, Event, ApcRoutine, ApcContext, IoStatusBlock, IoControlCode, +// InputBuffer, InputBufferLength, OutputBuffer, OutputBufferLength) -> NTSTATUS (ntdll, documented in +// winternl.h). The connection observer: we read the control code, a plain number, and COUNT a +// connection attempt (a suspected server time source) - every other argument is forwarded untouched and +// never dereferenced, so no undocumented structure of the socket driver is ever parsed here. +type NtDeviceIoControlFileFn = unsafe extern "system" fn( + HANDLE, + HANDLE, + *const c_void, + *const c_void, + *mut c_void, + u32, + *const c_void, + u32, + *mut c_void, + u32, +) -> i32; // SetThreadpoolTimer(pti, pftDueTime, msPeriod, msWindowLength) -> VOID, and SetThreadpoolTimerEx -> // BOOL (kernel32, threadpoolapiset). pftDueTime is a FILETIME* (same 64 bits as SetWaitableTimer's // LARGE_INTEGER*): positive/zero = absolute, negative = relative, NULL = cancel. ADR-7 class C: due + @@ -273,7 +286,7 @@ static O_TIMEGETTIME: OnceLock = OnceLock::new(); static O_TPTIMER: OnceLock = OnceLock::new(); static O_TPTIMEREX: OnceLock = OnceLock::new(); static O_NTCUP: OnceLock = OnceLock::new(); -static O_CONNECT: OnceLock = OnceLock::new(); +static O_NTDIOCF: OnceLock = OnceLock::new(); // Child inheritance (ADR-3): our own module handle (to inject the same DLL into a // child) and the CreateProcessW trampoline. @@ -420,8 +433,9 @@ unsafe extern "system" fn watcher_proc(_p: *mut c_void) -> u32 { unsafe { // from `GetTickCount`, two clocks that both mean "milliseconds since boot". // // Six channels can land here: `timeGetTime` and `SetTimer` are SCALED (their absence is a hole in -// the acceleration, not just in the audit), `timeSetEvent`, both message waits and `connect` are -// observed. +// the acceleration, not just in the audit), `timeSetEvent`, both message waits and the socket wait +// are observed. All six ride the `scale_duration` opt-in. The connection observer used to be the +// seventh, on ws2_32's `connect`, and left this list when it moved to ntdll, which is never late. // // WHY THE INSTALL RUNS ON THE WATCHER THREAD AND NOT WHERE THE MODULE ARRIVES // --------------------------------------------------------------------------- @@ -461,14 +475,7 @@ static INSTALL_DONE: AtomicBool = AtomicBool::new(false); const USER32_LATE: u64 = CHANNELS[IDX_MWFMO].bit | CHANNELS[IDX_MWFMOEX].bit | CHANNELS[IDX_SETTIMER].bit; const WINMM_LATE: u64 = CHANNELS[IDX_TIMESETEVENT].bit | CHANNELS[IDX_TIMEGETTIME].bit; -/// ws2_32's two late channels sit behind DIFFERENT gates, which is why they are named apart. `connect` -/// is watched in every session (the network is a suspected time source regardless of the duration -/// axis), while the socket wait rides the wait family's `scale_duration` opt-in like every other wait. -/// Folding them into one constant would make a session that never asked for the duration axis install -/// a wait channel anyway, the exact thing the `wanted_late` gate exists to prevent. -const WS2_32_CONNECT_LATE: u64 = CHANNELS[IDX_CONNECT].bit; -const WS2_32_WAIT_LATE: u64 = CHANNELS[IDX_WSAWFME].bit; -const WS2_32_LATE: u64 = WS2_32_CONNECT_LATE | WS2_32_WAIT_LATE; +const WS2_32_LATE: u64 = CHANNELS[IDX_WSAWFME].bit; /// Every channel that lives in a module which may show up after `DllMain`. const LATE_CHANNELS: u64 = USER32_LATE | WINMM_LATE | WS2_32_LATE; @@ -522,12 +529,12 @@ unsafe fn late_one( if slot.get().is_some() { return; // already installed at DllMain time - nothing owed here } - let Ok(cname) = CString::new(ch.name) else { + let Ok(cname) = CString::new(ch.export()) else { log(&format!("[chrono_hook] late: bad channel name: {}", ch.name)); return; }; let Some(target) = GetProcAddress(module, PCSTR(cname.as_ptr() as *const u8)) else { - log(&format!("[chrono_hook] late: no export: {}", ch.name)); + log(&format!("[chrono_hook] late: no export: {}", ch.export())); return; }; match MinHook::create_hook(target as *const () as *mut c_void, detour) { @@ -577,14 +584,7 @@ unsafe fn late_scan() { unsafe { if todo & WS2_32_LATE != 0 && let Some(m) = pin_module(s!("ws2_32.dll")) { - // Per bit here, unlike the two blocks above, because these two channels answer to different - // opt-ins - so "the module arrived" is not on its own a reason to install both. - if todo & WS2_32_CONNECT_LATE != 0 { - late_one(&mut newly, m, IDX_CONNECT, h_connect as *const () as *mut c_void, &O_CONNECT); - } - if todo & WS2_32_WAIT_LATE != 0 { - late_one(&mut newly, m, IDX_WSAWFME, h_wsawfme as *const () as *mut c_void, &O_WSAWFME); - } + late_one(&mut newly, m, IDX_WSAWFME, h_wsawfme as *const () as *mut c_void, &O_WSAWFME); } if newly == 0 { return; @@ -1734,17 +1734,58 @@ unsafe extern "system" fn h_timegettime() -> u32 { unsafe { } }} -// connect (ws2_32, SourceObserved): a network connection is a suspected SERVER time source, which no -// local hook can cover. We only COUNT it and forward untouched (never modify the connection) - the audit -// then warns source.network_at_start. Like timeSetEvent: no guard, no detached check (we never change the -// call). The unreachable None path returns SOCKET_ERROR (-1) so an un-hooked call never fakes success. -unsafe extern "system" fn h_connect(s: usize, name: *const c_void, namelen: i32) -> i32 { unsafe { - let o = match O_CONNECT.get() { +/// The socket driver's control code for a connection made by `connect` or `WSAConnect`. +/// +/// Not documented by Microsoft. Measured on both bitnesses (2026-09-23) as the one code a connection +/// attempt through either API sends, once, and corroborated by reverse engineering of the driver, +/// which names its handler AfdConnect. The driver builds its codes as (0x12 << 12) | (op << 2) | method, +/// NOT with the usual CTL_CODE layout, which is why the value looks like device type 1. +const AFD_CONNECT: u32 = 0x12007; + +/// The socket driver's control code for a connection made by `ConnectEx`, which `WSAConnectByName`, +/// `WSAConnectByList`, WinHTTP and WinINet all use. The driver names its handler AfdSuperConnect. +/// Measured and corroborated the same way as `AFD_CONNECT`. +const AFD_SUPER_CONNECT: u32 = 0x120C7; + +/// Whether a device control code is a network connection attempt. Every path measured sends exactly +/// one of the two codes per attempt, never both, and a datagram sent without a connection sends +/// neither - it is not a connection. +fn is_connection_attempt(code: u32) -> bool { + code == AFD_CONNECT || code == AFD_SUPER_CONNECT +} + +// The connection observer (SourceObserved, channel `connect`): a network connection is a suspected +// SERVER time source, which no local hook can cover. Every Winsock connection attempt reaches the +// socket driver through this one function, whichever API the application called - which is why it is +// detoured here and not on ws2_32's `connect`, which two of the three ways to connect never call +// (`ChannelDef::export`). We read the control code, COUNT a connection, and forward every argument +// untouched. Like timeSetEvent: no guard, no detached check, since we never change the call. +// +// This runs for every device control call in the process, socket reads and writes included, so it is +// two comparisons and a forward: measured against 200 000 socket polls, the difference stayed inside +// the run-to-run noise on both bitnesses. The unreachable None path returns STATUS_UNSUCCESSFUL so an +// un-hooked call never fakes success. +#[allow(clippy::too_many_arguments)] +unsafe extern "system" fn h_ntdiocf( + file: HANDLE, + event: HANDLE, + apc: *const c_void, + apc_context: *const c_void, + io_status: *mut c_void, + code: u32, + input: *const c_void, + input_len: u32, + output: *mut c_void, + output_len: u32, +) -> i32 { unsafe { + let o = match O_NTDIOCF.get() { Some(o) => o, - None => return -1, + None => return 0xC000_0001_u32 as i32, }; - bump(IDX_CONNECT); - o(s, name, namelen) + if is_connection_attempt(code) { + bump(IDX_CONNECT); + } + o(file, event, apc, apc_context, io_status, code, input, input_len, output, output_len) }} // Thread-pool timers (kernel32, ADR-7 class C): SetThreadpoolTimer / SetThreadpoolTimerEx share the @@ -2192,7 +2233,7 @@ unsafe fn make_kernelbase_copy_hook( slot: &OnceLock, ) { unsafe { let ch = &CHANNELS[idx]; - let Ok(cname) = CString::new(ch.name) else { + let Ok(cname) = CString::new(ch.export()) else { return; }; let name = PCSTR(cname.as_ptr() as *const u8); @@ -2297,7 +2338,7 @@ unsafe fn make_hook( } }, }; - let cname = match CString::new(ch.name) { + let cname = match CString::new(ch.export()) { Ok(c) => c, Err(_) => { log(&format!("[chrono_hook] bad channel name: {}", ch.name)); @@ -2307,7 +2348,7 @@ unsafe fn make_hook( let target = match GetProcAddress(module, PCSTR(cname.as_ptr() as *const u8)) { Some(f) => f, None => { - log(&format!("[chrono_hook] no export: {}", ch.name)); + log(&format!("[chrono_hook] no export: {}", ch.export())); return; } }; @@ -2506,10 +2547,11 @@ unsafe fn install() -> Result<(), String> { unsafe { // forwards untouched - the SPAWNING guard keeps the CreateProcess* funnel from counting here. make_hook(&mut pending, k32, ntdll, IDX_NTCUP, h_ntcup as *const () as *mut c_void, &O_NTCUP); - // Suspected time source (Etap 2, observed): hook ws2_32 connect ALWAYS - the network is watched - // regardless of scale_duration. It only counts a connection (a suspected server time source we cannot - // cover) and forwards untouched - the audit warns source.network_at_start. - make_hook(&mut pending, k32, ntdll, IDX_CONNECT, h_connect as *const () as *mut c_void, &O_CONNECT); + // Suspected time source (Etap 2, observed): watch network connections ALWAYS - the network is + // watched regardless of scale_duration. The detour sits in ntdll, where every connection attempt + // passes whichever API made it, only counts one (a suspected server time source we cannot cover) + // and forwards untouched - the audit warns source.network_at_start. + make_hook(&mut pending, k32, ntdll, IDX_CONNECT, h_ntdiocf as *const () as *mut c_void, &O_NTDIOCF); // Child inheritance (ADR-3): hook CreateProcessW and CreateProcessA so the whole // process tree joins the session whichever spawn API the parent uses. Not coverage @@ -2560,14 +2602,14 @@ unsafe fn install() -> Result<(), String> { unsafe { // Hand the watcher whatever this session WANTED from an optional module and did not get. The set // is computed here rather than in the watcher so the opt-in gates are stated once: without // scale_duration the duration and observed-time channels are not wanted at all, and looking for - // them later would install channels the session deliberately did not ask for. `connect` is not - // gated - the network is watched regardless. + // them later would install channels the session deliberately did not ask for. Every late channel + // rides that opt-in now - the connection observer lives in ntdll, which is never late - so a + // session without it leaves the watcher nothing to look for. // // `INSTALL_DONE` is released LAST, after the mask store above, and that ordering is the whole // point of the flag (R1): until it is set the watcher will not touch the Cov, so a late bit // cannot be ORed in and then wiped by our own store. - let wanted_late = - if read_scale_dur(ctl as *const Ctl) { LATE_CHANNELS } else { WS2_32_CONNECT_LATE }; + let wanted_late = if read_scale_dur(ctl as *const Ctl) { LATE_CHANNELS } else { 0 }; LATE_TODO.store(wanted_late & !pending, Ordering::Relaxed); INSTALL_DONE.store(true, Ordering::Release); @@ -2611,4 +2653,16 @@ mod tests { assert_eq!(settle_second_body(other, bit, true), other, "the first body failed, the second went live"); assert_eq!(settle_second_body(other, bit, false), other, "neither body detoured"); } + + /// The codes the connection observer counts, and the neighbours it must not, all measured on this + /// machine's socket driver (2026-09-23). The last one is a network code in the ordinary CTL_CODE + /// layout that name resolution sends - the value a filter built on that layout would have matched. + #[test] + fn only_the_two_connection_codes_count_as_a_connection_attempt() { + assert!(is_connection_attempt(0x12007), "connect and WSAConnect"); + assert!(is_connection_attempt(0x120C7), "ConnectEx and everything built on it"); + for other in [0x12003, 0x12023, 0x12024, 0x12047, 0x120BF, 0x120007] { + assert!(!is_connection_attempt(other), "0x{other:x} is not a connection attempt"); + } + } } diff --git a/crates/mech/src/lib.rs b/crates/mech/src/lib.rs index b477214..c00ee15 100644 --- a/crates/mech/src/lib.rs +++ b/crates/mech/src/lib.rs @@ -33,7 +33,7 @@ use chrono_ctl::{ read_uncovered_child, read_uncovered_children_count, read_uninjected_children, read_waits_at_floor, write_anchor, write_anchor_full, write_header, write_core_pid, write_scale_dur, write_scale_qpc, write_tz_bias, ChannelCategory, ChannelModule, - Cov, Ctl, CHANNELS, CH_CONNECT, CH_GTC, CH_GTC64, IDX_TIMEGETTIME, MAX_COV_PIDS, + Cov, Ctl, CHANNELS, CH_GTC, CH_GTC64, IDX_TIMEGETTIME, MAX_COV_PIDS, }; use windows::core::{s, PCWSTR, PWSTR}; use windows::Win32::Foundation::{ @@ -842,16 +842,19 @@ fn lock_is_ours(state: windows::Win32::Foundation::WAIT_EVENT) -> bool { /// before the move got the response. Without this line the report would say, one row above, that an /// I/O timeout is not shortened. /// -/// The signal is ws2_32 being loaded (the `connect` channel installed), not a connection counted: WinHTTP -/// connects through ConnectEx, which the `connect` observer does not see, so a count would miss exactly -/// the library this warning is about. +/// The signal is a connection counted by this process. The connection observer sees every Winsock +/// path, WinHTTP's `ConnectEx` included, so the count is the one the warning needs. It used to be +/// "ws2_32 loaded", read off the observer's install bit while the observer sat on ws2_32 and missed +/// WinHTTP - which also made a failed hook in a loaded ws2_32 look like no network stack at all. A +/// connection is the narrower signal on purpose: plenty of applications load ws2_32 through another +/// library and never open a socket, and their timeouts have nothing to follow. /// /// A tick count channel has to be installed as well, because that is what the timeout follows: with /// both of them failed the tick count stays real, and the sentence would describe a session that did /// not happen. One of the two is enough for the caution to be true for a library reading that one, and /// the one that failed is listed as uncovered already. -fn network_timeouts_follow_session(installed: u64, scale_duration: bool) -> bool { - scale_duration && installed & CH_CONNECT != 0 && installed & (CH_GTC64 | CH_GTC) != 0 +fn network_timeouts_follow_session(installed: u64, connected: bool, scale_duration: bool) -> bool { + scale_duration && connected && installed & (CH_GTC64 | CH_GTC) != 0 } /// Build one process's coverage from its `Cov` section: the install bitmask and the @@ -1001,7 +1004,7 @@ unsafe fn gather_coverage( if any_source_observed { out.warning_keys.push("source.network_at_start".to_string()); } - if network_timeouts_follow_session(installed, scale_duration) { + if network_timeouts_follow_session(installed, any_source_observed, scale_duration) { out.warning_keys.push("wait.network_timeouts_scaled".to_string()); } // At least one channel was hooked only after its module turned up, which for a runtime that pulls @@ -1524,7 +1527,7 @@ mod tests { // Only the QPC-channel test needs this bit, so it is imported here rather than in the lib. use chrono_ctl::CH_QPC; // Same for the winmm-clock test: the bit, its counter index, and the counter writer. - use chrono_ctl::{bump_calls, set_late_installed, CH_TIMEGETTIME, IDX_TIMEGETTIME}; + use chrono_ctl::{bump_calls, set_late_installed, CH_TIMEGETTIME, IDX_CONNECT, IDX_TIMEGETTIME}; /// R2-X2. The projection the core reports has to be the one the target sees - the hook clamps at /// the end of the range, so this must clamp there too. Before it did, a session at the edge showed @@ -1767,28 +1770,31 @@ mod tests { } /// Network timeouts follow the session once the tick count is detoured in kernelbase, and the audit - /// says so whenever the network stack is loaded under a scaled duration axis - with no call counted, - /// because WinHTTP connects through ConnectEx, which the `connect` observer never sees. + /// says so when the process opened a connection under a scaled duration axis. The connection is + /// counted, not inferred from the observer being installed: the observer sits in ntdll and is + /// installed in every process, network or not. #[test] - fn a_loaded_network_stack_under_a_scaled_duration_axis_is_warned_about_even_with_no_connect_counted() { + fn a_connection_under_a_scaled_duration_axis_warns_that_network_timeouts_follow_the_session() { let all = CHANNELS.iter().fold(0u64, |acc, ch| acc | ch.bit); let warned = |c: &Coverage| c.warning_keys.iter().any(|k| k == "wait.network_timeouts_scaled"); let quiet = zeroed_cov(); + let mut connected = zeroed_cov(); + unsafe { bump_calls(&mut connected as *mut Cov, IDX_CONNECT) }; - let loaded = unsafe { gather_coverage(&quiet as *const Cov, all, true, false) }; - assert!(warned(&loaded), "network stack loaded and the axis scaled, with zero connects counted"); - assert_eq!(chrono_core::verdict_from_coverage(&loaded), chrono_core::Verdict::Works, "a caution, never a verdict"); + let with_connection = unsafe { gather_coverage(&connected as *const Cov, all, true, false) }; + assert!(warned(&with_connection), "a connection counted and the axis scaled"); + assert_eq!(chrono_core::verdict_from_coverage(&with_connection), chrono_core::Verdict::Works, "a caution, never a verdict"); - let no_stack = unsafe { gather_coverage(&quiet as *const Cov, all & !CH_CONNECT, true, false) }; - assert!(!warned(&no_stack), "no network stack loaded, nothing to time out"); + let no_connection = unsafe { gather_coverage(&quiet as *const Cov, all, true, false) }; + assert!(!warned(&no_connection), "the observer is installed everywhere, so no connection means nothing to time out"); - let axis_real = unsafe { gather_coverage(&quiet as *const Cov, all, false, false) }; + let axis_real = unsafe { gather_coverage(&connected as *const Cov, all, false, false) }; assert!(!warned(&axis_real), "without the duration opt-in the tick count stays real"); // The timeout follows the tick count, so the tick count has to be on the session's axis. - let no_tick = unsafe { gather_coverage(&quiet as *const Cov, all & !(CH_GTC64 | CH_GTC), true, false) }; + let no_tick = unsafe { gather_coverage(&connected as *const Cov, all & !(CH_GTC64 | CH_GTC), true, false) }; assert!(!warned(&no_tick), "both tick count channels failed, so the timeouts stay real"); - let one_tick = unsafe { gather_coverage(&quiet as *const Cov, all & !CH_GTC, true, false) }; + let one_tick = unsafe { gather_coverage(&connected as *const Cov, all & !CH_GTC, true, false) }; assert!(warned(&one_tick), "a library reading the installed tick count still follows the session"); } diff --git a/gui/ChronoMock.App/Localization/Strings.en.json b/gui/ChronoMock.App/Localization/Strings.en.json index b761117..d2a26bd 100644 --- a/gui/ChronoMock.App/Localization/Strings.en.json +++ b/gui/ChronoMock.App/Localization/Strings.en.json @@ -420,7 +420,7 @@ "source.network_at_start": "The application opened a network connection - it may read the time from a server, which no local clock can change.", "wait.object_waits_not_scaled": "Waits on system objects are left on the real clock on purpose - an I/O or hardware timeout is not shortened.", "wait.timeout_collapsed": "Some of this application's waits were too short to divide by the full speed factor, so they ran at the shortest step this tool uses (1 ms) instead. That part of the application did not accelerate - a lower speed makes it exact again.", - "wait.network_timeouts_scaled": "This application has the network stack loaded. A network library that measures its own timeout from the system tick count, such as WinHTTP, follows the session speed - so a server slower than that timeout divided by the speed makes a request fail, even though the waits underneath stay on the real clock.", + "wait.network_timeouts_scaled": "This application opened a network connection. A network library that measures its own timeout from the system tick count, such as WinHTTP, follows the session speed - so a server slower than that timeout divided by the speed makes a request fail, even though the waits underneath stay on the real clock.", "timer.multimedia_not_scaled": "The multimedia timer (timeSetEvent) is observed but not scaled.", "clock.timegettime_scaled_audio_may_shift": "The winmm clock timeGetTime is being scaled with the rest of the duration axis, so a target that paces itself from it follows the session - but a media application that positions audio from that clock may drift.", "coverage.channel_installed_late": "The functions listed as coming under the fake clock late were hooked only once their own module loaded, so calls the application made before that are missing from their counts - and a scaled one's clock jumped once when it joined.", diff --git a/gui/ChronoMock.App/Localization/Strings.pl.json b/gui/ChronoMock.App/Localization/Strings.pl.json index da12385..d208ebe 100644 --- a/gui/ChronoMock.App/Localization/Strings.pl.json +++ b/gui/ChronoMock.App/Localization/Strings.pl.json @@ -404,7 +404,7 @@ "source.network_at_start": "Aplikacja otworzyła połączenie sieciowe - może czytać czas z serwera, na który żaden lokalny zegar nie ma wpływu.", "wait.object_waits_not_scaled": "Oczekiwania na obiekty systemowe są świadomie zostawione na prawdziwym zegarze - limit czasu I/O albo sprzętu nie jest skracany.", "wait.timeout_collapsed": "Część oczekiwań tej aplikacji była za krótka, żeby podzielić je przez pełny mnożnik, więc przebiegły w najkrótszym kroku, jakiego to narzędzie używa (1 ms). Ta część aplikacji nie przyspieszyła - mniejsza prędkość sprawi, że będzie dokładnie.", - "wait.network_timeouts_scaled": "Ta aplikacja ma załadowany stos sieciowy. Biblioteka sieciowa, która sama odmierza limit czasu z systemowego licznika, na przykład WinHTTP, idzie za prędkością sesji - więc serwer wolniejszy niż ten limit podzielony przez prędkość sprawi, że żądanie się nie powiedzie, choć oczekiwania pod spodem zostają na prawdziwym zegarze.", + "wait.network_timeouts_scaled": "Ta aplikacja otworzyła połączenie sieciowe. Biblioteka sieciowa, która sama odmierza limit czasu z systemowego licznika, na przykład WinHTTP, idzie za prędkością sesji - więc serwer wolniejszy niż ten limit podzielony przez prędkość sprawi, że żądanie się nie powiedzie, choć oczekiwania pod spodem zostają na prawdziwym zegarze.", "timer.multimedia_not_scaled": "Timer multimedialny (timeSetEvent) jest obserwowany, ale nie skalowany.", "clock.timegettime_scaled_audio_may_shift": "Zegar winmm timeGetTime jest skalowany razem z resztą osi trwania, więc cel odmierzający z niego własne tempo podąża za sesją - ale aplikacja multimedialna pozycjonująca dźwięk z tego zegara może dryfować.", "coverage.channel_installed_late": "Funkcje z listy „Pod fałszywy zegar trafiły później” zostały podpięte dopiero po załadowaniu swojego modułu, więc wywołań sprzed tego momentu nie ma w ich licznikach - a zegar funkcji skalowanej wykonał przy dołączeniu jednorazowy skok.", diff --git a/gui/ChronoMock.Protocol.Tests/BinaryImportsTests.cs b/gui/ChronoMock.Protocol.Tests/BinaryImportsTests.cs index a828022..3dc6e2e 100644 --- a/gui/ChronoMock.Protocol.Tests/BinaryImportsTests.cs +++ b/gui/ChronoMock.Protocol.Tests/BinaryImportsTests.cs @@ -335,8 +335,8 @@ public void The_injected_library_imports_no_networking_module_at_all() Assert.False( table.ImportsAnythingNamed("ws2_32"), - $"chrono_hook.dll ({triple}) links Winsock. It hooks connect - it must never CALL it. The " + - "module is resolved with GetModuleHandleA, and only when the target has already loaded " + + $"chrono_hook.dll ({triple}) links Winsock. It observes the target's sockets - it must never " + + "open one. The module is resolved with GetModuleHandleA, and only when the target has already loaded " + $"it, which is what keeps this import table clean: {string.Join(", ", table.Modules)}"); var networking = table.Modules.Where(IsNetworking).ToList(); From e4da4132977ac0e828767703bc9a70822f720783 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 23 Sep 2026 16:44:09 +0200 Subject: [PATCH 2/2] fix(hook): name the connection observer's failure status, bound the README claim The connection observer's unreachable None path returned the status as a literal, while the other ntdll detours use STATUS_UNSUCCESSFUL, which the comment above it already named. Same value, one name. The README row said every connection attempt is observed whichever function made it. That holds for connections through Windows' own socket layer, and a third-party Winsock provider would not be watched, so the row now says so. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- crates/hook/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5be2913..70f9d79 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ column says which is which._ | .NET (Framework and modern) | experimental | measured on x64, x86 | Time calls go through Win32 exports and are covered, including the session time zone. Stopwatch stays on the real high-resolution counter unless you opt in with Scale QPC (`--scale-qpc`), which accelerates it too | | Java (JVM) | experimental | measured on x64, x86 | Wall clock and elapsed time are covered. The session time zone is not reached - a known gap. nanoTime stays on the real high-resolution counter unless you opt in with Scale QPC (`--scale-qpc`) | | Python (CPython, incl. PyInstaller) | experimental | measured by hand on x64, not by the suite | Wall clock (time.time, datetime) and the session time zone (time.localtime) are covered. perf_counter, and monotonic on Python 3.13+, are on the high-resolution counter - real by default, accelerated when you opt in with Scale QPC (`--scale-qpc`) | -| Applications reading time from the network | out of scope by definition | measured on x64, x86 | The audit detects it - every connection attempt is observed and warned about, whichever function made it (Winsock, WinHTTP, WinINet, and the .NET, Node.js, Go, Java and Python runtimes). A datagram sent without a connection is not a connection and is not counted | +| Applications reading time from the network | out of scope by definition | measured on x64, x86 | The audit detects it - every connection attempt made through Windows' own socket layer is observed and warned about, whichever function made it (Winsock, WinHTTP, WinINet, and the .NET, Node.js, Go, Java and Python runtimes). A datagram sent without a connection is not a connection and is not counted. A third-party Winsock provider, rare on current Windows, is not watched | | Embedded web engine inside a native app (WebView2, Qt WebEngine) | experimental | measured by hand on a WebView2 host and a Qt WebEngine host (x64), not by the suite | The native hook covers the application and the pages inside it are reached over the engine's debugging port, opened for the session through two environment variables the application inherits. The pages read the session clock at the session rate and follow a speed change and a jump. The engine's helper processes and the renderer's native reads stay on the real clock, so the verdict is PARTIAL and says why. The pages keep the machine's time zone. An elevated application is out of reach - the engine ignores the variables | | Electron / Chromium | experimental (Chromium mode) | measured by hand on an Electron app (x64), not by the suite | A separate mechanism, not injection: the app is launched with a debug port and a clean isolated profile, and its own JS time APIs are put on the session clock over the DevTools protocol - reaching the sandboxed renderer and its Web Workers, where the timer often lives. The session zone follows the host zone (the instant is faked, not the local-time getters) | | UWP / MSIX (Store apps) | not supported | declared (not exercised) | Packaging and launch model | diff --git a/crates/hook/src/lib.rs b/crates/hook/src/lib.rs index 53b5a7a..3924d01 100644 --- a/crates/hook/src/lib.rs +++ b/crates/hook/src/lib.rs @@ -1780,7 +1780,7 @@ unsafe extern "system" fn h_ntdiocf( ) -> i32 { unsafe { let o = match O_NTDIOCF.get() { Some(o) => o, - None => return 0xC000_0001_u32 as i32, + None => return STATUS_UNSUCCESSFUL, }; if is_connection_attempt(code) { bump(IDX_CONNECT);