Skip to content

Commit 67725f7

Browse files
committed
Add Windows smoke example and CI coverage
1 parent 2a826c6 commit 67725f7

25 files changed

Lines changed: 704 additions & 30 deletions

File tree

.github/workflows/ci.yml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,3 +371,54 @@ jobs:
371371
-lc
372372
output="$(LD_PRELOAD="$PWD/hook.so" ./target)"
373373
test "$output" = "result=77"
374+
375+
zig-windows-x86_64:
376+
runs-on: windows-latest
377+
378+
steps:
379+
- name: Checkout
380+
uses: actions/checkout@v5
381+
382+
- name: Setup Zig
383+
shell: pwsh
384+
run: |
385+
$archive = "zig-x86_64-windows-$env:ZIG_VERSION.zip"
386+
$tmpdir = Join-Path $env:RUNNER_TEMP ("zig-" + [System.Guid]::NewGuid().ToString())
387+
New-Item -ItemType Directory -Path $tmpdir | Out-Null
388+
$url = "https://ziglang.org/download/$env:ZIG_VERSION/$archive"
389+
Invoke-WebRequest -Uri $url -OutFile (Join-Path $tmpdir $archive)
390+
Expand-Archive -Path (Join-Path $tmpdir $archive) -DestinationPath $tmpdir
391+
$zigDir = Join-Path $tmpdir ("zig-x86_64-windows-" + $env:ZIG_VERSION)
392+
$zigDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
393+
& (Join-Path $zigDir "zig.exe") version
394+
395+
- name: Zig fmt
396+
shell: bash
397+
run: zig fmt --check $(git ls-files '*.zig')
398+
399+
- name: Zig build
400+
shell: bash
401+
run: zig build
402+
403+
- name: Cross compile Windows AArch64 core
404+
shell: bash
405+
run: zig build -Dtarget=aarch64-windows-gnu
406+
407+
- name: Resolve Zydis bridge path
408+
shell: bash
409+
run: echo "ZYDIS_BRIDGE_C=$(./scripts/zydis-package-path.sh bridge-c)" >> "$GITHUB_ENV"
410+
411+
- name: Example windows_inline_hook_smoke
412+
shell: bash
413+
working-directory: examples/windows_inline_hook_smoke
414+
run: |
415+
set -euo pipefail
416+
zig cc -O3 -DNDEBUG -o target.exe target.c
417+
zig build-lib -dynamic -OReleaseFast -femit-bin=hook.dll \
418+
"$ZYDIS_BRIDGE_C" \
419+
--dep zighook \
420+
-Mroot=hook.zig \
421+
-Mzighook=../../src/root.zig \
422+
-lc
423+
output="$(./target.exe | tr -d '\r')"
424+
test "$output" = "result=42"

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ Current scope:
88
- iOS
99
- Linux
1010
- Android
11+
- Windows
1112
- AArch64 / ARM64
12-
- x86_64 (macOS / Linux)
13+
- x86_64 (macOS / Linux / Windows)
1314

1415
Implemented APIs:
1516

@@ -29,7 +30,7 @@ Implemented APIs:
2930
The current backends support:
3031

3132
- trap-based instrumentation via `brk` (AArch64) or `int3` (x86_64)
32-
- signal-based entry hooks on AArch64 and x86_64
33+
- signal/exception-based entry hooks on AArch64 and x86_64
3334
- strict execute-original replay for common AArch64 PC-relative instructions
3435
- Zydis-backed x86_64 instruction decoding plus trampoline replay
3536
- public callback access to AArch64 FP/SIMD state (`fpregs.v[i]`, `fpregs.named.v0..v31`, `fpsr`, `fpcr`)
@@ -44,11 +45,13 @@ Implemented AArch64 platform backends:
4445
- `aarch64-ios`
4546
- `aarch64-linux`
4647
- `aarch64-linux-android` at the code/backend level via the Linux-family signal path
48+
- `aarch64-windows` at the code/backend level via the Windows exception path
4749

4850
Implemented x86_64 platform backends:
4951

5052
- `x86_64-macos`
5153
- `x86_64-linux`
54+
- `x86_64-windows`
5255

