Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Batin

Rust — Windows x64 and ARM64 — works on Windows 10 and 11 with Defender enabled

Batin (باطن) — the hidden inner reality, opposite of Zahir (ظاهر), the visible surface.

EDRs instrument the Zahir: the exported stubs of ntdll.dll. Batin operates beneath them — resolving syscall numbers at runtime, executing from inside ntdll's own memory, and leaving no anonymous executable allocation in the process.

Written in Rust. No C runtime. One external crate.

Fourth in a series:

  • PeekABoo — Process Ghosting (C++)
  • Schrodinger — Process Doppelgänging (C++)
  • Dorian — Process Herpaderping (C++)
  • Batin — Syscall evasion stack (Rust)

For research and authorized testing only.


Techniques

Hell's Gate — SSN resolution

Every NT function in ntdll starts with the same pattern on an unhooked system:

x64:

4C 8B D1        mov r10, rcx
B8 XX 00 00 00  mov eax, <SSN>
0F 05           syscall
C3              ret

ARM64:

movz x8, #<SSN>   encoding: (instr & 0xFFE0001F) == 0xD2800008
svc  #0
ret

Batin walks ntdll's export table, finds each function by name, and reads the syscall number directly from its bytes in memory. No GetProcAddress. No hooked stubs.

Halo's Gate — hooked stub fallback

When an EDR hooks a function, it overwrites the first instruction with a jump (E9 on x64, B/BL on ARM64). The SSN is gone. NT stubs are sorted in the EAT, and their SSNs are sequential — so Batin scans neighboring functions until it finds a clean one and calculates the target by delta:

neighbor_ssn ± delta = target_ssn

Indirect syscalls

Some kernel-level EDRs check the return address on the kernel stack after a syscall. If it points outside ntdll's VA range, it's flagged.

The resolver scans the first clean NT stub it decodes, finds the syscall instruction inside it (0F 05 on x64, 0xD4000001 on ARM64), and stores that address in SYSCALL_ADDR. The gate jmps there instead of executing its own syscall. The actual kernel transition happens from inside ntdll — the kernel sees a legitimate origin.

x64 (src/gate.rs)

hell_descent:
    mov r10, rcx
    mov eax, dword ptr [rip + SSN]
    jmp qword ptr [rip + SYSCALL_ADDR]   ; lands inside ntdll's syscall; ret

ARM64

hell_descent:
    adrp x9, SSN
    ldr  w8, [x9, :lo12:SSN]
    adrp x9, SYSCALL_ADDR
    ldr  x9, [x9, :lo12:SYSCALL_ADDR]
    br   x9                              ; branches into ntdll's svc #0; ret

ETW patching

EtwEventWrite in ntdll is overwritten with xor eax, eax; ret. Userland ETW calls return immediately without logging. Kernel ETW (EtwTi) is unaffected.

AMSI patching

AmsiScanBuffer in amsi.dll is overwritten the same way. Returns 0 (AMSI_RESULT_CLEAN) for every scan. Both patches use NtProtectVirtualMemory through the syscall gate — no VirtualProtect call in the import table.

Module stomping

Instead of allocating anonymous RW→RX memory (a known detection pattern), Batin writes shellcode into the .text section of an already-mapped DLL (win32u.dllcryptbase.dllsspicli.dll). The original bytes are saved before and restored after execution. No private executable allocation ever appears.

Sleep obfuscation

After shellcode is written into the DLL but before the thread launches, Batin encrypts the region with ChaCha20, flips it to PAGE_NOACCESS, sleeps via NtDelayExecution, then decrypts and restores PAGE_EXECUTE_READ. A memory scanner polling during the sleep window finds inaccessible ciphertext.

ChaCha20 payload encryption

Shellcode can be stored encrypted on disk. gen_shellcode.py encrypt produces a file with a BATIN\0 header + random nonce + key + ciphertext. Batin detects the header at load time and decrypts in memory before execution. Raw shellcode still works unchanged.

Remote injection

Opens a target process with NtOpenProcess, allocates RW memory there, writes shellcode, flips to RX, spawns a remote thread with NtCreateThreadEx.

PPID spoofing

Creates a new suspended process with a chosen parent PID via PROC_THREAD_ATTRIBUTE_PARENT_PROCESS. The process tree shows the specified parent (e.g. explorer.exe) as the creator. Then injects shellcode into the new process and resumes it. All attribute-list functions are loaded dynamically from kernel32 — they don't appear in Batin's import table.

APC injection

