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.
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.
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
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.
hell_descent:
mov r10, rcx
mov eax, dword ptr [rip + SSN]
jmp qword ptr [rip + SYSCALL_ADDR] ; lands inside ntdll's syscall; rethell_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; retEtwEventWrite in ntdll is overwritten with xor eax, eax; ret. Userland ETW calls return immediately without logging. Kernel ETW (EtwTi) is unaffected.
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.
Instead of allocating anonymous RW→RX memory (a known detection pattern), Batin writes shellcode into the .text section of an already-mapped DLL (win32u.dll → cryptbase.dll → sspicli.dll). The original bytes are saved before and restored after execution. No private executable allocation ever appears.
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.
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.
Opens a target process with NtOpenProcess, allocates RW memory there, writes shellcode, flips to RX, spawns a remote thread with NtCreateThreadEx.
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.
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.).
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
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
# x64
cargo build --release --target x86_64-pc-windows-msvc
# ARM64
cargo build --release --target aarch64-pc-windows-msvcFirst time:
rustup target add x86_64-pc-windows-msvc
rustup target add aarch64-pc-windows-msvcOutput: target/<target>/release/batin.exe
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 4321rem 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.binBatin 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 callbacks —
PsSetCreateThreadNotifyRoutine,PsSetLoadImageNotifyRoutine. The kernel sees thread creation regardless of injection method. - Full call stack correlation — EDRs that correlate the entire kernel stack, not just the
syscallreturn 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.
One crate: windows-sys — Microsoft's zero-overhead Windows API bindings. No allocator, no CRT.