draugr.git


draugr

~ single ELF file analysis tool
git clone https://github.com/djnnvx/draugr.git
log | files | refs | archive | README

commit b0b6e560bb763c20e2825c375365df7830cac274
parent 0dc850fe62b8954de7c01918677c3b79b77e5d28
Author: djnn <[email protected]>
Date: Tue, 6 Oct 2026 12:21:00 +0900

feat(analysis): in-memory patching for function dynamic execution

Diffstat:
MREADME.md | 1+
Manalysis/call.odin | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Manalysis/call_test.odin | 37+++++++++++++++++++++++++++++++++++++
Mmain.odin | 8+++++++-

4 files changed, 249 insertions(+), 10 deletions(-)

diff --git a/README.md b/README.md
@@ -29,6 +29,7 @@ draugr <elf-path> [flags]
 | `--code` | Code regions: executable sections with entropy, entry point, function starts |
 | `--call-function <sym>` | Load the file from memory and call one symbol |
 | `--call-args <list>` | Arguments for `--call-function` |
+| `--patch <list>` | In-memory patches applied before `--call-function` (x86-64). `;`-separated: `sym=ret:N`, `sym=jmp:target`, `got:sym=target` |
 | `--bin-diff <file>` | Diff this binary's layout against a reference ELF |
 | `--json` | Full structured dump: header, segments, sections, dynamic, symbols, relocations, GOT, checksec |
 | `--verbose` | Verbose output |
diff --git a/analysis/call.odin b/analysis/call.odin
@@ -1,7 +1,9 @@
 package analysis
 
 import "core:c"
+import "core:encoding/endian"
 import "core:fmt"
+import "core:mem"
 import "core:os"
 import "core:strconv"
 import "core:strings"
@@ -15,12 +17,43 @@ foreign import libc "system:c"
 @(default_calling_convention="c")
 foreign libc {
 	system :: proc(command: cstring) -> c.int ---
+	dlinfo :: proc(handle: rawptr, request: c.int, info: rawptr) -> c.int ---
 }
 
+Link_Map :: struct {
+	l_addr : uintptr,
+}
+
+RTLD_DI_LINKMAP :: 2
+
 Callee :: #type proc "c" (u64, u64, u64, u64, u64, u64) -> u64
 
+Patch_Kind :: enum { Ret, Jmp, Got }
+
+Patch :: struct {
+	kind   : Patch_Kind,
+	target : string,
+	value  : u64,
+	repl   : string,
+}
+
 // The callee runs in a forked child so a crash is a report, not a dead draugr.