Allocates shellcode in the target process, then enumerates all its threads via CreateToolhelp32Snapshot and queues a user-mode APC to each one with NtQueueApcThread. No NtCreateThreadEx call — the APC fires when any thread enters an alertable wait (SleepEx, WaitForSingleObjectEx with bAlertable=TRUE, etc.).


What it looks like

flowchart TD
    A["resolve_all — walk ntdll EAT"]
    B{"stub hooked?"}
    C["Hell's Gate — read SSN"]
    D["Halo's Gate — infer from neighbors"]
    G["store syscall gadget → SYSCALL_ADDR"]
    E1["ETW patch — EtwEventWrite → ret"]
    E2["AMSI patch — AmsiScanBuffer → ret"]
    L["load shellcode\ndecrypt if BATIN header present"]

    M1["self: module stomp\n+ sleep mask"]
    M2["remote: NtCreateThreadEx"]
    M3["remote: APC queue"]
    M4["spawn: PPID spoof\n+ inject"]
    Z["✅ executing"]

    A --> B
    B -->|no| C --> G --> E1 --> E2 --> L
    B -->|yes| D --> G
    L --> M1 & M2 & M3 & M4
    M1 & M2 & M3 & M4 --> Z

    style D fill:#2980b9,color:#fff
    style G fill:#8e44ad,color:#fff
    style E1 fill:#c0392b,color:#fff
    style E2 fill:#c0392b,color:#fff
    style Z fill:#27ae60,color:#fff
Loading

Project structure

src/
├── main.rs        CLI — arg parsing, mode dispatch
├── gate.rs        indirect syscall gate (x64 + ARM64)
├── resolver.rs    Hell's Gate + Halo's Gate + gadget scanner
├── chacha20.rs    RFC 7539 ChaCha20 stream cipher (no external crate)
├── etw.rs         EtwEventWrite patch
├── amsi.rs        AmsiScanBuffer patch
├── stomper.rs     module stomping — shellcode into DLL .text
├── sleep_mask.rs  encrypt + PAGE_NOACCESS during sleep
├── runner.rs      fallback — anonymous alloc injection
├── injector.rs    remote thread injection
├── spawner.rs     PPID spoofing + suspended process creation
└── apc.rs         APC injection via NtQueueApcThread

tools/
└── gen_shellcode.py   smoke-test payloads + ChaCha20 encrypt + XOR encode

Building

# x64
cargo build --release --target x86_64-pc-windows-msvc

# ARM64
cargo build --release --target aarch64-pc-windows-msvc

First time:

rustup target add x86_64-pc-windows-msvc
rustup target add aarch64-pc-windows-msvc

Output: target/<target>/release/batin.exe


Usage

rem self-injection (module stomping + sleep mask)
batin.exe shellcode.bin

rem remote thread injection into an existing process
batin.exe shellcode.bin --pid 1234

rem APC injection — no new thread created
batin.exe shellcode.bin --pid 1234 --apc

rem spawn a process with spoofed parent PID, then inject
batin.exe shellcode.bin --spawn C:\Windows\System32\notepad.exe --ppid 4321

Prepare shellcode

rem generate with msfvenom
msfvenom -p windows/x64/messagebox TEXT="Batin" TITLE="الباطن" -f raw -o raw.bin

rem encrypt for transport (recommended)
python tools/gen_shellcode.py encrypt raw.bin -o shellcode.bin

rem smoke-test payload (does nothing harmful, just returns)
python tools/gen_shellcode.py smoketest --arch x64 -o shellcode.bin

rem entropy check
python tools/gen_shellcode.py stats shellcode.bin

What it doesn't bypass

Batin handles userland detection. Kernel-mode telemetry is a different layer.

  • ETW Threat Intelligence (EtwTi) — kernel-mode ETW that fires on memory allocation and thread creation regardless of how the syscall was made. Batin patches userland ETW only.
  • Kernel callbacksPsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine. The kernel sees thread creation regardless of injection method.
  • Full call stack correlation — EDRs that correlate the entire kernel stack, not just the syscall return address.
  • Memory scanning during execution — the sleep mask protects the region while sleeping, not while executing.
  • CFG / ACG — Control Flow Guard and Arbitrary Code Guard enforce additional constraints on some processes.

Dependency

One crate: windows-sys — Microsoft's zero-overhead Windows API bindings. No allocator, no CRT.

About

Windows syscall evasion stack in Rust — Hell's Gate, Halo's Gate, indirect syscalls, ETW/AMSI patching, module stomping, sleep obfuscation, ChaCha20 encryption, remote injection, PPID spoofing, and APC injection. No CRT. One crate.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages