From 6e6273710422a0a8d8c777eb744412279ae62622 Mon Sep 17 00:00:00 2001 From: Pascal Zittlau Date: Sun, 19 Oct 2025 10:08:13 +0200 Subject: [PATCH] init --- README.md | 51 +++++- build.zig | 76 +++++++++ build.zig.zon | 7 + src/main.zig | 333 ++++++++++++++++++++++++++++++++++++++++ src/test/exit.zig | 3 + src/test/helloWorld.zig | 9 ++ src/test/printArgs.zig | 17 ++ 7 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/main.zig create mode 100644 src/test/exit.zig create mode 100644 src/test/helloWorld.zig create mode 100644 src/test/printArgs.zig diff --git a/README.md b/README.md index c0db045..6d69c5e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,51 @@ -# loader +# ELF Loader +A minimal user-space ELF loader for Linux. + +This project is an experiment in implementing a user-space `execve`. It can load and execute Linux +ELF binaries by mapping their segments into memory, setting up the stack, and trampolining to their +entry point. + +## Features + +It supports statically linked PIE(`ET_DYN`) and non-PIE(`ET_ECEC`) executables directly. + +For dynamically linked executables it loads the in `PT_INTERP` specified interpreter and transfers +control to it, such that it handles the full dynamic linkign process. + +It also sanitizes the stack by removing the loader's arguments and updates `auxv` with the client's +information. + +## Building and Running + +This creates the `loader` executable and a set of test binaries in `zig-out/bin/` : +```sh +zig build +``` + +Alternatively use something like this to run directly: +```sh +zig build run -- /bin/ls +``` + +This runs the tests: +```sh +zig build test +``` + +You can run an executable by passing it as an argument to the `loader`. Any subsequent arguments are +passed through to the target executable. + +```sh +# Run a test executable through the loader +./zig-out/bin/loader ./zig-out/bin/test_nolibc_pie_helloWorld +# Output: Hello World! + +# Run a test executable that prints its arguments +./zig-out/bin/loader ./zig-out/bin/test_nolibc_pie_printArgs foo bar baz +# Output: ./zig-out/bin/test_nolibc_pie_printArgs foo bar baz +``` + +## License + +Apache 2.0 diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..1283ab8 --- /dev/null +++ b/build.zig @@ -0,0 +1,76 @@ +const std = @import("std"); + +pub fn compileTestApplications( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + comptime link_libc: bool, + comptime pie: bool, +) !void { + // Compile test applications + const test_path = "src/test/"; + const test_prefix = prefix: { + const p1 = "test_" ++ if (link_libc) "libc_" else "nolibc_"; + const p2 = p1 ++ if (pie) "pie_" else "nopie_"; + break :prefix p2; + }; + var test_dir = try std.fs.cwd().openDir(test_path, .{ .iterate = true }); + defer test_dir.close(); + var iterator = test_dir.iterate(); + while (try iterator.next()) |entry| { + const name = try std.mem.concat(b.allocator, u8, &.{ + test_prefix, entry.name[0 .. entry.name.len - 4], // strip .zig suffix + }); + const test_executable = b.addExecutable(.{ + .name = name, + .root_module = b.createModule(.{ + .root_source_file = b.path(b.pathJoin(&.{ test_path, entry.name })), + .optimize = optimize, + .target = target, + .link_libc = link_libc, + .link_libcpp = false, + .pic = pie, + }), + .linkage = if (link_libc) .dynamic else .static, + }); + test_executable.pie = pie; + b.installArtifact(test_executable); + } +} + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + try compileTestApplications(b, target, optimize, false, false); + try compileTestApplications(b, target, optimize, false, true); + try compileTestApplications(b, target, optimize, true, true); + + const mod = b.addModule("loader", .{ + .root_source_file = b.path("src/main.zig"), + .optimize = optimize, + .target = target, + .link_libc = false, + .link_libcpp = false, + }); + const exe = b.addExecutable(.{ + .name = "loader", + .root_module = mod, + }); + exe.pie = true; + b.installArtifact(exe); + + const run_step = b.step("run", "Run the app"); + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const exe_tests = b.addTest(.{ .root_module = mod }); + const run_exe_tests = b.addRunArtifact(exe_tests); + const test_step = b.step("test", "Run tests"); + test_step.dependOn(b.getInstallStep()); + test_step.dependOn(&run_exe_tests.step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..0077f43 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,7 @@ +.{ + .name = .loader, + .version = "0.0.1", + .minimum_zig_version = "0.15.1", + .paths = .{""}, + .fingerprint = 0xbf53f276a39b2af9, +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..80fd880 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,333 @@ +const std = @import("std"); + +const elf = std.elf; +const mem = std.mem; +const posix = std.posix; +const testing = std.testing; + +const assert = std.debug.assert; + +const log = std.log.scoped(.loader); +pub const std_options = std.Options{ .log_level = .info }; + +const page_size = std.heap.pageSize(); +const max_interp_path_length = 128; +const help = + \\Usage: + \\ ./loader [loader_flags] [args...] + \\Flags: + \\ -h print this help + \\ +; + +const UnfinishedReadError = error{UnfinishedRead}; + +pub fn main() !void { + // Parse arguments + var arg_index: u64 = 1; // Skip own name + while (arg_index < std.os.argv.len) : (arg_index += 1) { + const arg = mem.sliceTo(std.os.argv[arg_index], '0'); + if (arg[0] != '-') break; + if (mem.eql(u8, arg, "-h") or mem.eql(u8, arg, "--help")) { + std.debug.print("{s}", .{help}); + return; + } + // TODO: Handle loader flags when/if we need them + } else { + std.debug.print("No executable given.\n", .{}); + std.debug.print("{s}", .{help}); + return; + } + + // TODO: maybe search for the file in PATH + // Map file into memory + const file = try std.fs.cwd().openFile( + mem.sliceTo(std.os.argv[arg_index], 0), + .{ .mode = .read_only }, + ); + var buffer: [128]u8 = undefined; + var file_reader = file.reader(&buffer); + log.info("--- Loading executable: {s} ---", .{std.os.argv[arg_index]}); + const ehdr = try elf.Header.read(&file_reader.interface); + const base = try loadStaticElf(ehdr, &file_reader); + const entry = ehdr.entry + if (ehdr.type == .DYN) base else 0; + log.info("Executable loaded: base=0x{x}, entry=0x{x}", .{ base, entry }); + + // Check for dynamic linker + const maybe_interp: ?std.fs.File = interp: { + var phdrs = ehdr.iterateProgramHeaders(&file_reader); + while (try phdrs.next()) |phdr| { + if (phdr.p_type != elf.PT_INTERP) continue; + var interp_path: [max_interp_path_length]u8 = undefined; + try file_reader.seekTo(phdr.p_offset); + if (try file_reader.read(interp_path[0..phdr.p_filesz]) != phdr.p_filesz) + return UnfinishedReadError.UnfinishedRead; + assert(interp_path[phdr.p_filesz - 1] == 0); // Must be zero terminated + log.info("Found interpreter path: {s}", .{interp_path[0 .. phdr.p_filesz - 1]}); + break :interp try std.fs.cwd().openFile( + interp_path[0 .. phdr.p_filesz - 1], + .{ .mode = .read_only }, + ); + } + break :interp null; + }; + + // We don't need the file anymore. But we reuse the buffer if we need to load the interpreter. + // Therefore deinit everything. + file_reader = undefined; + file.close(); + + var maybe_interp_base: ?usize = null; + var maybe_interp_entry: ?usize = null; + if (maybe_interp) |interp| { + log.info("--- Loading interpreter ---", .{}); + var interp_reader = interp.reader(&buffer); + const interp_ehdr = try elf.Header.read(&interp_reader.interface); + assert(interp_ehdr.type == elf.ET.DYN); + const interp_base = try loadStaticElf(interp_ehdr, &interp_reader); + maybe_interp_base = interp_base; + maybe_interp_entry = interp_ehdr.entry + if (interp_ehdr.type == .DYN) interp_base else 0; + log.info("Interpreter loaded: base=0x{x}, entry=0x{x}", .{ interp_base, maybe_interp_entry.? }); + interp.close(); + } + + var i: usize = 0; + const auxv = std.os.linux.elf_aux_maybe.?; + while (auxv[i].a_type != elf.AT_NULL) : (i += 1) { + // TODO: look at other auxv types and check if we need to change them. + auxv[i].a_un.a_val = switch (auxv[i].a_type) { + elf.AT_PHDR => base + ehdr.phoff, + elf.AT_PHENT => ehdr.phentsize, + elf.AT_PHNUM => ehdr.phnum, + elf.AT_BASE => maybe_interp_base orelse auxv[i].a_un.a_val, + elf.AT_ENTRY => entry, + elf.AT_EXECFN => @intFromPtr(std.os.argv[arg_index]), + else => auxv[i].a_un.a_val, + }; + } + + // The stack layout provided by the kernel is: + // argc, argv..., NULL, envp..., NULL, auxv... + // We need to shift this block of memory to remove the loader's own arguments before we jump to + // the new executable. + // The end of the block is one entry past the AT_NULL entry in auxv. + const end_of_auxv = &auxv[i + 1]; + const dest_ptr = @as([*]u8, @ptrCast(std.os.argv.ptr)); + const src_ptr = @as([*]u8, @ptrCast(&std.os.argv[arg_index])); + const len = @intFromPtr(end_of_auxv) - @intFromPtr(src_ptr); + log.debug( + "Copying stack from {*} to {*} with length 0x{x}", + .{ src_ptr, dest_ptr, len }, + ); + assert(@intFromPtr(dest_ptr) < @intFromPtr(src_ptr)); + std.mem.copyForwards(u8, dest_ptr[0..len], src_ptr[0..len]); + + // `std.os.argv.ptr` points to the argv pointers. The word just before it is argc and also the + // start of the stack. + const argc: [*]usize = @as([*]usize, @ptrCast(@alignCast(&std.os.argv.ptr[0]))) - 1; + argc[0] = std.os.argv.len - arg_index; + log.debug("new argc: {x}", .{argc[0]}); + + const final_entry = maybe_interp_entry orelse entry; + log.info("Trampolining to final entry: 0x{x} with sp: {*}", .{ final_entry, argc }); + trampoline(final_entry, argc); +} + +/// Loads all `PT_LOAD` segments of an ELF file into memory. +/// +/// For `ET_EXEC` (non-PIE), segments are mapped at their fixed virtual addresses (`p_vaddr`). +/// For `ET_DYN` (PIE), segments are mapped at a random base address chosen by the kernel. +/// +/// It handles zero-initialized(e.g., .bss) sections by mapping anonymous memory and only reading +/// `p_filesz` bytes from the file, ensuring `p_memsz` bytes are allocated. +fn loadStaticElf(ehdr: elf.Header, file_reader: *std.fs.File.Reader) !usize { + // NOTE: In theory we could also just look at the first and last loadable segment because the + // ELF spec mandates these to be in ascending order of `p_vaddr`, but better be safe than sorry. + // https://gabi.xinuos.com/elf/08-pheader.html#:~:text=ascending%20order + const minva, const maxva = bounds: { + var minva: u64 = std.math.maxInt(u64); + var maxva: u64 = 0; + var phdrs = ehdr.iterateProgramHeaders(file_reader); + while (try phdrs.next()) |phdr| { + if (phdr.p_type != elf.PT_LOAD) continue; + minva = @min(minva, phdr.p_vaddr); + maxva = @max(maxva, phdr.p_vaddr + phdr.p_memsz); + } + minva = mem.alignBackward(usize, minva, page_size); + maxva = mem.alignForward(usize, maxva, page_size); + log.debug("Calculated bounds: minva=0x{x}, maxva=0x{x}", .{ minva, maxva }); + break :bounds .{ minva, maxva }; + }; + + // Check, that the needed memory region can be allocated as a whole. We do this + const dynamic = ehdr.type == elf.ET.DYN; + log.debug("ELF type is {s}", .{if (dynamic) "DYN" else "EXEC (static)"}); + const hint = if (dynamic) null else @as(?[*]align(page_size) u8, @ptrFromInt(minva)); + log.debug("mmap pre-flight hint: {*}", .{hint}); + const base = try posix.mmap( + hint, + maxva - minva, + posix.PROT.NONE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true, .FIXED = !dynamic }, + -1, + 0, + ); + log.debug("Pre-flight reservation successful at: {*}, size: 0x{x}", .{ base.ptr, base.len }); + posix.munmap(base); + + const flags = posix.MAP{ .TYPE = .PRIVATE, .ANONYMOUS = true, .FIXED = true }; + var phdrs = ehdr.iterateProgramHeaders(file_reader); + var phdr_idx: u32 = 0; + errdefer posix.munmap(base); + while (try phdrs.next()) |phdr| : (phdr_idx += 1) { + if (phdr.p_type != elf.PT_LOAD) continue; + if (phdr.p_memsz == 0) continue; + + const offset = phdr.p_vaddr & (page_size - 1); + const size = mem.alignForward(usize, phdr.p_memsz + offset, page_size); + var start = mem.alignBackward(usize, phdr.p_vaddr, page_size); + const base_for_dyn = if (dynamic) @intFromPtr(base.ptr) else 0; + start += base_for_dyn; + log.debug( + " - phdr[{}]: mapping 0x{x} bytes at 0x{x} (vaddr=0x{x}, dyn_base=0x{x})", + .{ phdr_idx, size, start, phdr.p_vaddr, base_for_dyn }, + ); + // NOTE: We can't use a single file-backed mmap for the segment, because p_memsz may be + // larger than p_filesz. This difference accounts for the .bss section, which must be + // zero-initialized. + const ptr = try posix.mmap( + @as(?[*]align(page_size) u8, @ptrFromInt(start)), + size, + posix.PROT.WRITE, + flags, + -1, + 0, + ); + try file_reader.seekTo(phdr.p_offset); + if (try file_reader.read(ptr[offset..][0..phdr.p_filesz]) != phdr.p_filesz) + return UnfinishedReadError.UnfinishedRead; + try posix.mprotect(ptr, elfToMmapProt(phdr.p_flags)); + } + log.debug("loadElf returning base: 0x{x}", .{@intFromPtr(base.ptr)}); + return @intFromPtr(base.ptr); +} + +/// Converts ELF program header protection flags to mmap protection flags. +fn elfToMmapProt(elf_prot: u64) u32 { + var result: u32 = posix.PROT.NONE; + if ((elf_prot & elf.PF_R) != 0) result |= posix.PROT.READ; + if ((elf_prot & elf.PF_W) != 0) result |= posix.PROT.WRITE; + if ((elf_prot & elf.PF_X) != 0) result |= posix.PROT.EXEC; + return result; +} + +/// This function performs the final jump into the loaded program (amd64) +// TODO: support more architectures +fn trampoline(entry: usize, sp: [*]usize) noreturn { + asm volatile ( + \\ mov %[sp], %%rsp + \\ jmp *%[entry] + : // No outputs + : [entry] "r" (entry), + [sp] "r" (sp), + : .{ .rsp = true, .memory = true }); + unreachable; +} + +// TODO: make this be passed in from the build system +const bin_path = "zig-out/bin/"; +fn getExePath(comptime name: []const u8) []const u8 { + return bin_path ++ name; +} +const loader_path = getExePath("loader"); + +test "test_nolibc_nopie_exit" { + try testExit("test_nolibc_nopie_exit"); +} +test "test_nolibc_pie_exit" { + try testExit("test_nolibc_pie_exit"); +} +test "test_libc_pie_exit" { + try testExit("test_libc_pie_exit"); +} + +test "test_nolibc_nopie_helloWorld" { + try testHelloWorld("test_nolibc_nopie_helloWorld"); +} +test "test_nolibc_pie_helloWorld" { + try testHelloWorld("test_nolibc_pie_helloWorld"); +} +test "test_libc_pie_helloWorld" { + try testHelloWorld("test_libc_pie_helloWorld"); +} + +test "test_nolibc_nopie_printArgs" { + try testPrintArgs("test_nolibc_nopie_printArgs"); +} +test "test_nolibc_pie_printArgs" { + try testPrintArgs("test_nolibc_pie_printArgs"); +} +test "test_libc_pie_printArgs" { + try testPrintArgs("test_libc_pie_printArgs"); +} + +fn testExit(comptime name: []const u8) !void { + const exe_path = comptime getExePath(name); + const argv = &.{ loader_path, exe_path }; + log.info("Running test: {s}", .{name}); + + const result = try std.process.Child.run(.{ + .allocator = testing.allocator, + .argv = argv, + }); + defer testing.allocator.free(result.stdout); + defer testing.allocator.free(result.stderr); + errdefer std.log.err("term: {}", .{result.term}); + errdefer std.log.err("stdout: {s}", .{result.stdout}); + + try testing.expectEqualStrings("", result.stdout); + try testing.expect(result.term == .Exited); + try testing.expectEqual(0, result.term.Exited); +} + +fn testHelloWorld(comptime name: []const u8) !void { + const exe_path = comptime getExePath(name); + const argv = &.{ loader_path, exe_path }; + log.info("Running test: {s}", .{name}); + + const result = try std.process.Child.run(.{ + .allocator = testing.allocator, + .argv = argv, + }); + defer testing.allocator.free(result.stdout); + defer testing.allocator.free(result.stderr); + errdefer std.log.err("term: {}", .{result.term}); + errdefer std.log.err("stdout: {s}", .{result.stdout}); + + try testing.expectEqualStrings("Hello World!\n", result.stdout); + try testing.expect(result.term == .Exited); + try testing.expectEqual(0, result.term.Exited); +} + +fn testPrintArgs(comptime name: []const u8) !void { + const exe_path = comptime getExePath(name); + const loader_argv: []const []const u8 = &.{ loader_path, exe_path, "foo", "bar", "baz", "hi" }; + const target_argv = loader_argv[1..]; + log.info("Running test: {s}", .{name}); + + const result = try std.process.Child.run(.{ + .allocator = testing.allocator, + .argv = loader_argv, + }); + defer testing.allocator.free(result.stdout); + defer testing.allocator.free(result.stderr); + errdefer std.log.err("term: {}", .{result.term}); + errdefer std.log.err("stdout: {s}", .{result.stdout}); + + const expected_stout = try mem.join(testing.allocator, " ", target_argv); + defer testing.allocator.free(expected_stout); + + try testing.expectEqualStrings(expected_stout, result.stdout); + try testing.expect(result.term == .Exited); + try testing.expectEqual(0, result.term.Exited); +} diff --git a/src/test/exit.zig b/src/test/exit.zig new file mode 100644 index 0000000..187a4eb --- /dev/null +++ b/src/test/exit.zig @@ -0,0 +1,3 @@ +pub fn main() void { + return; +} diff --git a/src/test/helloWorld.zig b/src/test/helloWorld.zig new file mode 100644 index 0000000..811a56e --- /dev/null +++ b/src/test/helloWorld.zig @@ -0,0 +1,9 @@ +const std = @import("std"); + +pub fn main() !void { + var stdout_buffer: [64]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + try stdout.print("Hello World!\n", .{}); + try stdout.flush(); +} diff --git a/src/test/printArgs.zig b/src/test/printArgs.zig new file mode 100644 index 0000000..df2c74a --- /dev/null +++ b/src/test/printArgs.zig @@ -0,0 +1,17 @@ +const std = @import("std"); + +pub fn main() !void { + var stdout_buffer: [64]u8 = undefined; + var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); + const stdout = &stdout_writer.interface; + + // It is done this way to remove the trailing space with a naive implementation. + var args = std.process.args(); + if (args.next()) |arg| { + try stdout.print("{s}", .{arg}); + } + while (args.next()) |arg| { + try stdout.print(" {s}", .{arg}); + } + try stdout.flush(); +}