diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b648fb..a3ed15d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,12 @@ jobs: version: 0.16.0 - name: Build - run: ./build.sh --zig-targets "${{ matrix.zig-target }}" --no-dotnet + run: >- + zig build + -Dtarget=${{ matrix.zig-target }} + -Doptimize=ReleaseFast + -Dinjector-optimize=ReleaseSmall + --prefix bin - name: Build payload run: dotnet build Hauyne.Payload -c Release -p:TargetFramework=net${{ matrix.dotnet }} --nologo diff --git a/Hauyne.Bootstrap/build.zig b/Hauyne.Bootstrap/build.zig deleted file mode 100644 index 5909b62..0000000 --- a/Hauyne.Bootstrap/build.zig +++ /dev/null @@ -1,36 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. -// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. -// -// This Source Code Form is "Incompatible With Secondary Licenses", as defined by the -// Mozilla Public License, v. 2.0. - -const std = @import("std"); - -pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); - - const mod = b.createModule(.{ - .root_source_file = b.path("bootstrap.zig"), - .target = target, - .optimize = optimize, - }); - - mod.link_libc = true; - - if (target.result.os.tag != .windows) { - mod.linkSystemLibrary("dl", .{}); - mod.linkSystemLibrary("pthread", .{}); - } - - const lib = b.addLibrary(.{ - .name = "Hauyne.Bootstrap", - .root_module = mod, - .linkage = .dynamic, - }); - - const install_step = b.addInstallArtifact(lib, .{ - .dest_dir = .{ .override = .{ .custom = "../../bin" } }, - }); - b.getInstallStep().dependOn(&install_step.step); -} diff --git a/Hauyne.Injector/build.zig b/Hauyne.Injector/build.zig deleted file mode 100644 index fa082fa..0000000 --- a/Hauyne.Injector/build.zig +++ /dev/null @@ -1,34 +0,0 @@ -// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. -// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. -// -// This Source Code Form is "Incompatible With Secondary Licenses", as defined by the -// Mozilla Public License, v. 2.0. - -const std = @import("std"); - -pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); - - const mod = b.createModule(.{ - .root_source_file = b.path("injector.zig"), - .target = target, - .optimize = optimize, - }); - - mod.link_libc = true; - - if (target.result.os.tag != .windows) { - mod.linkSystemLibrary("dl", .{}); - } - - const exe = b.addExecutable(.{ - .name = "Hauyne.Injector", - .root_module = mod, - }); - - const install_step = b.addInstallArtifact(exe, .{ - .dest_dir = .{ .override = .{ .custom = "../../bin" } }, - }); - b.getInstallStep().dependOn(&install_step.step); -} diff --git a/Hauyne.Injector/injector.zig b/Hauyne.Injector/injector.zig index 0d50646..22904cb 100644 --- a/Hauyne.Injector/injector.zig +++ b/Hauyne.Injector/injector.zig @@ -9,8 +9,6 @@ const builtin = @import("builtin"); const is_windows = builtin.os.tag == .windows; -const max_matches = 64; - fn println(allocator: std.mem.Allocator, comptime fmt: []const u8, args: anytype) void { const msg = std.fmt.allocPrint(allocator, fmt, args) catch return; defer allocator.free(msg); @@ -23,7 +21,11 @@ pub fn main(init: std.process.Init) u8 { const args = init.minimal.args.toSlice(allocator) catch return 1; - if (args.len < 2 or std.mem.eql(u8, args[1], "--help") or std.mem.eql(u8, args[1], "-h")) { + const wants_help = for (args) |a| { + if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break true; + } else false; + + if (args.len < 2 or wants_help) { const usage = \\Usage: {s} [payload-path] [options] \\ @@ -36,7 +38,7 @@ pub fn main(init: std.process.Init) u8 { \\Options: \\ --type Fully qualified type name (default: Hauyne.Payload.Entrypoint, Hauyne.Payload) \\ --method Entry method name (default: Initialize) - \\ -h, --help Hello + \\ -h, --help Show this help \\ ; println(allocator, usage, .{args[0]}); @@ -65,6 +67,9 @@ pub fn main(init: std.process.Init) u8 { return 1; } method_name = args[i]; + } else if (std.mem.startsWith(u8, a, "--")) { + std.debug.print("Unknown option: {s}\n", .{a}); + return 1; } else if (payload_path == null) { payload_path = a; } else { @@ -144,11 +149,9 @@ fn resolveTarget(io: std.Io, allocator: std.mem.Allocator, spec: []const u8) !u3 return pid; } else |_| {} - var matches: [max_matches]u32 = undefined; - var n: usize = 0; - try collectMatches(io, spec, &matches, &n, &inaccessible); + const matches = try collectMatches(io, allocator, spec, &inaccessible); - if (n == 0) { + if (matches.len == 0) { if (inaccessible > 0) { std.debug.print("No process matches '{s}' ({d} process(es) unreadable — try root or ptrace_scope=0)\n", .{ spec, inaccessible }); } else { @@ -157,10 +160,8 @@ fn resolveTarget(io: std.Io, allocator: std.mem.Allocator, spec: []const u8) !u3 return error.NotFound; } - // Compact .NET-valid PIDs over the front of `matches`. If vn == 0 no writes - // happen and matches[0..n] stays intact for the "none loaded hostfxr" list. var vn: usize = 0; - for (matches[0..n]) |pid| { + for (matches) |pid| { if (isDotNetProcess(io, allocator, pid, &inaccessible) catch false) { matches[vn] = pid; vn += 1; @@ -168,8 +169,8 @@ fn resolveTarget(io: std.Io, allocator: std.mem.Allocator, spec: []const u8) !u3 } if (vn == 0) { - std.debug.print("'{s}' matched {d} process(es) but none loaded hostfxr: ", .{ spec, n }); - printPidList(matches[0..n]); + std.debug.print("'{s}' matched {d} process(es) but none loaded hostfxr: ", .{ spec, matches.len }); + printPidList(matches); return error.NoDotNetMatch; } if (vn > 1) { @@ -188,12 +189,13 @@ fn printPidList(pids: []const u32) void { std.debug.print("\n", .{}); } -fn collectMatches(io: std.Io, name: []const u8, out: []u32, count: *usize, inaccessible: *usize) !void { - if (is_windows) return collectMatchesWindows(name, out, count); - return collectMatchesLinux(io, name, out, count, inaccessible); +fn collectMatches(io: std.Io, allocator: std.mem.Allocator, name: []const u8, inaccessible: *usize) ![]u32 { + if (is_windows) return collectMatchesWindows(allocator, name); + return collectMatchesLinux(io, allocator, name, inaccessible); } -fn collectMatchesLinux(io: std.Io, name: []const u8, out: []u32, count: *usize, inaccessible: *usize) !void { +fn collectMatchesLinux(io: std.Io, allocator: std.mem.Allocator, name: []const u8, inaccessible: *usize) ![]u32 { + var matches: std.ArrayList(u32) = .empty; const self_pid: u32 = @intCast(std.posix.system.getpid()); var proc_dir = try std.Io.Dir.openDirAbsolute(io, "/proc", .{ .iterate = true }); @@ -207,11 +209,10 @@ fn collectMatchesLinux(io: std.Io, name: []const u8, out: []u32, count: *usize, if (pid == self_pid) continue; if (pidMatchesName(io, pid, name, inaccessible)) { - if (count.* >= out.len) return; - out[count.*] = pid; - count.* += 1; + try matches.append(allocator, pid); } } + return matches.items; } fn pidMatchesName(io: std.Io, pid: u32, name: []const u8, inaccessible: *usize) bool { @@ -257,7 +258,8 @@ fn nameMatches(candidate: []const u8, name: []const u8) bool { return false; } -fn collectMatchesWindows(name: []const u8, out: []u32, count: *usize) !void { +fn collectMatchesWindows(allocator: std.mem.Allocator, name: []const u8) ![]u32 { + var matches: std.ArrayList(u32) = .empty; const windows = std.os.windows; const TH32CS_SNAPPROCESS: windows.DWORD = 0x00000002; @@ -301,7 +303,7 @@ fn collectMatchesWindows(name: []const u8, out: []u32, count: *usize) !void { .library_name = "kernel32", }); - if (Process32FirstW(snapshot, &entry) == .FALSE) return; + if (Process32FirstW(snapshot, &entry) == .FALSE) return matches.items; while (true) { if (entry.th32ProcessID == self_pid) { @@ -320,13 +322,12 @@ fn collectMatchesWindows(name: []const u8, out: []u32, count: *usize) !void { exe_name; if (std.ascii.eqlIgnoreCase(stem, name) or std.ascii.eqlIgnoreCase(exe_name, name)) { - if (count.* >= out.len) return; - out[count.*] = entry.th32ProcessID; - count.* += 1; + try matches.append(allocator, entry.th32ProcessID); } if (Process32NextW(snapshot, &entry) == .FALSE) break; } + return matches.items; } fn isDotNetProcess(io: std.Io, allocator: std.mem.Allocator, pid: u32, inaccessible: *usize) !bool { diff --git a/Hauyne.Injector/linux/arch.zig b/Hauyne.Injector/linux/arch.zig new file mode 100644 index 0000000..dac149c --- /dev/null +++ b/Hauyne.Injector/linux/arch.zig @@ -0,0 +1,19 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// This Source Code Form is "Incompatible With Secondary Licenses", as defined by the +// Mozilla Public License, v. 2.0. + +const builtin = @import("builtin"); + +pub const cpu = switch (builtin.cpu.arch) { + .x86_64 => @import("arch/x86_64.zig"), + .aarch64 => @import("arch/aarch64.zig"), + else => @compileError("unsupported architecture"), +}; + +pub const emitter = switch (builtin.cpu.arch) { + .x86_64 => @import("shim/x86_64.zig"), + .aarch64 => @import("shim/aarch64.zig"), + else => @compileError("unsupported architecture"), +}; diff --git a/Hauyne.Injector/linux/linux.zig b/Hauyne.Injector/linux/linux.zig index ebac7f3..e0f3533 100644 --- a/Hauyne.Injector/linux/linux.zig +++ b/Hauyne.Injector/linux/linux.zig @@ -12,11 +12,7 @@ const shim = @import("shim.zig"); const symbols = @import("symbols.zig"); const victim_mod = @import("victim.zig"); -const arch = switch (builtin.cpu.arch) { - .x86_64 => @import("arch/x86_64.zig"), - .aarch64 => @import("arch/aarch64.zig"), - else => @compileError("unsupported architecture"), -}; +const arch = @import("arch.zig").cpu; const UserRegsStruct = ptrace_mod.UserRegsStruct; @@ -114,10 +110,11 @@ pub fn inject( const victim = try victim_mod.pickVictimThread(io, allocator, tgid); - const dlopen_addr = try symbols.findSymbolInTarget(io, allocator, tgid, "dlopen"); - const dlsym_addr = try symbols.findSymbolInTarget(io, allocator, tgid, "dlsym"); - const pthread_create_addr = try symbols.findSymbolInTarget(io, allocator, tgid, "pthread_create"); - const pthread_detach_addr = try symbols.findSymbolInTarget(io, allocator, tgid, "pthread_detach"); + const sym_addrs = try symbols.findSymbolsInTarget(io, allocator, tgid, &.{ "dlopen", "dlsym", "pthread_create", "pthread_detach" }); + const dlopen_addr = sym_addrs[0]; + const dlsym_addr = sym_addrs[1]; + const pthread_create_addr = sym_addrs[2]; + const pthread_detach_addr = sym_addrs[3]; std.debug.print("[hauyne] victim tid={d} (tgid={d})\n", .{ victim, tgid }); if (debug) { diff --git a/Hauyne.Injector/linux/procfs.zig b/Hauyne.Injector/linux/procfs.zig index 05d0c60..03c4356 100644 --- a/Hauyne.Injector/linux/procfs.zig +++ b/Hauyne.Injector/linux/procfs.zig @@ -6,6 +6,7 @@ const std = @import("std"); +// /proc pseudo-files report st_size=0, so std.Io readFileAlloc returns empty. pub fn readFileAlloc(allocator: std.mem.Allocator, path: []const u8) ![]u8 { const fd = try std.posix.openat(std.posix.AT.FDCWD, path, .{ .ACCMODE = .RDONLY }, 0); defer _ = std.c.close(fd); diff --git a/Hauyne.Injector/linux/ptrace.zig b/Hauyne.Injector/linux/ptrace.zig index 68a58ee..96cefce 100644 --- a/Hauyne.Injector/linux/ptrace.zig +++ b/Hauyne.Injector/linux/ptrace.zig @@ -7,11 +7,7 @@ const std = @import("std"); const builtin = @import("builtin"); -const arch = switch (builtin.cpu.arch) { - .x86_64 => @import("arch/x86_64.zig"), - .aarch64 => @import("arch/aarch64.zig"), - else => @compileError("unsupported architecture"), -}; +const arch = @import("arch.zig").cpu; pub const UserRegsStruct = arch.UserRegsStruct; diff --git a/Hauyne.Injector/linux/shim.zig b/Hauyne.Injector/linux/shim.zig index 8eb6da0..4db32ec 100644 --- a/Hauyne.Injector/linux/shim.zig +++ b/Hauyne.Injector/linux/shim.zig @@ -5,7 +5,6 @@ // Mozilla Public License, v. 2.0. const std = @import("std"); -const builtin = @import("builtin"); pub const ScratchSize: usize = 0x2000; // 8 KiB (two pages) pub const PathOffset: usize = 0x40; // bootstrap .so path (1984) @@ -14,11 +13,7 @@ pub const SymbolOffset: usize = 0x1800; // "hauyne_start\0" (256) pub const VictimShimOff: usize = 0x1900; // pthread_create + pthread_detach (256) pub const PayloadShimOff: usize = 0x1A00; // dlopen + dlsym + hauyne_start (1536) -const arch = switch (builtin.cpu.arch) { - .x86_64 => @import("shim/x86_64.zig"), - .aarch64 => @import("shim/aarch64.zig"), - else => @compileError("unsupported architecture"), -}; +const arch = @import("arch.zig").emitter; pub const InputError = error{ BootstrapPathTooLong, diff --git a/Hauyne.Injector/linux/symbols.zig b/Hauyne.Injector/linux/symbols.zig index b82510d..42ced17 100644 --- a/Hauyne.Injector/linux/symbols.zig +++ b/Hauyne.Injector/linux/symbols.zig @@ -16,7 +16,15 @@ const MapsRow = struct { const exacts = [_][]const u8{ "libc.so.6", "libpthread.so.0", "libdl.so.2" }; const prefix = [_][]const u8{"libc.musl-"}; -pub fn findSymbolInTarget(io: std.Io, allocator: std.mem.Allocator, pid: i32, symbol: []const u8) !usize { +pub fn findSymbolsInTarget( + io: std.Io, + allocator: std.mem.Allocator, + pid: i32, + comptime names: []const []const u8, +) ![names.len]usize { + var results = std.mem.zeroes([names.len]usize); + var found: usize = 0; + const maps_path = try std.fmt.allocPrint(allocator, "/proc/{d}/maps", .{pid}); defer allocator.free(maps_path); @@ -25,6 +33,8 @@ pub fn findSymbolInTarget(io: std.Io, allocator: std.mem.Allocator, pid: i32, sy var lines = std.mem.splitScalar(u8, maps_text, '\n'); while (lines.next()) |line| { + if (found == names.len) break; + const row = parseMapsRow(line) orelse continue; if (!std.mem.eql(u8, row.offset, "00000000")) continue; if (!isLibcCandidate(std.fs.path.basename(row.path))) continue; @@ -32,13 +42,24 @@ pub fn findSymbolInTarget(io: std.Io, allocator: std.mem.Allocator, pid: i32, sy const data = readTargetFile(io, allocator, pid, row.path) catch continue; defer allocator.free(data); - const sym_off = lookupDynsym(data, symbol) catch |err| switch (err) { - error.SymbolNotFound => continue, - else => return err, - }; - return row.start + sym_off; + for (0..names.len) |i| { + if (results[i] != 0) continue; + const sym_off = lookupDynsym(data, names[i]) catch |err| switch (err) { + error.SymbolNotFound => continue, + else => return err, + }; + results[i] = row.start + sym_off; + found += 1; + } } - return error.SymbolNotFound; + + if (found < names.len) return error.SymbolNotFound; + return results; +} + +pub fn findSymbolInTarget(io: std.Io, allocator: std.mem.Allocator, pid: i32, comptime symbol: []const u8) !usize { + const result = try findSymbolsInTarget(io, allocator, pid, &.{symbol}); + return result[0]; } fn isLibcCandidate(basename: []const u8) bool { diff --git a/Hauyne.Injector/linux/victim.zig b/Hauyne.Injector/linux/victim.zig index dacb33e..3888510 100644 --- a/Hauyne.Injector/linux/victim.zig +++ b/Hauyne.Injector/linux/victim.zig @@ -7,11 +7,7 @@ const std = @import("std"); const procfs = @import("procfs.zig"); -const arch = switch (@import("builtin").cpu.arch) { - .x86_64 => @import("arch/x86_64.zig"), - .aarch64 => @import("arch/aarch64.zig"), - else => @compileError("unsupported architecture"), -}; +const arch = @import("arch.zig").cpu; // Falls back to the main thread, but main thread holds EE locks, // and will probably just suicide bomb if hijacked diff --git a/build.sh b/build.sh index ffae620..f4cab12 100755 --- a/build.sh +++ b/build.sh @@ -11,11 +11,8 @@ set -euo pipefail cd "$(dirname "$(readlink -f "$0")")" -REPO_ROOT="$PWD" -BOOTSTRAP_DIR="$REPO_ROOT/Hauyne.Bootstrap" -INJECTOR_DIR="$REPO_ROOT/Hauyne.Injector" -PAYLOAD_CSPROJ="$REPO_ROOT/Hauyne.Payload/Hauyne.Payload.csproj" -BIN_DIR="$REPO_ROOT/bin" +BIN_DIR="$PWD/bin" +PAYLOAD_CSPROJ="$PWD/Hauyne.Payload/Hauyne.Payload.csproj" CONFIG="${CONFIG:-Release}" OPTIMIZE="${OPTIMIZE:-ReleaseFast}" @@ -75,27 +72,13 @@ injector_artifact_for() { } build_zig() { - mkdir -p "$BIN_DIR" - rm -f "$BIN_DIR/libHauyne.Bootstrap.so" "$BIN_DIR/Hauyne.Bootstrap.dll" - rm -f "$BIN_DIR/Hauyne.Injector" "$BIN_DIR/Hauyne.Injector.exe" - for target in "${ZIG_TARGETS[@]}"; do - echo "==> zig bootstrap $target ($OPTIMIZE)" - ( cd "$BOOTSTRAP_DIR" && zig build -Dtarget="$target" -Doptimize="$OPTIMIZE" ) - - echo "==> zig injector $target ($INJECTOR_OPTIMIZE)" - ( cd "$INJECTOR_DIR" && zig build -Dtarget="$target" -Doptimize="$INJECTOR_OPTIMIZE" ) - - local dest_dir - dest_dir="$BIN_DIR/$target" - mkdir -p "$dest_dir" - - local bs_artifact inj_artifact - bs_artifact="$(bootstrap_artifact_for "$target")" - inj_artifact="$(injector_artifact_for "$target")" - - mv "$BIN_DIR/$bs_artifact" "$dest_dir/$bs_artifact" - mv "$BIN_DIR/$inj_artifact" "$dest_dir/$inj_artifact" + echo "==> zig $target (bootstrap=$OPTIMIZE, injector=$INJECTOR_OPTIMIZE)" + zig build \ + -Dtarget="$target" \ + -Doptimize="$OPTIMIZE" \ + -Dinjector-optimize="$INJECTOR_OPTIMIZE" \ + --prefix "$BIN_DIR/$target" done link_canonical() { @@ -119,9 +102,6 @@ build_dotnet() { dotnet build "$PAYLOAD_CSPROJ" -c "$CONFIG" --nologo } -# Bootstrap resolves Hauyne.Payload.dll relative to its own .so directory (dladdr), -# so symlink the payload into each per-target subdir. -# Multi-target build puts payloads in bin/net9.0/ and bin/net10.0/ payload() { (( DO_ZIG )) || return 0 local payload="" diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..23c6693 --- /dev/null +++ b/build.zig @@ -0,0 +1,59 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// This Source Code Form is "Incompatible With Secondary Licenses", as defined by the +// Mozilla Public License, v. 2.0. + +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const injector_optimize = b.option( + std.builtin.OptimizeMode, + "injector-optimize", + "Optimization for the injector (defaults to -Doptimize)", + ) orelse optimize; + + { + const mod = b.createModule(.{ + .root_source_file = b.path("Hauyne.Bootstrap/bootstrap.zig"), + .target = target, + .optimize = optimize, + }); + mod.link_libc = true; + if (target.result.os.tag != .windows) { + mod.linkSystemLibrary("dl", .{}); + mod.linkSystemLibrary("pthread", .{}); + } + const lib = b.addLibrary(.{ + .name = "Hauyne.Bootstrap", + .root_module = mod, + .linkage = .dynamic, + }); + const install = b.addInstallArtifact(lib, .{ + .dest_dir = .{ .override = .{ .custom = "" } }, + }); + b.getInstallStep().dependOn(&install.step); + } + + { + const mod = b.createModule(.{ + .root_source_file = b.path("Hauyne.Injector/injector.zig"), + .target = target, + .optimize = injector_optimize, + }); + mod.link_libc = true; + if (target.result.os.tag != .windows) { + mod.linkSystemLibrary("dl", .{}); + } + const exe = b.addExecutable(.{ + .name = "Hauyne.Injector", + .root_module = mod, + }); + const install = b.addInstallArtifact(exe, .{ + .dest_dir = .{ .override = .{ .custom = "" } }, + }); + b.getInstallStep().dependOn(&install.step); + } +}