5356
Verification status:
5457

@@ -58,6 +61,11 @@ Verification status:
5861
- Android AArch64: compiled core/payload objects against a local NDK sysroot
5962
- Linux x86_64: runtime-tested in CI
6063
- macOS x86_64: core library and example payload cross-compiled
64+
- Windows x86_64: core library cross-compiled
65+
- Windows AArch64: core library cross-compiled
66+
67+
Windows support is currently build-validated but not yet covered by a runtime
68+
smoke test.
6169

6270
x86_64 replay coverage:
6371

@@ -89,6 +97,7 @@ Deployment model by platform:
8997
- Linux: runtime patching or prepatched trap sites, usually with `LD_PRELOAD` or `patchelf`
9098
- iOS: recommended prepatched trap sites plus inserted dylib + re-sign
9199
- Android: Linux-family backend plus sidecar `.so`, typically loaded via patched ELF metadata / app packaging
100+
- Windows: explicit sidecar DLL load, or an external injector / launcher that loads the hook DLL before patch-point use
92101

93102
Current execute-original replay whitelist for PC-relative AArch64 instructions:
94103

examples/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ These examples mirror the intent of the Rust `sighook` demos.
55
Current coverage:
66

77
- **AArch64 / ARM64:** macOS / iOS / Linux / Android
8-
- **x86_64:** macOS / Linux with entry hooks and instruction-level replay
8+
- **x86_64:** macOS / Linux with entry hooks and instruction-level replay, plus Windows runtime smoke
99

1010
Each example directory is intentionally a standalone mini-project with exactly:
1111

@@ -23,6 +23,9 @@ The example payloads now auto-select the constructor section:
2323
- Mach-O (`macOS`, `iOS`): `__DATA,__mod_init_func`
2424
- ELF (`Linux`, `Android`): `.init_array`
2525

26+
Windows uses an explicit `LoadLibrary(...)` + exported install function instead
27+
of a constructor-based preload path.
28+
2629
Common build pattern from inside an example directory:
2730

