11 KiB
Dyncvoke_rs
A Rust crate for indirect syscalls and dynamic invocation on Windows. The point is calling NT functions without leaving import-table traces and without tripping inline hooks on ntdll exports.
Overview & Innner Workings
The main attraction is the syscall path. Every Nt stub has its system call number sitting right there in the prologue bytes, so the resolver reads it directly. That gets you Hell's Gate on an unhooked system. When an EDR has planted an inline hook at the stub entry (usually a JMP rel32), the resolver walks neighboring stubs in 32 byte strides and works out the original SSN from the offset distance. That covers Halo's Gate. The same neighbor walk handles the case where the hook lands three bytes in, just after mov r10, rcx. That's Tartarus Gate proper. Search caps at 255 iterations and returns a typed error if nothing pans out. No infinite spin.
Once the resolver hands back an (ssn, syscall_addr) pair, dispatch goes through a single variadic function called do_syscall. It places arguments in the registers the syscall ABI wants, copies any extras from the caller's stack into the kernel's expected slots with rep movsq, and jumps to the real syscall instruction inside ntdll. Stack walkers see the syscall coming from inside ntdll, which is what they expect to see. There's no shellcode page allocated per call, no protection flips, no fixed argument count.
The rest of the crate covers the usual supporting tools. You get dynamic API resolution by walking the PEB and reading the export table directly, so no GetProcAddress. There's manual PE mapping with relocations, IAT rewriting, per section permissions, and API set resolution for the api-* and ext-* virtual DLLs on Windows 10 and later. The PE mapper is hardened against the usual malformed input games like a missing relocation directory or a zero size relocation block. There's section overloading, where you load a real signed System32 DLL into a file backed section and then write your payload over it. There's a module fluctuation manager that keeps a payload mapped only when you're actively calling it and swaps in a decoy the rest of the time. There's shellcode stomping into a loaded module's .text section, template stomping that generates a decoy DLL with neutered entry points before injecting a payload, and a syscall parameter spoofing path that uses hardware breakpoints plus a vectored exception handler to show the EDR boring arguments at the function entry and the kernel real arguments at the syscall instruction. TLS callbacks during manual mapping are supported if you need them.
Layout
dyncvoke_core sys module, dynamic invocation, nt_* wrappers
dyncvoke_core::sys Tartarus Gate plus the variadic do_syscall gateway
manualmap PE mapping with relocations and IAT rewriting
overload section overloading, module stomping, template stomping
dmanager the fluctuation manager
spoof call stack spoofing, synthetic and desync modes
data shared types and FFI signatures
This is still moving. Test before shipping anything you care about.
Adding it
[dependencies]
dyncvoke = { git = "https://codeberg.org/smukx/Dyncvoke.git" }
Or from a local checkout:
[dependencies]
dyncvoke = { path = "path/to/Dyncvoke" }
Core only:
[dependencies]
dyncvoke_core = { path = "path/to/Dyncvoke/dyncvoke_core" }
Features:
syscall(default): indirect syscall path. Pulls indyncvoke_core.manualmap: PE manual mapping. Impliessyscall.overload: section overloading and module stomping. Impliesmanualmap.dmanager: module fluctuation manager. Impliesoverload.spoof: call stack spoofing, synthetic mode.spoof-desync: spoof with the desync mode toggled on.full: everything above.
Picking a non-default set looks like this:
[dependencies]
dyncvoke = { git = "https://codeberg.org/smukx/Dyncvoke.git", default-features = false, features = ["syscall", "spoof", "spoof-desync"] }
x86_64 only.
Using it
Resolving exports
use dyncvoke::dyncvoke_core;
fn main() {
let ntdll = dyncvoke_core::get_module_base_address("ntdll.dll");
if ntdll == 0 { return; }
let nt_create_thread = dyncvoke_core::get_function_address(ntdll, "NtCreateThread");
println!("NtCreateThread @ 0x{:X}", nt_create_thread);
// By ordinal works too.
let ord_8 = dyncvoke_core::get_function_address_by_ordinal(ntdll, 8);
println!("ord 8 @ 0x{:X}", ord_8);
}
Indirect syscalls
The syscall! macro handles resolution and dispatch in one shot. It returns whatever NTSTATUS the kernel gave back, wrapped in a Result.
use dyncvoke::dyncvoke_core::syscall;
use std::ffi::c_void;
use std::ptr::null_mut;
fn main() {
let mut addr: *mut c_void = null_mut();
let mut size: usize = 0x1000;
let status = syscall!(
"NtAllocateVirtualMemory",
-1isize as *mut c_void, // NtCurrentProcess pseudo-handle
&mut addr as *mut *mut c_void,
0usize,
&mut size as *mut usize,
0x3000u32, // MEM_COMMIT | MEM_RESERVE
0x04u32 // PAGE_READWRITE
)
.expect("could not resolve NtAllocateVirtualMemory");
if status == 0 {
println!("0x{:X} bytes at {:p}", size, addr);
} else {
println!("NTSTATUS = 0x{:08X}", status as u32);
}
}
The return type is Result<i32, SyscallError>. The Err half covers the cases where the function or its SSN couldn't be found, in which case the syscall never ran. Ok(status) means the kernel actually executed your syscall and that i32 is the NTSTATUS it returned. Zero is success.
If you call the same syscall a lot, resolve it once and dispatch with do_syscall!. That skips the name lookup on every call.
use dyncvoke::dyncvoke_core::{do_syscall, resolve_syscall};
fn main() {
let (ssn, addr) = resolve_syscall("NtClose").expect("resolve");
let status = do_syscall!(ssn, addr, some_handle);
}
If you're curious what's going on under the macro, it's four steps. The resolver walks ntdll's exports to find the stub, then reads the SSN from stub[4..6]. If the first byte is E9 then the stub has been hooked at the entry, and a neighbor walk over 32 byte strides recovers the original SSN from the offset distance. If E9 is at offset 3, same walk handles it. Then do_syscall puts everything in the right registers, copies any extra arguments down the stack with rep movsq, and jumps to the syscall instruction inside ntdll itself.
Manual mapping
Map a clean ntdll without any of the hooks the in process copy has:
use dyncvoke::manualmap;
use dyncvoke::data::PeMetadata;
fn main() {
let ntdll: (PeMetadata, usize) = manualmap::read_and_map_module(
r"C:\Windows\System32\ntdll.dll",
true, // scrub the DOS header
false // skip TLS callbacks
).unwrap();
}
Section overloading
Load a signed System32 DLL into a file backed section, then write your payload over it:
use dyncvoke::overload;
fn main() {
let payload = your_download_function();
let result = overload::overload_module(&payload, "").unwrap();
// Empty string picks a decoy automatically.
}
Module fluctuation
Keeps a payload mapped when you call it and a decoy mapped the rest of the time:
use dyncvoke::{overload, dmanager::Manager};
fn main() {
let mut manager = Manager::new();
let m = overload::managed_read_and_overload(
r"c:\windows\system32\payload.dll",
r"c:\windows\system32\cdp.dll"
).unwrap();
manager.new_module(m.1, m.0.0, m.0.1).unwrap();
manager.map_module(m.1).unwrap();
// ... call into the payload ...
manager.hide_module(m.1).unwrap();
}
Syscall parameter spoofing
This trick uses hardware breakpoints plus a vectored exception handler. EDR hooks at the function entry see the boring values you passed in. At the actual syscall instruction the handler swaps in the real arguments. It lives on the nt_* wrapper path, separate from the Hell's Hall gateway.
use dyncvoke::dyncvoke_core::{
use_hardware_breakpoints, add_vectored_exception_handler, nt_open_process, breakpoint_handler,
};
use dyncvoke::data::{HANDLE, OBJECT_ATTRIBUTES, ClientId};
fn main() {
unsafe {
use_hardware_breakpoints(true);
add_vectored_exception_handler(1, breakpoint_handler as usize);
let mut handle: HANDLE = std::ptr::null_mut();
let attrs = OBJECT_ATTRIBUTES::default();
let client_id = ClientId {
unique_process: target_pid as HANDLE,
unique_thread: std::ptr::null_mut(),
};
let _ = nt_open_process(
&mut handle,
0x1F03FF,
&attrs as *const _ as *mut _,
&client_id as *const _ as *mut _,
);
use_hardware_breakpoints(false);
}
}
x64 only.
Call stack spoofing (spoof crate)
Enable the spoof feature for synthetic mode, or spoof-desync for desync mode. Synthetic mode builds a fake stack rooted at RtlUserThreadStart and runs from any thread, including pool threads. Desync mode finds a real BaseThreadInitThunk return address on the current thread's stack and splices spoofed frames on top. Use desync only on threads started through the normal user-thread chain.
The spoof_syscall! macro spoofs the call stack and dispatches an indirect syscall in one step. SSN resolution runs through the same Tartarus Gate path as dyncvoke_core::syscall!.
use spoof::spoof_syscall;
use core::ffi::c_void;
fn main() {
let mut addr: *mut c_void = core::ptr::null_mut();
let mut size: usize = 0x1000;
let status = spoof_syscall!(
"NtAllocateVirtualMemory",
-1isize, // NtCurrentProcess
&mut addr as *mut _,
0usize,
&mut size as *mut _,
0x3000u32, // MEM_COMMIT | MEM_RESERVE
0x04u32 // PAGE_READWRITE
).map(|p| p as i32).unwrap_or(-1);
println!("NTSTATUS = 0x{:08X}", status as u32);
}
For non-ntdll calls, resolve the address with dyncvoke_core and dispatch through spoof::spoof! instead.
use dyncvoke::dyncvoke_core;
use spoof::spoof;
fn main() {
let kernel32 = dyncvoke_core::get_module_base_address("kernel32.dll");
let virtual_alloc = dyncvoke_core::get_function_address(kernel32, "VirtualAlloc");
let p = unsafe {
spoof!(
virtual_alloc,
core::ptr::null_mut::<core::ffi::c_void>(),
1 << 12,
0x3000u32,
0x04u32
)
};
}