-call_function :: proc(data: []u8, info: ^elf.ELF_Info, path: string, symbol: string, raw_args: string) -> string {
+call_function :: proc(data: []u8, info: ^elf.ELF_Info, path: string, symbol: string, raw_args: string, raw_patch: string) -> string {
+	args, parse_err := parse_call_args(raw_args)
+	defer delete(args)
+	if parse_err != "" {
+		return parse_err
+	}
+
+	patches, patch_err := parse_patches(raw_patch)
+	defer delete(patches)
+	if patch_err != "" {
+		return patch_err
+	}
+	if len(patches) > 0 && info.header.machine != elf.EM_X86_64 {
+		return "patching only supported on x86-64"
+	}
+
 	fd, err := linux.memfd_create("draugr-call", {})
 	if err != .NONE {
 		return fmt.tprintf("memfd_create failed: %v", err)
@@ -40,12 +73,6 @@ call_function :: proc(data: []u8, info: ^elf.ELF_Info, path: string, symbol: str
 		}
 	}
 
-	args, parse_err := parse_call_args(raw_args)
-	defer delete(args)
-	if parse_err != "" {
-		return parse_err
-	}
-
 	fmt.fprintf(os.stderr, "calling %s in a forked child, loaded from memory\n", symbol)
 
 	pid, ferr := linux.fork()
@@ -54,7 +81,7 @@ call_function :: proc(data: []u8, info: ^elf.ELF_Info, path: string, symbol: str
 	}
 
 	if pid == 0 {
-		run_callee(fd_path, symbol, args)
+		run_callee(fd_path, symbol, args, info, patches)
 	}
 
 	status: u32
@@ -64,13 +91,20 @@ call_function :: proc(data: []u8, info: ^elf.ELF_Info, path: string, symbol: str
 	return report_status(status)
 }
 
-run_callee :: proc(fd_path: string, symbol: string, args: []u64) -> ! {
+run_callee :: proc(fd_path: string, symbol: string, args: []u64, info: ^elf.ELF_Info, patches: []Patch) -> ! {
 	handle := posix.dlopen(strings.clone_to_cstring(fd_path, context.temp_allocator), {.NOW})
 	if handle == nil {
 		fmt.fprintf(os.stderr, "dlopen: %s\n", posix.dlerror())
 		linux.exit(101)
 	}
 
+	for p in patches {
+		if perr := apply_patch(handle, info, p); perr != "" {
+			fmt.fprintf(os.stderr, "patch: %s\n", perr)
+			linux.exit(103)
+		}
+	}
+
 	sym := posix.dlsym(handle, strings.clone_to_cstring(symbol, context.temp_allocator))
 	if sym == nil {
 		fmt.fprintf(os.stderr, "dlsym: %s\n", posix.dlerror())
@@ -92,12 +126,173 @@ report_status :: proc(status: u32) -> string {
 		case 0:   return ""
 		case 101: return "dlopen failed, see above"
 		case 102: return "symbol not found, see above"
+		case 103: return "patch failed, see above"
 		}
 		return fmt.tprintf("callee exited with status %d", code)
 	}
 	return fmt.tprintf("callee died on signal %d", status & 0x7f)
 }
 
+apply_patch :: proc(handle: posix.Symbol_Table, info: ^elf.ELF_Info, p: Patch) -> string {
+	switch p.kind {
+	case .Ret:
+		fn := sym_addr(handle, p.target)
+		if fn == nil {
+			return fmt.tprintf("symbol not found: %s", p.target)
+		}
+		code := [11]u8{0x48, 0xB8, 0, 0, 0, 0, 0, 0, 0, 0, 0xC3}
+		if werr := fits(info, p.target, len(code)); werr != "" {
+			return werr
+		}
+		endian.unchecked_put_u64le(code[2:10], p.value)
+		return write_code(fn, code[:])
+
+	case .Jmp:
+		fn := sym_addr(handle, p.target)
+		if fn == nil {
+			return fmt.tprintf("symbol not found: %s", p.target)
+		}
+		to := sym_addr(handle, p.repl)
+		if to == nil {
+			return fmt.tprintf("symbol not found: %s", p.repl)
+		}
+		code := [12]u8{0x48, 0xB8, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xE0}
+		if werr := fits(info, p.target, len(code)); werr != "" {
+			return werr
+		}
+		endian.unchecked_put_u64le(code[2:10], u64(uintptr(to)))
+		return write_code(fn, code[:])
+
+	case .Got:
+		to := sym_addr(handle, p.repl)
+		if to == nil {
+			return fmt.tprintf("symbol not found: %s", p.repl)
+		}
+		slot := got_slot(handle, info, p.target)
+		if slot == nil {
+			return fmt.tprintf("no GOT slot for import: %s", p.target)
+		}
+		if werr := make_writable(slot, 8); werr != "" {
+			return werr
+		}
+		(^rawptr)(slot)^ = to
+		return ""
+	}
+	return ""
+}
+
+sym_addr :: proc(handle: posix.Symbol_Table, name: string) -> rawptr {
+	return posix.dlsym(handle, strings.clone_to_cstring(name, context.temp_allocator))
+}
+
+got_slot :: proc(handle: posix.Symbol_Table, info: ^elf.ELF_Info, import_name: string) -> rawptr {
+	lm: ^Link_Map
+	if dlinfo(rawptr(handle), RTLD_DI_LINKMAP, &lm) != 0 || lm == nil {
+		return nil
+	}
+	for r in info.relocs {
+		if r.sym_name == import_name && (r.type == 6 || r.type == 7) {
+			return rawptr(lm.l_addr + uintptr(r.offset))
+		}
+	}
+	return nil
+}
+
+fits :: proc(info: ^elf.ELF_Info, name: string, need: int) -> string {
+	size := u64(0)
+	for s in info.symbols {
+		if s.name == name && s.size != 0 { size = s.size; break }
+	}
+	if size == 0 {
+		for s in info.dyn_symbols {
+			if s.name == name && s.size != 0 { size = s.size; break }
+		}
+	}
+	if size != 0 && size < u64(need) {
+		return fmt.tprintf("%s is %d bytes, too small for a %d-byte patch", name, size, need)
+	}
+	return ""
+}
+
+write_code :: proc(dst: rawptr, code: []u8) -> string {
+	if werr := make_writable(dst, len(code)); werr != "" {
+		return werr
+	}
+	mem.copy(dst, raw_data(code), len(code))
+	return ""
+}
+
+make_writable :: proc(ptr: rawptr, length: int) -> string {
+	page := uintptr(4096)
+	start := uintptr(ptr) & ~(page - 1)
+	end := (uintptr(ptr) + uintptr(length) + page - 1) & ~(page - 1)
+	if err := linux.mprotect(rawptr(start), uint(end - start), {.READ, .WRITE, .EXEC}); err != .NONE {
+		return fmt.tprintf("mprotect failed: %v", err)
+	}
+	return ""
+}
+
+parse_patches :: proc(raw: string) -> ([]Patch, string) {
+	if raw == "" {
+		return nil, ""
+	}
+
+	specs := strings.split(raw, ";", context.temp_allocator)
+	patches := make([]Patch, len(specs))
+	n := 0
+
+	for spec in specs {
+		s := strings.trim_space(spec)
+		if s == "" {
+			continue
+		}
+
+		eq := strings.index_byte(s, '=')
+		if eq < 0 {
+			return patches[:n], fmt.tprintf("patch spec missing '=': %s", s)
+		}
+		lhs := strings.trim_space(s[:eq])
+		rhs := strings.trim_space(s[eq + 1:])
+
+		p: Patch
+		switch {
+		case strings.has_prefix(lhs, "got:"):
+			p.kind = .Got
+			p.target = strings.trim_space(lhs[4:])
+			p.repl = rhs
+			if p.target == "" || p.repl == "" {
+				return patches[:n], fmt.tprintf("got: patch needs got:import=target: %s", s)
+			}
+		case strings.has_prefix(rhs, "ret:"):
+			p.kind = .Ret
+			p.target = lhs
+			v, ok := strconv.parse_i64_maybe_prefixed(strings.trim_space(rhs[4:]))
+			if !ok {
+				return patches[:n], fmt.tprintf("ret: patch needs an integer: %s", s)
+			}
+			p.value = u64(v)
+		case strings.has_prefix(rhs, "jmp:"):
+			p.kind = .Jmp
+			p.target = lhs
+			p.repl = strings.trim_space(rhs[4:])
+			if p.repl == "" {
+				return patches[:n], fmt.tprintf("jmp: patch needs a target symbol: %s", s)
+			}
+		case:
+			return patches[:n], fmt.tprintf("unknown patch spec: %s", s)
+		}
+
+		if p.target == "" {
+			return patches[:n], fmt.tprintf("patch spec missing symbol: %s", s)
+		}
+
+		patches[n] = p
+		n += 1
+	}
+
+	return patches[:n], ""
+}
+
 parse_call_args :: proc(raw: string) -> ([]u64, string) {
 	args := make([]u64, 6)
 	if raw == "" {
diff --git a/analysis/call_test.odin b/analysis/call_test.odin
@@ -1,5 +1,6 @@
 package analysis
 
+import "core:fmt"
 import "core:testing"
 
 @(test)
@@ -50,6 +51,42 @@ test_call_args_reject_junk_and_overflow :: proc(t: ^testing.T) {
 }
 
 @(test)
+test_parse_patches :: proc(t: ^testing.T) {
+	patches, err := parse_patches("got:puts=my_puts; secret=ret:1; hook=jmp:repl")
+	defer delete(patches)
+
+	testing.expect_value(t, err, "")
+	testing.expect_value(t, len(patches), 3)
+
+	testing.expect_value(t, patches[0].kind, Patch_Kind.Got)
+	testing.expect_value(t, patches[0].target, "puts")
+	testing.expect_value(t, patches[0].repl, "my_puts")
+
+	testing.expect_value(t, patches[1].kind, Patch_Kind.Ret)
+	testing.expect_value(t, patches[1].target, "secret")
+	testing.expect_value(t, patches[1].value, 1)
+
+	testing.expect_value(t, patches[2].kind, Patch_Kind.Jmp)
+	testing.expect_value(t, patches[2].target, "hook")
+	testing.expect_value(t, patches[2].repl, "repl")
+
+	empty, eerr := parse_patches("")
+	defer delete(empty)
+	testing.expect_value(t, eerr, "")
+	testing.expect_value(t, len(empty), 0)
+}
+
+@(test)
+test_parse_patches_rejects_junk :: proc(t: ^testing.T) {
+	cases := []string{"noequalssign", "f=ret:", "f=ret:xyz", "got:=target", "f=bogus:1"}
+	for c in cases {
+		p, err := parse_patches(c)
+		defer delete(p)
+		testing.expect(t, err != "", fmt.tprintf("spec %q must be rejected", c))
+	}
+}
+
+@(test)
 test_report_status_maps_exit_and_signal :: proc(t: ^testing.T) {
 	testing.expect_value(t, report_status(0), "")
 	testing.expect_value(t, report_status(101 << 8), "dlopen failed, see above")
diff --git a/main.odin b/main.odin
@@ -24,6 +24,7 @@ Options :: struct {
 	bin_diff    : string `args:"name=bin-diff" usage:"Diff this binary's layout against a reference ELF."`,
 	call_func   : string `args:"name=call-function" usage:"Load from memory and call this symbol."`,
 	call_args   : string `args:"name=call-args" usage:"Comma-separated args for --call-function: integers, or s:string."`,
+	patch       : string `args:"name=patch" usage:"Semicolon-separated in-memory patches applied before --call-function. Specs: sym=ret:N, sym=jmp:target, got:sym=target."`,
 	checksec    : bool   `usage:"Hardening report: RELRO, NX, canary, PIE, RPATH, FORTIFY."`,
 	relocs      : bool   `usage:"Relocation table and PLT/GOT map."`,
 	info        : bool   `usage:"Print ELF summary only."`,
@@ -62,6 +63,11 @@ main :: proc() {
 		elf.machine_to_str(info.header.machine), bits, elf_type, dyn_type)
 	fmt.fprintln(os.stderr, "")
 
+	if opts.patch != "" && opts.call_func == "" {
+		fmt.fprintln(os.stderr, "--patch requires --call-function")
+		os.exit(1)
+	}
+
 	if opts.json {
 		if opts.hexdump || opts.hexdump_out != "" ||
 		   opts.entropy || opts.entropy_out != "" ||
@@ -140,7 +146,7 @@ main :: proc() {
 
 	if opts.call_func != "" {
 		section_header("Call Function", "")
-		report("Call", analysis.call_function(elf_data, &info, opts.elf_path, opts.call_func, opts.call_args), &failed)
+		report("Call", analysis.call_function(elf_data, &info, opts.elf_path, opts.call_func, opts.call_args, opts.patch), &failed)
 		fmt.println("")
 	}