2831
```bash
@@ -66,7 +69,8 @@ Available examples:
6669
- `instrument_no_original`: trap one instruction and replace it, expected output `result=99`
6770
- `instrument_unhook_restore`: install a trap hook, call `unhook`, and confirm restoration, expected output `hooked=123` then `restored=5`
6871
- `prepatched_inline_hook`: use `prepatched.inline_hook(...)` on a binary that already contains `brk`, expected output `result=77`
72+
- `windows_inline_hook_smoke`: explicit Windows DLL load + `inline_hook(...)`, expected output `result=42`
6973

7074
Each example README contains the exact commands and expected output. CI executes
71-
the documented runtime smokes on both macOS and Linux and compares stdout
75+
the documented runtime smokes on macOS, Linux, and Windows and compares stdout
7276
against those values.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# windows_inline_hook_smoke
2+
3+
This example is the minimal native Windows runtime smoke for `zighook`.
4+
5+
Unlike the Mach-O / ELF examples, Windows does not use `DYLD_INSERT_LIBRARIES`
6+
or `LD_PRELOAD`. Instead, the target process loads `hook.dll` explicitly with
7+
`LoadLibraryA`, resolves the exported `zighook_example_install` function, and
8+
lets that function install `zighook.inline_hook(...)` on the exported
9+
`target_add` symbol.
10+
11+
The example is intentionally simple so it validates the essential Windows path:
12+
13+
- executable page patching through `VirtualProtect`
14+
- trap delivery through a vectored exception handler (VEH)
15+
- `CONTEXT` remapping into `zighook.HookContext`
16+
- `inline_hook(...)` return-to-caller behavior
17+
18+
## Build
19+
20+
Build the C target in release mode:
21+
22+
```bash
23+
zig cc -O3 -DNDEBUG -o target.exe target.c
24+
```
25+
26+
Build the Zig hook DLL in release mode:
27+
28+
```bash
29+
(cd ../.. && zig build --fetch)
30+
ZYDIS_BRIDGE_C="$(../../scripts/zydis-package-path.sh bridge-c)"
31+
32+
zig build-lib -dynamic -OReleaseFast -femit-bin=hook.dll \
33+
"$ZYDIS_BRIDGE_C" \
34+
--dep zighook \
35+
-Mroot=hook.zig \
36+
-Mzighook=../../src/root.zig \
37+
-lc
38+
```
39+
40+
## Run
41+
42+
```bash
43+
./target.exe
44+
```
45+
46+
## Expected Output
47+
48+
```text
49+
result=42
50+
```
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const builtin = @import("builtin");
2+
const std = @import("std");
3+
const zighook = @import("zighook");
4+
5+
const windows = std.os.windows;
6+
const kernel32 = windows.kernel32;
7+
8+
comptime {
9+
if (builtin.os.tag != .windows) {
10+
@compileError("windows_inline_hook_smoke only supports Windows targets");
11+
}
12+
switch (builtin.cpu.arch) {
13+
.x86_64, .aarch64 => {},
14+
else => @compileError("windows_inline_hook_smoke only supports x86_64 and AArch64"),
15+
}
16+
}
17+
18+
fn setReturnValue(ctx: *zighook.HookContext, value: u64) void {
19+
switch (builtin.cpu.arch) {
20+
.x86_64 => ctx.regs.named.rax = value,
21+
.aarch64 => ctx.regs.named.x0 = value,
22+
else => unreachable,
23+
}
24+
}
25+
26+
fn onHit(_: u64, ctx: *zighook.HookContext) callconv(.c) void {
27+
setReturnValue(ctx, 42);
28+
}
29+
30+
pub export fn zighook_example_install() callconv(.c) void {
31+
const main_module = kernel32.GetModuleHandleW(null) orelse return;
32+
const symbol = kernel32.GetProcAddress(main_module, "target_add") orelse return;
33+
_ = zighook.inline_hook(@intFromPtr(symbol), onHit) catch {};
34+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#define WIN32_LEAN_AND_MEAN
2+
#include <windows.h>
3+
#include <stdio.h>
4+
5+
__declspec(dllexport) __declspec(noinline) int target_add(int a, int b) {
6+
return a + b;
7+
}
8+
9+
typedef void (__cdecl *hook_install_fn)(void);
10+
11+
int main(void) {
12+
volatile int a = 2;
13+
volatile int b = 3;
14+
15+
HMODULE hook = LoadLibraryA(".\\hook.dll");
16+
if (hook == NULL) {
17+
fprintf(stderr, "load_hook_failed=%lu\n", (unsigned long)GetLastError());
18+
return 1;
19+
}
20+
21+
hook_install_fn install = (hook_install_fn)GetProcAddress(hook, "zighook_example_install");
22+
if (install == NULL) {
23+
fprintf(stderr, "resolve_install_failed=%lu\n", (unsigned long)GetLastError());
24+
return 1;
25+
}
26+
27+
install();
28+
printf("result=%d\n", target_add(a, b));
29+
return 0;
30+
}

src/arch/aarch64/context/root.zig

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,25 @@
22
//!
33
//! Files under `context/` are split by responsibility:
44
//! - `types.zig`: stable public register/context layout
5-
//! - `darwin.zig`: Darwin signal-frame bridge
6-
//! - `linux.zig`: Linux / Android signal-frame bridge
5+
//! - `unix/*`: Darwin and Linux-family signal-frame bridges
6+
//! - `windows.zig`: Windows `CONTEXT` bridge
77
//! - `root.zig`: backend selector and public re-exports
88
//!
99
//! The register layout itself is OS-independent, but the signal-frame bridge is
1010
//! platform-specific:
1111
//! - Darwin (`macOS`, `iOS`) remaps from `mcontext.ss + mcontext.ns`
1212
//! - Linux-family targets (`Linux`, `Android`) remap from `ucontext_t` plus
1313
//! AArch64 extension records stored in the reserved signal-frame area
14+
//! - Windows remaps from the native ARM64 `CONTEXT` record used by VEH / SEH
1415

1516
const builtin = @import("builtin");
1617

1718
const types = @import("types.zig");
1819
const backend = switch (builtin.os.tag) {
19-
.macos, .ios => @import("darwin.zig"),
20-
.linux => @import("linux.zig"),
21-
else => @compileError("AArch64 signal-context remapping is only implemented for Darwin and Linux-family targets."),
20+
.macos, .ios => @import("unix/darwin.zig"),
21+
.linux => @import("unix/linux.zig"),
22+
.windows => @import("windows.zig"),
23+
else => @compileError("AArch64 context remapping is only implemented for Darwin, Linux-family targets, and Windows."),
2224
};
2325

2426
pub const XRegistersNamed = types.XRegistersNamed;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
const std = @import("std");
99

10-
const types = @import("types.zig");
10+
const types = @import("../types.zig");
1111

1212
/// Darwin thread-state type used by the currently supported backend.
1313
const DarwinThreadState = @FieldType(std.c.mcontext_t, "ss");
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
const std = @import("std");
88

9-
const types = @import("types.zig");
9+
const types = @import("../types.zig");
1010

1111
const linux = std.os.linux;
1212
const LinuxMContext = linux.mcontext_t;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//! Windows AArch64 exception-context remapping.
2+
//!
3+
//! Windows ARM64 exposes breakpoint state through the native `CONTEXT` record.
4+
//! This module copies that record into zighook's stable callback-facing
5+
//! `HookContext` layout and writes callback edits back into the Windows record
6+
//! before execution resumes.
7+
8+
const std = @import("std");
9+
10+
const types = @import("types.zig");
11+
12+
const windows = std.os.windows;
13+
14+
fn readU128(bytes: []const u8) u128 {
15+
return std.mem.readInt(u128, bytes[0..16], .little);
16+
}
17+
18+
fn writeU128(bytes: []u8, value: u128) void {
19+
std.mem.writeInt(u128, bytes[0..16], value, .little);
20+
}
21+
22+
fn captureFromContext(context: *const windows.CONTEXT) types.HookContext {
23+
var ctx = std.mem.zeroes(types.HookContext);
24+
25+
for (context.DUMMYUNIONNAME.X, 0..) |reg, index| {
26+
ctx.regs.x[index] = reg;
27+
}
28+
ctx.sp = context.Sp;
29+
ctx.pc = context.Pc;
30+
ctx.cpsr = context.Cpsr;
31+
ctx.pad = 0;
32+
33+
for (context.V, 0..) |vreg, index| {
34+
ctx.fpregs.v[index] = readU128(std.mem.asBytes(&vreg));
35+
}
36+
ctx.fpsr = context.Fpsr;
37+
ctx.fpcr = context.Fpcr;
38+
39+
return ctx;
40+
}
41+
42+
fn writeBackToContext(context: *windows.CONTEXT, ctx: *const types.HookContext) void {
43+
for (ctx.regs.x, 0..) |reg, index| {
44+
context.DUMMYUNIONNAME.X[index] = reg;
45+
}
46+
context.Sp = ctx.sp;
47+
context.Pc = ctx.pc;
48+
context.Cpsr = ctx.cpsr;
49+
50+
for (ctx.fpregs.v, 0..) |vreg, index| {
51+
writeU128(std.mem.asBytes(&context.V[index]), vreg);
52+
}
53+
context.Fpsr = ctx.fpsr;
54+
context.Fpcr = ctx.fpcr;
55+
}
56+
57+
pub fn captureMachineContext(context_opaque: ?*anyopaque) ?types.HookContext {
58+
if (context_opaque == null) return null;
59+
60+
const context: *align(1) const windows.CONTEXT = @ptrCast(context_opaque.?);
61+
return captureFromContext(@alignCast(context));
62+
}
63+
64+
pub fn writeBackMachineContext(context_opaque: ?*anyopaque, ctx: *const types.HookContext) bool {
65+
if (context_opaque == null) return false;
66+
67+
const context: *align(1) windows.CONTEXT = @ptrCast(context_opaque.?);
68+
writeBackToContext(@alignCast(context), ctx);
69+
return true;
70+
}
71+
72+
comptime {
73+
std.debug.assert(@sizeOf(windows.NEON128) == 16);
74+
}

0 commit comments

Comments
 (0)