Skip to content

Commit ea185df

Browse files
wan9chicodex
andauthored
fix(fspy): trace Windows process images (#518)
## Motivation Windows Server 2022 can perform executable image lookup entirely inside `NtCreateUserProcess`, bypassing the user-mode NT file APIs fspy currently hooks. That can omit executable inputs from automatic cache fingerprints and causes the process-image tracing tests to fail on build 20348. ## What changed - Trace the kernel-facing `PS_ATTRIBUTE_IMAGE_NAME` at the `NtCreateUserProcess` boundary. - Parse the variable-length native attribute list and borrow its counted UTF-16 image path without allocating. - Initialize `STARTUPINFO.cb` correctly in the direct Win32 process tests. - Document the user-visible Windows tracing fix in the changelog. --------- Co-authored-by: GPT-5.6 <codex@openai.com>
1 parent fac52cb commit ea185df

3 files changed

Lines changed: 149 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Changelog
22

3+
- **Fixed** Windows automatic input tracking now records executable image reads when process creation performs the lookup inside `NtCreateUserProcess` ([#518](https://github.com/voidzero-dev/vite-task/pull/518)).
34
- **Fixed** Failures while waiting for a started task process to exit no longer incorrectly say the process failed to spawn ([#515](https://github.com/voidzero-dev/vite-task/pull/515)).
45
- **Fixed** Missing env vars requested through `@voidzero-dev/vite-task-client` now return `undefined` instead of `null`, preserving Vite production `NODE_ENV` semantics when builds run through `vp run` ([#508](https://github.com/voidzero-dev/vite-task/pull/508)).
56
- **Fixed** Windows builds no longer hang on CI when a `node_modules/.bin` `.cmd` shim is routed through PowerShell: the npm/pnpm/yarn `.ps1` wrappers read stdin and block forever on a non-TTY pipe, so the PowerShell rewrite is now skipped when stdin is not an interactive terminal, falling back to the `.cmd` (which never reads stdin) ([#491](https://github.com/voidzero-dev/vite-task/pull/491)).

crates/fspy/tests/winapi.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ async fn create_process_a() -> anyhow::Result<()> {
1515
let accesses = track_fn!((), |(): ()| {
1616
// SAFETY: zeroing STARTUPINFOA is valid for the Windows API
1717
let mut si: STARTUPINFOA = unsafe { std::mem::zeroed() };
18+
// CreateProcess requires cb to identify the STARTUPINFO layout.
19+
si.cb = std::mem::size_of::<STARTUPINFOA>().try_into().unwrap();
1820
// SAFETY: zeroing PROCESS_INFORMATION is valid for the Windows API
1921
let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
2022
// SAFETY: all pointers are valid or null_mut as required by CreateProcessA
@@ -44,6 +46,8 @@ async fn create_process_w() -> anyhow::Result<()> {
4446
let accesses = track_fn!((), |(): ()| {
4547
// SAFETY: zeroing STARTUPINFOW is valid for the Windows API
4648
let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() };
49+
// CreateProcess requires cb to identify the STARTUPINFO layout.
50+
si.cb = std::mem::size_of::<STARTUPINFOW>().try_into().unwrap();
4751
// SAFETY: zeroing PROCESS_INFORMATION is valid for the Windows API
4852
let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
4953
let program =

crates/fspy_preload_windows/src/windows/detours/nt.rs

Lines changed: 144 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1+
use std::mem::{offset_of, size_of};
2+
13
use fspy_shared::ipc::{AccessMode, NativePath, PathAccess};
2-
use ntapi::ntioapi::{
3-
FILE_INFORMATION_CLASS, NtQueryDirectoryFile, NtQueryFullAttributesFile,
4-
NtQueryInformationByName, PFILE_BASIC_INFORMATION, PFILE_NETWORK_OPEN_INFORMATION,
5-
PIO_APC_ROUTINE, PIO_STATUS_BLOCK,
4+
use ntapi::{
5+
ntioapi::{
6+
FILE_INFORMATION_CLASS, NtQueryDirectoryFile, NtQueryFullAttributesFile,
7+
NtQueryInformationByName, PFILE_BASIC_INFORMATION, PFILE_NETWORK_OPEN_INFORMATION,
8+
PIO_APC_ROUTINE, PIO_STATUS_BLOCK,
9+
},
10+
ntpsapi::{
11+
NtCreateUserProcess, PPS_ATTRIBUTE_LIST, PPS_CREATE_INFO, PS_ATTRIBUTE,
12+
PS_ATTRIBUTE_IMAGE_NAME, PS_ATTRIBUTE_LIST,
13+
},
614
};
715
use winapi::{
816
shared::{
@@ -21,6 +29,137 @@ use crate::windows::{
2129
detour::{Detour, DetourAny},
2230
};
2331

32+
// CreateProcess ultimately asks NtCreateUserProcess to open the executable image. Some Windows
33+
// versions perform that open entirely inside the syscall, so it never reaches the NtCreateFile and
34+
// query functions hooked below. PS_ATTRIBUTE_IMAGE_NAME is the kernel-facing path that identifies
35+
// the image to open; RTL_USER_PROCESS_PARAMETERS.ImagePathName is only metadata for the child PEB
36+
// and can intentionally name a different file.
37+
//
38+
// Record the image before forwarding the syscall, matching the attempted-access semantics of the
39+
// other NT hooks in this module. This is important for missing executables: the failed lookup is
40+
// still an input access even though NtCreateUserProcess returns an error.
41+
static DETOUR_NT_CREATE_USER_PROCESS: Detour<
42+
unsafe extern "system" fn(
43+
process_handle: PHANDLE,
44+
thread_handle: PHANDLE,
45+
process_desired_access: ACCESS_MASK,
46+
thread_desired_access: ACCESS_MASK,
47+
process_object_attributes: POBJECT_ATTRIBUTES,
48+
thread_object_attributes: POBJECT_ATTRIBUTES,
49+
process_flags: ULONG,
50+
thread_flags: ULONG,
51+
process_parameters: PVOID,
52+
create_info: PPS_CREATE_INFO,
53+
attribute_list: PPS_ATTRIBUTE_LIST,
54+
) -> NTSTATUS,
55+
> =
56+
// SAFETY: initializing Detour with the real NtCreateUserProcess function pointer
57+
unsafe {
58+
Detour::new(c"NtCreateUserProcess", NtCreateUserProcess, {
59+
unsafe extern "system" fn new_fn(
60+
process_handle: PHANDLE,
61+
thread_handle: PHANDLE,
62+
process_desired_access: ACCESS_MASK,
63+
thread_desired_access: ACCESS_MASK,
64+
process_object_attributes: POBJECT_ATTRIBUTES,
65+
thread_object_attributes: POBJECT_ATTRIBUTES,
66+
process_flags: ULONG,
67+
thread_flags: ULONG,
68+
process_parameters: PVOID,
69+
create_info: PPS_CREATE_INFO,
70+
attribute_list: PPS_ATTRIBUTE_LIST,
71+
) -> NTSTATUS {
72+
// SAFETY: observing caller memory without changing the forwarded arguments
73+
unsafe { handle_process_image(attribute_list) };
74+
75+
// SAFETY: calling the original NtCreateUserProcess with all original arguments
76+
unsafe {
77+
(DETOUR_NT_CREATE_USER_PROCESS.real())(
78+
process_handle,
79+
thread_handle,
80+
process_desired_access,
81+
thread_desired_access,
82+
process_object_attributes,
83+
thread_object_attributes,
84+
process_flags,
85+
thread_flags,
86+
process_parameters,
87+
create_info,
88+
attribute_list,
89+
)
90+
}
91+
}
92+
new_fn
93+
})
94+
};
95+
96+
unsafe fn handle_process_image(attribute_list: PPS_ATTRIBUTE_LIST) {
97+
// SAFETY: NtCreateUserProcess requires its attribute list to remain valid for this call.
98+
if let Some(image_path) = unsafe { read_process_image_attribute(attribute_list) } {
99+
// Sender serialization completes before this call returns, so NativePath does not retain
100+
// the borrowed PS_ATTRIBUTE_IMAGE_NAME buffer past the NtCreateUserProcess call.
101+
// SAFETY: accessing the global client which was initialized during DLL_PROCESS_ATTACH
102+
unsafe { global_client() }
103+
.send(PathAccess { mode: AccessMode::READ, path: NativePath::from_wide(image_path) });
104+
}
105+
}
106+
107+
/// Find the kernel-facing executable name in an `NtCreateUserProcess` attribute list.
108+
///
109+
/// `PS_ATTRIBUTE_LIST` is a variable-length structure: `TotalLength` covers a fixed-size header
110+
/// followed by contiguous `PS_ATTRIBUTE` entries. Its Rust definition contains one placeholder
111+
/// element, so the actual entry count must be derived from `TotalLength`, not from the array type.
112+
///
113+
/// The returned slice borrows the caller's `PS_ATTRIBUTE_IMAGE_NAME` buffer and is valid only while
114+
/// the intercepted `NtCreateUserProcess` call is active.
115+
///
116+
/// # Safety
117+
///
118+
/// `attribute_list` and the image-name buffer it references must remain valid for the duration of
119+
/// the intercepted call, as required by `NtCreateUserProcess`.
120+
unsafe fn read_process_image_attribute<'a>(
121+
attribute_list: PPS_ATTRIBUTE_LIST,
122+
) -> Option<&'a [u16]> {
123+
// SAFETY: NtCreateUserProcess keeps a supplied attribute list valid for this call; a null
124+
// optional pointer is parsed as None.
125+
let attribute_list = unsafe { attribute_list.as_ref()? };
126+
127+
// Attributes is a trailing array. Subtract its byte offset to remove the header; the native API
128+
// contract guarantees that the remaining bytes contain complete PS_ATTRIBUTE entries.
129+
let attributes_offset = offset_of!(PS_ATTRIBUTE_LIST, Attributes);
130+
let attribute_count =
131+
(attribute_list.TotalLength - attributes_offset) / size_of::<PS_ATTRIBUTE>();
132+
133+
// The Rust field exposes the first placeholder entry as a reference; TotalLength describes how
134+
// many contiguous entries follow it in the actual variable-length allocation.
135+
let first_attribute = attribute_list.Attributes.first()?;
136+
// SAFETY: TotalLength covers a contiguous variable-length tail starting at first_attribute.
137+
let attributes: &[PS_ATTRIBUTE] =
138+
unsafe { std::slice::from_raw_parts(std::ptr::from_ref(first_attribute), attribute_count) };
139+
for attribute in attributes {
140+
if attribute.Attribute != PS_ATTRIBUTE_IMAGE_NAME {
141+
continue;
142+
}
143+
144+
// Unlike a UNICODE_STRING, PS_ATTRIBUTE_IMAGE_NAME stores the path buffer directly in
145+
// ValuePtr and stores its byte length in Size. It is the image path consumed by the kernel,
146+
// so do not fall back to the separately spoofable process-parameter path.
147+
// SAFETY: PS_ATTRIBUTE_IMAGE_NAME stores a valid UTF-16 pointer in ValuePtr for this call;
148+
// a null pointer is parsed as None.
149+
let image_path = unsafe { attribute.u.ValuePtr.cast::<u16>().as_ref()? };
150+
// SAFETY: the attribute contract guarantees a valid UTF-16 buffer of Size bytes for this
151+
// call. Size is the counted string length, so no NUL-terminator parsing is needed.
152+
return Some(unsafe {
153+
std::slice::from_raw_parts(
154+
std::ptr::from_ref(image_path),
155+
attribute.Size / size_of::<u16>(),
156+
)
157+
});
158+
}
159+
160+
None
161+
}
162+
24163
static DETOUR_NT_CREATE_FILE: Detour<
25164
unsafe extern "system" fn(
26165
file_handle: PHANDLE,
@@ -380,6 +519,7 @@ static DETOUR_NT_QUERY_DIRECTORY_FILE_EX: Detour<NtQueryDirectoryFileExFn> =
380519
};
381520

382521
pub const DETOURS: &[DetourAny] = &[
522+
DETOUR_NT_CREATE_USER_PROCESS.as_any(),
383523
DETOUR_NT_CREATE_FILE.as_any(),
384524
DETOUR_NT_OPEN_FILE.as_any(),
385525
DETOUR_NT_QUERY_ATTRIBUTES_FILE.as_any(),

0 commit comments

Comments
 (0)