draugr.git


draugr

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

commit 0dc850fe62b8954de7c01918677c3b79b77e5d28
parent db3aa4ab468d83c316cb60559baffaca0ef23607
Author: djnn <[email protected]>
Date: Sat, 5 Sep 2026 07:41:43 +0200

ability to call functions, reloc parsing, proper checksec, etc.

Diffstat:
MREADME.md | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Manalysis/analysis_test.odin | 211++++++++++++++++++++++++++++++++++++++++---------------------------------------
Aanalysis/bin_diff.odin | 200+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aanalysis/call.odin | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aanalysis/call_test.odin | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aanalysis/code.odin | 219+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Danalysis/disasm.odin | 57---------------------------------------------------------
Manalysis/hexdump.odin | 1-
Manalysis/mapper.odin | 11-----------
Aelf/checksec.odin | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelf/checksec_test.odin | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelf/dynamic.odin | 241+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelf/dynamic_test.odin | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Melf/elf.odin | 10++++++++++
Aelf/json.odin | 203+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelf/json_test.odin | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Melf/loader.odin | 11+++++++++++
Melf/printer.odin | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Aelf/reloc.odin | 270+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelf/reloc_test.odin | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmain.odin | 59++++++++++++++++++++++++++++++++++++++++++++++++-----------

21 files changed, 2332 insertions(+), 229 deletions(-)

diff --git a/README.md b/README.md
@@ -1,10 +1,6 @@
 # draugr
 
-ELF mapping and analysis tool. Parses ELF32/ELF64, little and big endian, and reports
-sections, segments, symbols, entropy and memory layout.
-
-Written in Odin with no external dependencies. Odin ships no ELF package, so the parser
-is hand-rolled; byte reads go through `core:encoding/endian`.
+ELF mapping and analysis tool. Written in Odin with no third-party dependencies.
 
 ## Build
 
@@ -28,14 +24,22 @@ draugr <elf-path> [flags]
 | `--map` | Visual memory layout |
 | `--entropy` | Shannon entropy, 256-byte blocks, bits per byte |
 | `--hexdump` | Hex dump the whole file |
-| `--disasm` | List executable sections with a byte preview |
-| `--remap <file>` | Remap against a prototype file (stub) |
-| `--json` | JSON output (applies to `--info`) |
+| `--checksec` | Hardening report: RELRO, NX, canary, PIE, RPATH, FORTIFY |
+| `--relocs` | Relocation table and PLT/GOT map |
+| `--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` |
+| `--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 |
 
-`--hexdump`, `--entropy` and `--disasm` each have an `--<name>-out <file>` variant that
+`--hexdump`, `--entropy` and `--code` each have an `--<name>-out <file>` variant that
 writes to a file instead of stdout. Flags combine, so `--sections --symbols` prints both.
 
+`--json` covers the whole structured dump on its own and ignores the selector flags. It is
+rejected alongside `--hexdump`, `--entropy`, `--code`, `--map`, `--bin-diff` and
+`--call-function`, which are visualizations or actions with no JSON form.
+
 Both `--flag value` and `--flag=value` work, and a single dash is accepted.
 
 ### Output streams
@@ -44,12 +48,58 @@ Data goes to **stdout**. Headers, separators and the `arch=` summary go to **std
 So the tool stays readable on a terminal and pipeable everywhere else.
 
 ```sh
-draugr --info --json /bin/ls | jq .entry
+draugr --json /bin/ls | jq .needed
+draugr --json /bin/ls | jq .checksec
+draugr --json /bin/ls | jq .got.fclose       # GOT slot address, pwntools style
+draugr --json /bin/ls | jq '.dynamic_entries[] | select(.tag == "RUNPATH")'
 draugr --sections /bin/ls | awk '$3 == "PROGBITS"'
 draugr --symbols /bin/ls | grep ' U '        # undefined imports
+draugr --bin-diff v1.4.2/httpd v1.4.3/httpd  # what changed between firmware builds
 ```
 
-Exit code is 0 on success, 1 on a read, parse or output-write failure. A failing
-`--<name>-out` does not prevent the other requested analyses from running.
+### Hardening
+
+`--checksec` reports RELRO, stack canary, NX, PIE, RPATH, RUNPATH and FORTIFY. It also
+flags `DT_TEXTREL` and any RWX `PT_LOAD` segment.
+
+Three places where it deliberately disagrees with `checksec(1)`:
+
+- **NX with no `PT_GNU_STACK`** reports `Unknown`, not `Enabled`.
+- **PIE** separates `PIE`, `Static PIE`, `Shared library` and `ET_EXEC`.
+- **FORTIFY** counts distinct functions. `checksec(1)` counts a function twice when it is
+  imported both fortified and unfortified.
+
+### Relocations
+
+`--relocs` parses `RELA`, `REL` and `RELR`, from section headers when present and from the
+dynamic tags otherwise.
+
+### Dynamic linking
+
+`--info` reports `SONAME`, `RPATH`, `RUNPATH` and the `DT_NEEDED` list. `--info --verbose`
+adds the full `.dynamic` table. Unrecognised `DT_` tags print as hex rather than `UNKNOWN`.
+
+### Calling a function
+
+`--call-function` loads the file through a `memfd` and calls a single symbol, without the
+linked object ever reaching disk. A `.o` is linked first with `ld -shared` writing straight
+into the memfd; a `.so` is written to the memfd as-is.
+
+```sh
+draugr --call-function alpha --call-args 21 ./thing.o
+draugr --call-function parse --call-args s:AAAA,64 ./libvendor.so
+```
+
+Arguments are comma-separated: integers (decimal or `0x`), or `s:text` to pass a pointer to
+a NULL-terminated string. Up to six, matching the integer argument registers. Floats and
+structs are not supported, and the return value is read as a 64-bit integer.
+
+`.o` support needs `ld` on `$PATH`, and a cross-linker for a foreign architecture.
+
+### Function starts
+
+`--code` recovers function entry addresses from the `.eh_frame_hdr` binary search table.
+
+draugr does not disassemble. Pipe to `objdump -d` for that.
 
 
diff --git a/analysis/analysis_test.odin b/analysis/analysis_test.odin
@@ -433,33 +433,25 @@ test_entropy_output_reports_open_failure :: proc(t: ^testing.T) {
 	testing.expect(t, err != "", "unwritable out_path must surface an error")
 }
 
-DISASM_HEAD :: "Disassembly Analysis\n" +
-               "====================\n\n" +
-               "Executable Sections:\n" +
-               "Name" + SP17 + "Offset" + SP7 + "Size" + SP9 + "VAddr\n" +
-               DASH49 + "\n"
-
-DISASM_FOOT :: "\nNote: Full disassembly requires external disassembler\n" +
-               "Consider integrating zydis or capstone for x86/x64\n"
-
 @(test)
-test_disasm_output_no_sections :: proc(t: ^testing.T) {
-	path := tmp_path("disasm_empty")
+test_code_output_no_sections :: proc(t: ^testing.T) {
+	path := tmp_path("code_none")
 	defer drop(path)
 
 	info := new_info()
 	defer elf.destroy(&info)
 
-	testing.expect_value(t, disasm_output([]u8{}, &info, path), "")
+	testing.expect_value(t, code_output([]u8{}, &info, path), "")
 
 	got := read_all(t, path)
 	defer delete(got)
-	testing.expect_value(t, got, DISASM_HEAD + DISASM_FOOT)
+	testing.expect(t, strings.contains(got, "no section headers"), "must fall back to segments")
+	testing.expect(t, strings.contains(got, "no PT_GNU_EH_FRAME"), "must report missing eh_frame_hdr")
 }
 
 @(test)
-test_disasm_output_lists_only_executable_sections :: proc(t: ^testing.T) {
-	path := tmp_path("disasm_exec")
+test_code_output_lists_only_executable_sections :: proc(t: ^testing.T) {
+	path := tmp_path("code_exec")
 	defer drop(path)
 
 	info := new_info()
@@ -468,38 +460,26 @@ test_disasm_output_lists_only_executable_sections :: proc(t: ^testing.T) {
 
 	append(&info.section_hdrs, elf.Section_Header{type = elf.SHT_NULL})
 	append(&info.section_hdrs, elf.Section_Header{
-		name   = 1,
-		type   = elf.SHT_PROGBITS,
-		flags  = elf.SHF_EXECINSTR,
-		addr   = 0x401000,
-		offset = 0,
-		size   = 4,
+		name = 1, type = elf.SHT_PROGBITS, flags = elf.SHF_EXECINSTR,
+		addr = 0x401000, offset = 0, size = 4,
 	})
 	append(&info.section_hdrs, elf.Section_Header{
-		name   = 7,
-		type   = elf.SHT_PROGBITS,
-		flags  = 0,
-		addr   = 0x402000,
-		offset = 4,
-		size   = 4,
+		name = 7, type = elf.SHT_PROGBITS, flags = 0,
+		addr = 0x402000, offset = 4, size = 4,
 	})
 
 	data := []u8{0x90, 0x90, 0xc3, 0x00, 0xde, 0xad, 0xbe, 0xef}
-	testing.expect_value(t, disasm_output(data, &info, path), "")
+	testing.expect_value(t, code_output(data, &info, path), "")
 
 	got := read_all(t, path)
 	defer delete(got)
-	testing.expect_value(t, got,
-		DISASM_HEAD +
-		".text" + SP16 + "0x00000000 0x00000004 0x0000000000401000\n" +
-		"  First 64 bytes (hex): 90 90 c3 00 \n" +
-		DISASM_FOOT)
+	testing.expect(t, strings.contains(got, ".text"), ".text must be listed")
 	testing.expect(t, !strings.contains(got, ".data"), "non-executable sections must be skipped")
 }
 
 @(test)
-test_disasm_output_caps_hex_preview_at_64_bytes :: proc(t: ^testing.T) {
-	path := tmp_path("disasm_cap")
+test_code_output_skips_nobits_and_out_of_file_sections :: proc(t: ^testing.T) {
+	path := tmp_path("code_nobits")
 	defer drop(path)
 
 	info := new_info()
@@ -507,90 +487,59 @@ test_disasm_output_caps_hex_preview_at_64_bytes :: proc(t: ^testing.T) {
 	info.shstrtab = SHSTRTAB[:]
 
 	append(&info.section_hdrs, elf.Section_Header{
-		name  = 1,
-		type  = elf.SHT_PROGBITS,
-		flags = elf.SHF_EXECINSTR,
-		size  = 200,
+		name = 1, type = elf.SHT_NOBITS, flags = elf.SHF_EXECINSTR, size = 100,
+	})
+	append(&info.section_hdrs, elf.Section_Header{
+		name = 7, type = elf.SHT_PROGBITS, flags = elf.SHF_EXECINSTR,
+		offset = 1000, size = 4,
 	})
 
-	data := make([]u8, 200)
-	defer delete(data)
-
-	testing.expect_value(t, disasm_output(data, &info, path), "")
+	data := []u8{1, 2, 3, 4}
+	testing.expect_value(t, code_output(data, &info, path), "")
 
 	got := read_all(t, path)
 	defer delete(got)
-
-	PREFIX :: "  First 64 bytes (hex): "
-	start := strings.index(got, PREFIX)
-	testing.expect(t, start >= 0, "hex preview line must be present")
-	if start < 0 {
-		return
-	}
-
-	rest := got[start + len(PREFIX):]
-	end := strings.index_byte(rest, '\n')
-	testing.expect(t, end >= 0, "hex preview line must be terminated")
-	if end < 0 {
-		return
-	}
-
-	fields := strings.fields(rest[:end], context.allocator)
-	defer delete(fields)
-	testing.expect_value(t, len(fields), 64)
+	testing.expect(t, !strings.contains(got, ".text"), "SHT_NOBITS must be skipped")
+	testing.expect(t, strings.contains(got, ".data"), "out-of-file section is still listed")
+	testing.expect(t, strings.contains(got, "0.00"), "unreadable section entropy is zero")
 }
 
 @(test)
-test_disasm_output_clamps_section_running_past_eof :: proc(t: ^testing.T) {
-	path := tmp_path("disasm_clamp")
+test_code_output_flags_entry_outside_pt_load :: proc(t: ^testing.T) {
+	path := tmp_path("code_entry_out")
 	defer drop(path)
 
 	info := new_info()
 	defer elf.destroy(&info)
-	info.shstrtab = SHSTRTAB[:]
-
-	append(&info.section_hdrs, elf.Section_Header{
-		name   = 1,
-		type   = elf.SHT_PROGBITS,
-		flags  = elf.SHF_EXECINSTR,
-		offset = 4,
-		size   = 100,
+	info.header.entry = 0xdeadbeef
+	append(&info.program_hdrs, elf.Program_Header{
+		type = elf.PT_LOAD, vaddr = 0x400000, memsz = 0x1000, flags = elf.PF_R | elf.PF_X,
 	})
 
-	data := []u8{0, 0, 0, 0, 0xaa, 0xbb}
-	testing.expect_value(t, disasm_output(data, &info, path), "")
+	testing.expect_value(t, code_output([]u8{}, &info, path), "")
 
 	got := read_all(t, path)
 	defer delete(got)
-	testing.expect(t, strings.contains(got, "  First 64 bytes (hex): aa bb \n"),
-		"preview must stop at end of file")
+	testing.expect(t, strings.contains(got, "outside every PT_LOAD"), "must flag unmapped entry")
 }
 
 @(test)
-test_disasm_output_skips_preview_for_section_beyond_eof :: proc(t: ^testing.T) {
-	path := tmp_path("disasm_beyond")
+test_code_output_flags_entry_in_non_executable_segment :: proc(t: ^testing.T) {
+	path := tmp_path("code_entry_nx")
 	defer drop(path)
 
 	info := new_info()
 	defer elf.destroy(&info)
-	info.shstrtab = SHSTRTAB[:]
-
-	append(&info.section_hdrs, elf.Section_Header{
-		name   = 1,
-		type   = elf.SHT_PROGBITS,
-		flags  = elf.SHF_EXECINSTR,
-		offset = 1000,
-		size   = 4,
+	info.header.entry = 0x400100
+	append(&info.program_hdrs, elf.Program_Header{
+		type = elf.PT_LOAD, vaddr = 0x400000, memsz = 0x1000, flags = elf.PF_R | elf.PF_W,
 	})
 
-	data := []u8{1, 2, 3, 4}
-	testing.expect_value(t, disasm_output(data, &info, path), "")
+	testing.expect_value(t, code_output([]u8{}, &info, path), "")
 
 	got := read_all(t, path)
 	defer delete(got)
-	testing.expect(t, strings.contains(got, ".text"), "section row must still be listed")
-	testing.expect(t, !strings.contains(got, "First 64 bytes"),
-		"out-of-file section must not print a preview")
+	testing.expect(t, strings.contains(got, "non-executable segment"), "must flag NX entry")
 }
 
 MAP_HEAD :: "ELF Memory Map\n" +
@@ -754,16 +703,20 @@ test_map_output_skips_bar_for_pt_null :: proc(t: ^testing.T) {
 	testing.expect(t, !strings.contains(got, "  [|"), "PT_NULL must not draw a bar")
 }
 
-// BUG: print_bar computes offset*50/u64(total) with total = len(data).
-// Empty data divides by zero and kills the process with SIGILL, so this test
-// cannot clean up after itself. It writes to /dev/null to leave nothing behind.
 @(test)
-test_map_output_empty_data_with_segment_divides_by_zero :: proc(t: ^testing.T) {
+test_map_output_empty_data_draws_no_bar :: proc(t: ^testing.T) {
+	path := tmp_path("map_empty_bar")
+	defer drop(path)
+
 	info := new_info()
 	defer elf.destroy(&info)
 	append(&info.program_hdrs, elf.Program_Header{type = elf.PT_LOAD, filesz = 1, memsz = 1})
 
-	testing.expect_value(t, map_output([]u8{}, &info, "/dev/null"), "")
+	testing.expect_value(t, map_output([]u8{}, &info, path), "")
+
+	got := read_all(t, path)
+	defer delete(got)
+	testing.expect(t, !strings.contains(got, "  [|"), "empty data must not draw a bar")
 }
 
 @(test)
@@ -805,26 +758,76 @@ test_map_output_reports_open_failure :: proc(t: ^testing.T) {
 }
 
 @(test)
-test_remap_output_stub_reaches_out_file :: proc(t: ^testing.T) {
-	path := tmp_path("remap_stub")
+test_bin_diff_missing_reference :: proc(t: ^testing.T) {
+	info := new_info()
+	defer elf.destroy(&info)
+
+	err := bin_diff_output([]u8{}, &info, "/draugr_no_such_file_4c1f", "")
+	testing.expect(t, strings.contains(err, "failed to read"), "missing reference must surface an error")
+}
+
+@(test)
+test_bin_diff_non_elf_reference :: proc(t: ^testing.T) {
+	path := tmp_path("bindiff_notelf")
 	defer drop(path)
+	_ = os.write_entire_file(path, []u8{'n', 'o', 'p', 'e'})
 
 	info := new_info()
 	defer elf.destroy(&info)
 
-	testing.expect_value(t, remap_output([]u8{}, &info, "/proto/path", path), "")
+	err := bin_diff_output([]u8{}, &info, path, "")
+	testing.expect(t, strings.contains(err, "not an ELF"), "non-ELF reference must surface an error")
+}
 
-	got := read_all(t, path)
+@(test)
+test_bin_diff_reports_section_and_dependency_changes :: proc(t: ^testing.T) {
+	ref_path := tmp_path("bindiff_ref")
+	defer drop(ref_path)
+	out_path := tmp_path("bindiff_out")
+	defer drop(out_path)
+
+	ref := elf.build_dynamic_elf(true, true, elf.DEFAULT_DYN_ENTRIES[:])
+	defer delete(ref)
+	_ = os.write_entire_file(ref_path, ref)
+
+	entries := [][2]u64{
+		{elf.DT_NEEDED, elf.DYN_OFF_NEEDED_1},
+		{elf.DT_STRTAB, elf.DYN_BASE + 0x200},
+		{elf.DT_STRSZ,  u64(len(elf.DYN_STR))},
+		{elf.DT_NULL,   0},
+	}
+	mine := elf.build_dynamic_elf(true, true, entries[:])
+	defer delete(mine)
+
+	info, _ := elf.load(mine)
+	defer elf.destroy(&info)
+
+	testing.expect_value(t, bin_diff_output(mine, &info, ref_path, out_path), "")
+
+	got := read_all(t, out_path)
 	defer delete(got)
-	testing.expect_value(t, got, "ELF Remapping (stub - prototype: /proto/path)\n")
+	testing.expect(t, strings.contains(got, "+ libc.so.6"), "reference-only dependency must show as added")
+	testing.expect(t, strings.contains(got, "/opt/runpath"), "runpath change must be reported")
 }
 
 @(test)
-test_remap_output_reports_open_failure :: proc(t: ^testing.T) {
-	info := new_info()
+test_bin_diff_identical_files_report_no_changes :: proc(t: ^testing.T) {
+	ref_path := tmp_path("bindiff_same_ref")
+	defer drop(ref_path)
+	out_path := tmp_path("bindiff_same_out")
+	defer drop(out_path)
+
+	img := elf.build_dynamic_elf(true, true, elf.DEFAULT_DYN_ENTRIES[:])
+	defer delete(img)
+	_ = os.write_entire_file(ref_path, img)
+
+	info, _ := elf.load(img)
 	defer elf.destroy(&info)
 
-	err := remap_output([]u8{}, &info, "/proto/path", "/draugr_no_such_dir_4c1f/remap.txt")
+	testing.expect_value(t, bin_diff_output(img, &info, ref_path, out_path), "")
 
-	testing.expect(t, err != "", "unwritable out_path must surface an error")
+	got := read_all(t, out_path)
+	defer delete(got)
+	testing.expect(t, strings.contains(got, "unchanged"), "identical dependencies must report unchanged")
+	testing.expect(t, !strings.contains(got, "->"), "identical files must show no deltas")
 }
diff --git a/analysis/bin_diff.odin b/analysis/bin_diff.odin
@@ -0,0 +1,200 @@
+package analysis
+
+import "core:fmt"
+import "core:os"
+import "core:slice"
+import "core:strings"
+
+import "../elf"
+
+Section_Delta :: struct {
+	name  : string,
+	a     : u64,
+	b     : u64,
+	delta : i64,
+}
+
+bin_diff_output :: proc(data: []u8, info: ^elf.ELF_Info, ref_path: string, out_path: string) -> string {
+	ref_data, read_err := os.read_entire_file_from_path(ref_path, context.allocator)
+	if read_err != nil {
+		return fmt.tprintf("failed to read reference file: %s", ref_path)
+	}
+	defer delete(ref_data)
+
+	if !elf.probe(ref_data) {
+		return fmt.tprintf("reference is not an ELF file: %s", ref_path)
+	}
+
+	ref, parse_err := elf.load(ref_data)
+	if parse_err != "" {
+		return fmt.tprintf("reference parse error: %s", parse_err)
+	}
+	defer elf.destroy(&ref)
+
+	f, err := open_out(out_path)
+	if err != "" {
+		return err
+	}
+	defer close_out(f, out_path)
+
+	fmt.fprintf(f, "Binary Diff\n")
+	fmt.fprintf(f, "===========\n\n")
+	fmt.fprintf(f, "reference: %s\n\n", ref_path)
+
+	diff_header(f, info, &ref, len(data), len(ref_data))
+	diff_segments(f, info, &ref)
+	diff_sections(f, info, &ref)
+	diff_needed(f, info, &ref)
+
+	return ""
+}
+
+diff_header :: proc(f: ^os.File, a: ^elf.ELF_Info, b: ^elf.ELF_Info, a_size: int, b_size: int) {
+	fmt.fprintf(f, "Header:\n")
+	row_str(f, "class", "ELF64" if a.is_64bit else "ELF32", "ELF64" if b.is_64bit else "ELF32")
+	row_str(f, "machine", elf.machine_to_str(a.header.machine), elf.machine_to_str(b.header.machine))
+	row_str(f, "type", elf.type_to_str(a.header.type), elf.type_to_str(b.header.type))
+	row_hex(f, "entry", a.header.entry, b.header.entry)
+	row_num(f, "file size", i64(a_size), i64(b_size))
+	fmt.fprintf(f, "\n")
+}
+
+diff_segments :: proc(f: ^os.File, a: ^elf.ELF_Info, b: ^elf.ELF_Info) {
+	fmt.fprintf(f, "Segments:\n")
+	row_num(f, "count", i64(len(a.program_hdrs)), i64(len(b.program_hdrs)))
+
+	n := min(len(a.program_hdrs), len(b.program_hdrs))
+	for i := 0; i < n; i += 1 {
+		pa, pb := a.program_hdrs[i], b.program_hdrs[i]
+		if pa.type == pb.type && pa.vaddr == pb.vaddr && pa.filesz == pb.filesz &&
+		   pa.memsz == pb.memsz && pa.flags == pb.flags {
+			continue
+		}
+		label := elf.segment_type_str(pa.type)
+		if pa.type != pb.type {
+			label = fmt.tprintf("%s -> %s", label, elf.segment_type_str(pb.type))
+		}
+		fmt.fprintf(f, "  ~ [%d] %s%s%s%s\n", i, label,
+			delta_hex("vaddr", pa.vaddr, pb.vaddr),
+			delta_hex("filesz", pa.filesz, pb.filesz),
+			delta_hex("memsz", pa.memsz, pb.memsz))
+	}
+	fmt.fprintf(f, "\n")
+}
+
+diff_sections :: proc(f: ^os.File, a: ^elf.ELF_Info, b: ^elf.ELF_Info) {
+	a_sizes := section_sizes(a)
+	defer delete(a_sizes)
+	b_sizes := section_sizes(b)
+	defer delete(b_sizes)
+
+	fmt.fprintf(f, "Sections:\n")
+
+	changed := make([dynamic]Section_Delta)
+	defer delete(changed)
+
+	for name, a_size in a_sizes {
+		b_size, in_b := b_sizes[name]
+		if !in_b {
+			fmt.fprintf(f, "  - %-24s 0x%x (removed)\n", name, a_size)
+			continue
+		}
+		if a_size != b_size {
+			append(&changed, Section_Delta{name, a_size, b_size, i64(b_size) - i64(a_size)})
+		}
+	}
+	for name, b_size in b_sizes {
+		if _, in_a := a_sizes[name]; !in_a {
+			fmt.fprintf(f, "  + %-24s 0x%x (added)\n", name, b_size)
+		}
+	}
+
+	slice.sort_by(changed[:], proc(x, y: Section_Delta) -> bool {
+		return abs_i64(x.delta) > abs_i64(y.delta)
+	})
+	for c in changed {
+		fmt.fprintf(f, "  ~ %-24s 0x%x -> 0x%x (%+d)\n", c.name, c.a, c.b, c.delta)
+	}
+
+	if len(changed) == 0 && len(a_sizes) == len(b_sizes) {
+		fmt.fprintf(f, "  no size changes\n")
+	}
+	fmt.fprintf(f, "\n")
+}
+
+section_sizes :: proc(info: ^elf.ELF_Info) -> map[string]u64 {
+	out := make(map[string]u64)
+	for shdr in info.section_hdrs {
+		name := elf.get_section_name(info, shdr)
+		if name == "" || shdr.type == elf.SHT_NULL {
+			continue
+		}
+		out[name] = shdr.size
+	}
+	return out
+}
+
+diff_needed :: proc(f: ^os.File, a: ^elf.ELF_Info, b: ^elf.ELF_Info) {
+	fmt.fprintf(f, "Dependencies:\n")
+	changed := false
+	for lib in a.needed {
+		if !slice.contains(b.needed[:], lib) {
+			fmt.fprintf(f, "  - %s\n", lib)
+			changed = true
+		}
+	}
+	for lib in b.needed {
+		if !slice.contains(a.needed[:], lib) {
+			fmt.fprintf(f, "  + %s\n", lib)
+			changed = true
+		}
+	}
+	if a.runpath != b.runpath {
+		row_str(f, "runpath", a.runpath, b.runpath)
+		changed = true
+	}
+	if a.rpath != b.rpath {
+		row_str(f, "rpath", a.rpath, b.rpath)
+		changed = true
+	}
+	if !changed {
+		fmt.fprintf(f, "  unchanged\n")
+	}
+	fmt.fprintf(f, "\n")
+}
+
+delta_hex :: proc(label: string, a: u64, b: u64) -> string {
+	if a == b {
+		return ""
+	}
+	return fmt.tprintf("  %s 0x%x -> 0x%x", label, a, b)
+}
+
+abs_i64 :: proc(v: i64) -> i64 {
+	return -v if v < 0 else v
+}
+
+row_str :: proc(f: ^os.File, label: string, a: string, b: string) {
+	mark := " " if a == b else "~"
+	if a == b {
+		fmt.fprintf(f, "  %s %-12s %s\n", mark, label, a)
+	} else {
+		fmt.fprintf(f, "  %s %-12s %s -> %s\n", mark, label, a, b)
+	}
+}
+
+row_hex :: proc(f: ^os.File, label: string, a: u64, b: u64) {
+	if a == b {
+		fmt.fprintf(f, "    %-12s 0x%x\n", label, a)
+	} else {
+		fmt.fprintf(f, "  ~ %-12s 0x%x -> 0x%x\n", label, a, b)
+	}
+}
+
+row_num :: proc(f: ^os.File, label: string, a: i64, b: i64) {
+	if a == b {
+		fmt.fprintf(f, "    %-12s %d\n", label, a)
+	} else {
+		fmt.fprintf(f, "  ~ %-12s %d -> %d (%+d)\n", label, a, b, b - a)
+	}
+}
diff --git a/analysis/call.odin b/analysis/call.odin
@@ -0,0 +1,125 @@
+package analysis
+
+import "core:c"
+import "core:fmt"
+import "core:os"
+import "core:strconv"
+import "core:strings"
+import "core:sys/linux"
+import "core:sys/posix"
+
+import "../elf"
+
+foreign import libc "system:c"
+
+@(default_calling_convention="c")
+foreign libc {
+	system :: proc(command: cstring) -> c.int ---
+}
+
+Callee :: #type proc "c" (u64, u64, u64, u64, u64, u64) -> u64
+
+// 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 {
+	fd, err := linux.memfd_create("draugr-call", {})
+	if err != .NONE {
+		return fmt.tprintf("memfd_create failed: %v", err)
+	}
+	defer linux.close(fd)
+
+	fd_path := fmt.tprintf("/proc/self/fd/%d", int(fd))
+
+	if info.header.type == elf.ET_REL {
+		cmd := fmt.ctprintf("ld -shared -o %s %s", fd_path, path)
+		if system(cmd) != 0 {
+			return fmt.tprintf("failed to link %s into a shared object", path)
+		}
+	} else {
+		if _, werr := linux.write(fd, data); werr != .NONE {
+			return fmt.tprintf("writing to memfd failed: %v", werr)
+		}
+	}
+
+	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()
+	if ferr != .NONE {
+		return fmt.tprintf("fork failed: %v", ferr)
+	}
+
+	if pid == 0 {
+		run_callee(fd_path, symbol, args)
+	}
+
+	status: u32
+	if _, werr := linux.waitpid(pid, &status, {}, nil); werr != .NONE {
+		return fmt.tprintf("waitpid failed: %v", werr)
+	}
+	return report_status(status)
+}
+
+run_callee :: proc(fd_path: string, symbol: string, args: []u64) -> ! {
+	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)
+	}
+
+	sym := posix.dlsym(handle, strings.clone_to_cstring(symbol, context.temp_allocator))
+	if sym == nil {
+		fmt.fprintf(os.stderr, "dlsym: %s\n", posix.dlerror())
+		linux.exit(102)
+	}
+
+	fn := cast(Callee)sym
+	result := fn(args[0], args[1], args[2], args[3], args[4], args[5])
+
+	fmt.printf("%s returned %d (0x%x)\n", symbol, i64(result), result)
+	os.flush(os.stdout)
+	linux.exit(0)
+}
+
+report_status :: proc(status: u32) -> string {
+	if status & 0x7f == 0 {
+		code := (status >> 8) & 0xff
+		switch code {
+		case 0:   return ""
+		case 101: return "dlopen failed, see above"
+		case 102: return "symbol not found, see above"
+		}
+		return fmt.tprintf("callee exited with status %d", code)
+	}
+	return fmt.tprintf("callee died on signal %d", status & 0x7f)
+}
+
+parse_call_args :: proc(raw: string) -> ([]u64, string) {
+	args := make([]u64, 6)
+	if raw == "" {
+		return args, ""
+	}
+
+	fields := strings.split(raw, ",", context.temp_allocator)
+	if len(fields) > 6 {
+		return args, "at most 6 arguments are supported"
+	}
+
+	for field, i in fields {
+		f := strings.trim_space(field)
+		if strings.has_prefix(f, "s:") {
+			args[i] = u64(uintptr(rawptr(strings.clone_to_cstring(f[2:]))))
+			continue
+		}
+		v, ok := strconv.parse_i64_maybe_prefixed(f)
+		if !ok {
+			return args, fmt.tprintf("argument %d is not an integer or s:string: %s", i, f)
+		}
+		args[i] = u64(v)
+	}
+	return args, ""
+}
diff --git a/analysis/call_test.odin b/analysis/call_test.odin
@@ -0,0 +1,59 @@
+package analysis
+
+import "core:testing"
+
+@(test)
+test_call_args_default_to_zero :: proc(t: ^testing.T) {
+	args, err := parse_call_args("")
+	defer delete(args)
+
+	testing.expect_value(t, err, "")
+	testing.expect_value(t, len(args), 6)
+	for a in args {
+		testing.expect_value(t, a, 0)
+	}
+}
+
+@(test)
+test_call_args_parse_decimal_and_hex :: proc(t: ^testing.T) {
+	args, err := parse_call_args("42, 0x2a, -1")
+	defer delete(args)
+
+	testing.expect_value(t, err, "")
+	testing.expect_value(t, args[0], 42)
+	testing.expect_value(t, args[1], 42)
+	testing.expect_value(t, i64(args[2]), -1)
+	testing.expect_value(t, args[3], 0)
+}
+
+@(test)
+test_call_args_string_becomes_a_pointer :: proc(t: ^testing.T) {
+	args, err := parse_call_args("s:hello")
+	defer delete(args)
+
+	testing.expect_value(t, err, "")
+	testing.expect(t, args[0] != 0, "s: argument must produce a non-null pointer")
+
+	str := cstring(rawptr(uintptr(args[0])))
+	testing.expect_value(t, string(str), "hello")
+}
+
+@(test)
+test_call_args_reject_junk_and_overflow :: proc(t: ^testing.T) {
+	junk, err := parse_call_args("notanumber")
+	defer delete(junk)
+	testing.expect(t, err != "", "non-numeric argument must be rejected")
+
+	too_many, err2 := parse_call_args("1,2,3,4,5,6,7")
+	defer delete(too_many)
+	testing.expect(t, err2 != "", "more than 6 arguments must be rejected")
+}
+
+@(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")
+	testing.expect_value(t, report_status(102 << 8), "symbol not found, see above")
+	testing.expect(t, report_status(3 << 8) != "", "a non-zero exit must be an error")
+	testing.expect_value(t, report_status(11), "callee died on signal 11")
+}
diff --git a/analysis/code.odin b/analysis/code.odin
@@ -0,0 +1,219 @@
+package analysis
+
+import "core:fmt"
+import "core:os"
+
+import "../elf"
+
+code_output :: proc(data: []u8, info: ^elf.ELF_Info, out_path: string) -> string {
+	f, err := open_out(out_path)
+	if err != "" {
+		return err
+	}
+	defer close_out(f, out_path)
+
+	fmt.fprintf(f, "Code Regions\n")
+	fmt.fprintf(f, "============\n\n")
+
+	print_exec_regions(f, data, info)
+	print_entry_analysis(f, info)
+	print_function_starts(f, data, info)
+
+	return ""
+}
+
+print_exec_regions :: proc(f: ^os.File, data: []u8, info: ^elf.ELF_Info) {
+	fmt.fprintf(f, "Executable Sections:\n")
+	fmt.fprintf(f, "%-20s %-18s %-12s %-12s %s\n", "Name", "VAddr", "Offset", "Size", "Entropy")
+
+	found := false
+	for shdr in info.section_hdrs {
+		if shdr.flags & elf.SHF_EXECINSTR == 0 || shdr.type == elf.SHT_NOBITS {
+			continue
+		}
+		found = true
+		fmt.fprintf(f, "%-20s 0x%016x 0x%-10x 0x%-10x %.2f\n",
+			elf.get_section_name(info, shdr), shdr.addr, shdr.offset, shdr.size,
+			region_entropy(data, shdr.offset, shdr.size))
+	}
+
+	if !found {
+		fmt.fprintf(f, "  (no section headers, falling back to executable segments)\n")
+		for ph in info.program_hdrs {
+			if ph.type != elf.PT_LOAD || ph.flags & elf.PF_X == 0 {
+				continue
+			}
+			fmt.fprintf(f, "%-20s 0x%016x 0x%-10x 0x%-10x %.2f\n",
+				"PT_LOAD", ph.vaddr, ph.offset, ph.filesz,
+				region_entropy(data, ph.offset, ph.filesz))
+		}
+	}
+	fmt.fprintf(f, "\n")
+}
+
+region_entropy :: proc(data: []u8, offset: u64, size: u64) -> f64 {
+	if !elf.in_bounds(data, offset, size) || size == 0 {
+		return 0.0
+	}
+	return calculate_entropy(data[offset : offset + size])
+}
+
+print_entry_analysis :: proc(f: ^os.File, info: ^elf.ELF_Info) {
+	entry := info.header.entry
+	fmt.fprintf(f, "Entry Point: 0x%016x\n", entry)
+
+	for shdr in info.section_hdrs {
+		if shdr.addr != 0 && entry >= shdr.addr && entry - shdr.addr < shdr.size {
+			fmt.fprintf(f, "  in section: %s\n", elf.get_section_name(info, shdr))
+			break
+		}
+	}
+
+	executable := false
+	mapped := false
+	for ph in info.program_hdrs {
+		if ph.type != elf.PT_LOAD || ph.memsz == 0 {
+			continue
+		}
+		if entry >= ph.vaddr && entry - ph.vaddr < ph.memsz {
+			mapped = true
+			if ph.flags & elf.PF_X != 0 {
+				executable = true
+			}
+		}
+	}
+
+	switch {
+	case !mapped:
+		fmt.fprintf(f, "  WARNING: entry point is outside every PT_LOAD\n")
+	case !executable:
+		fmt.fprintf(f, "  WARNING: entry point is in a non-executable segment\n")
+	case:
+		fmt.fprintf(f, "  in an executable segment\n")
+	}
+	fmt.fprintf(f, "\n")
+}
+
+DW_EH_PE_OMIT :: 0xff
+
+DW_EH_PE_ABSPTR :: 0x00
+DW_EH_PE_UDATA2 :: 0x02
+DW_EH_PE_UDATA4 :: 0x03
+DW_EH_PE_UDATA8 :: 0x04
+DW_EH_PE_SDATA2 :: 0x0a
+DW_EH_PE_SDATA4 :: 0x0b
+DW_EH_PE_SDATA8 :: 0x0c
+
+DW_EH_PE_PCREL   :: 0x10
+DW_EH_PE_DATAREL :: 0x30
+
+// .eh_frame_hdr holds a sorted table of function start addresses, and it
+// survives stripping.
+print_function_starts :: proc(f: ^os.File, data: []u8, info: ^elf.ELF_Info) {
+	hdr_vaddr, hdr_off, hdr_size, ok := find_eh_frame_hdr(info)
+	if !ok || !elf.in_bounds(data, hdr_off, hdr_size) {
+		fmt.fprintf(f, "Function Starts: no PT_GNU_EH_FRAME\n\n")
+		return
+	}
+
+	starts, err := eh_frame_starts(data, info, hdr_vaddr, hdr_off, hdr_size)
+	defer delete(starts)
+
+	if err != "" {
+		fmt.fprintf(f, "Function Starts: %s\n\n", err)
+		return
+	}
+
+	fmt.fprintf(f, "Function Starts: %d (from .eh_frame_hdr)\n", len(starts))
+	shown := min(len(starts), 10)
+	for i := 0; i < shown; i += 1 {
+		fmt.fprintf(f, "  0x%016x\n", starts[i])
+	}
+	if len(starts) > shown {
+		fmt.fprintf(f, "  ... %d more\n", len(starts) - shown)
+	}
+	fmt.fprintf(f, "\n")
+}
+
+find_eh_frame_hdr :: proc(info: ^elf.ELF_Info) -> (vaddr: u64, offset: u64, size: u64, ok: bool) {
+	for ph in info.program_hdrs {
+		if ph.type == elf.PT_GNU_EH_FRAME && ph.filesz > 0 {
+			return ph.vaddr, ph.offset, ph.filesz, true
+		}
+	}
+	return 0, 0, 0, false
+}
+
+eh_frame_starts :: proc(data: []u8, info: ^elf.ELF_Info, hdr_vaddr, hdr_off, hdr_size: u64) -> ([dynamic]u64, string) {
+	starts := make([dynamic]u64)
+	order := elf.byte_order(info.header)
+
+	buf := data[hdr_off : hdr_off + hdr_size]
+	if len(buf) < 4 {
+		return starts, "header too small"
+	}
+	if buf[0] != 1 {
+		return starts, fmt.tprintf("unsupported version %d", buf[0])
+	}
+
+	frame_enc := buf[1]
+	count_enc := buf[2]
+	table_enc := buf[3]
+	pos := 4
+
+	if count_enc == DW_EH_PE_OMIT || table_enc == DW_EH_PE_OMIT {
+		return starts, "no binary search table"
+	}
+	if _, e := read_encoded(buf, &pos, frame_enc, hdr_vaddr, hdr_vaddr, order, info.is_64bit); e != "" {
+		return starts, e
+	}
+	count, cerr := read_encoded(buf, &pos, count_enc, hdr_vaddr, hdr_vaddr, order, info.is_64bit)
+	if cerr != "" {
+		return starts, cerr
+	}
+
+	for i := u64(0); i < count; i += 1 {
+		loc, lerr := read_encoded(buf, &pos, table_enc, hdr_vaddr, hdr_vaddr, order, info.is_64bit)
+		if lerr != "" {
+			return starts, lerr
+		}
+		if _, aerr := read_encoded(buf, &pos, table_enc, hdr_vaddr, hdr_vaddr, order, info.is_64bit); aerr != "" {
+			return starts, aerr
+		}
+		append(&starts, loc)
+	}
+	return starts, ""
+}
+
+read_encoded :: proc(buf: []u8, pos: ^int, enc: u8, pc_vaddr, data_vaddr: u64, order: elf.Byte_Order, is_64bit: bool) -> (u64, string) {
+	base := u64(0)
+	switch enc & 0x70 {
+	case 0:                 base = 0
+	case DW_EH_PE_PCREL:    base = pc_vaddr + u64(pos^)
+	case DW_EH_PE_DATAREL:  base = data_vaddr
+	case:                   return 0, fmt.tprintf("unsupported pointer application 0x%x", enc & 0x70)
+	}
+
+	format := enc & 0x0f
+	if format == DW_EH_PE_ABSPTR {
+		format = DW_EH_PE_UDATA8 if is_64bit else DW_EH_PE_UDATA4
+	}
+
+	value := u64(0)
+	size := 0
+	switch format {
+	case DW_EH_PE_UDATA2: value = u64(elf.read_u16(buf, pos^, order));               size = 2
+	case DW_EH_PE_UDATA4: value = u64(elf.read_u32(buf, pos^, order));               size = 4
+	case DW_EH_PE_UDATA8: value = elf.read_u64(buf, pos^, order);                    size = 8
+	case DW_EH_PE_SDATA2: value = u64(i64(i16(elf.read_u16(buf, pos^, order))));     size = 2
+	case DW_EH_PE_SDATA4: value = u64(i64(i32(elf.read_u32(buf, pos^, order))));     size = 4
+	case DW_EH_PE_SDATA8: value = elf.read_u64(buf, pos^, order);                    size = 8
+	case:                 return 0, fmt.tprintf("unsupported pointer format 0x%x", format)
+	}
+
+	if pos^ + size > len(buf) {
+		return 0, "truncated .eh_frame_hdr"
+	}
+	pos^ += size
+	return base + value, ""
+}
diff --git a/analysis/disasm.odin b/analysis/disasm.odin
@@ -1,57 +0,0 @@
-package analysis
-
-import "core:fmt"
-import "core:os"
-
-import "../elf"
-
-disasm_output :: proc(data: []u8, info: ^elf.ELF_Info, out_path: string) -> string {
-	f, err := open_out(out_path)
-	if err != "" {
-		return err
-	}
-	defer close_out(f, out_path)
-
-	fmt.fprintf(f, "Disassembly Analysis\n")
-	fmt.fprintf(f, "====================\n\n")
-
-	fmt.fprintf(f, "Executable Sections:\n")
-	fmt.fprintf(f, "%-20s %-12s %-12s %s\n", "Name", "Offset", "Size", "VAddr")
-	fmt.fprintf(f, "%s\n", "-------------------------------------------------")
-
-	for i := 0; i < len(info.section_hdrs); i += 1 {
-		shdr := info.section_hdrs[i]
-		if shdr.type == elf.SHT_NULL {
-			continue
-		}
-
-		name := elf.get_section_name(info, shdr)
-
-		if shdr.flags & elf.SHF_EXECINSTR != 0 {
-			fmt.fprintf(f, "%-20s 0x%08x 0x%08x 0x%016x\n", 
-				name, shdr.offset, shdr.size, shdr.addr)
-
-		if int(shdr.offset) < len(data) {
-			end := int(shdr.offset + shdr.size)
-			if end > len(data) {
-				end = len(data)
-			}
-				section_data := data[int(shdr.offset):end]
-
-				fmt.fprintf(f, "  First 64 bytes (hex): ")
-				for j := 0; j < len(section_data); j += 1 {
-					if j >= 64 {
-						break
-					}
-					fmt.fprintf(f, "%02x ", section_data[j])
-				}
-				fmt.fprintf(f, "\n")
-			}
-		}
-	}
-
-	fmt.fprintf(f, "\nNote: Full disassembly requires external disassembler\n")
-	fmt.fprintf(f, "Consider integrating zydis or capstone for x86/x64\n")
-
-	return ""
-}
diff --git a/analysis/hexdump.odin b/analysis/hexdump.odin
@@ -17,7 +17,6 @@ default_config :: proc() -> HexdumpConfig {
 	}
 }
 
-// Returns empty string on success, error message on failure
 hexdump :: proc(data: []u8, offset: u64, out_path: string) -> string {
 	cfg := default_config()
 
diff --git a/analysis/mapper.odin b/analysis/mapper.odin
@@ -74,17 +74,6 @@ map_output :: proc(data: []u8, info: ^elf.ELF_Info, out_path: string) -> string 
 	return ""
 }
 
-remap_output :: proc(data: []u8, info: ^elf.ELF_Info, proto_path: string, out_path: string) -> string {
-	f, err := open_out(out_path)
-	if err != "" {
-		return err
-	}
-	defer close_out(f, out_path)
-
-	fmt.fprintf(f, "ELF Remapping (stub - prototype: %s)\n", proto_path)
-	return ""
-}
-
 print_bar :: proc(f: ^os.File, offset: u64, size: u64, total: int) {
 	if total <= 0 {
 		return
diff --git a/elf/checksec.odin b/elf/checksec.odin
@@ -0,0 +1,216 @@
+package elf
+
+import "core:strings"
+
+Relro :: enum { None, Partial, Full }
+Nx    :: enum { Unknown, Enabled, Disabled }
+Pie   :: enum { None, Pie, Static_Pie, Shared_Library, Rel }
+
+Checksec :: struct {
+	relro       : Relro,
+	nx          : Nx,
+	pie         : Pie,
+	canary      : bool,
+	textrel     : bool,
+	rpath       : string,
+	runpath     : string,
+	rwx_segment : bool,
+	fortified   : int,
+	fortifiable : int,
+	partial     : int,
+	stripped    : bool,
+}
+
+// Derived from glibc's exported __*_chk symbols.
+FORTIFIABLE := [?]string{
+	"asprintf", "confstr", "dprintf", "explicit_bzero", "fdelt", "fgets", "fgets_unlocked",
+	"fgetws", "fgetws_unlocked", "fprintf", "fread", "fread_unlocked", "fwprintf", "getcwd",
+	"getdomainname", "getgroups", "gethostname", "getlogin_r", "gets", "getwd", "inet_ntop",
+	"inet_pton", "longjmp", "mbsnrtowcs", "mbsrtowcs", "mbstowcs", "memcpy", "memmove",
+	"mempcpy", "memset", "memset_explicit", "obstack_printf", "obstack_vprintf", "poll",
+	"ppoll", "pread", "pread64", "printf", "ptsname_r", "read", "readlink", "readlinkat",
+	"realpath", "recv", "recvfrom", "snprintf", "sprintf", "stpcpy", "stpncpy", "strcat",
+	"strcpy", "strlcat", "strlcpy", "strncat", "strncpy", "swprintf", "syslog", "ttyname_r",
+	"vasprintf", "vdprintf", "vfprintf", "vfwprintf", "vprintf", "vsnprintf", "vsprintf",
+	"vswprintf", "vsyslog", "vwprintf", "wcpcpy", "wcpncpy", "wcrtomb", "wcscat", "wcscpy",
+	"wcslcat", "wcslcpy", "wcsncat", "wcsncpy", "wcsnrtombs", "wcsrtombs", "wcstombs", "wctomb",
+	"wmemcpy", "wmemmove", "wmempcpy", "wmemset", "wprintf",
+}
+
+checksec :: proc(info: ^ELF_Info) -> Checksec {
+	c: Checksec
+
+	c.relro = relro_state(info)
+	c.nx = nx_state(info)
+	c.pie = pie_state(info)
+	c.canary = has_symbol(info, "__stack_chk_fail") || has_symbol(info, "__stack_chk_guard")
+	c.textrel = has_textrel(info)
+	c.rpath = info.rpath
+	c.runpath = info.runpath
+	c.rwx_segment = has_rwx_segment(info)
+	c.stripped = len(info.symbols) == 0
+	c.fortified, c.fortifiable, c.partial = fortify_counts(info)
+
+	return c
+}
+
+relro_state :: proc(info: ^ELF_Info) -> Relro {
+	relro := false
+	for ph in info.program_hdrs {
+		if ph.type == PT_GNU_RELRO {
+			relro = true
+			break
+		}
+	}
+	if !relro {
+		return .None
+	}
+
+	if _, ok := dyn_val(info, DT_BIND_NOW); ok {
+		return .Full
+	}
+	if flags, ok := dyn_val(info, DT_FLAGS); ok && flags & DF_BIND_NOW != 0 {
+		return .Full
+	}
+	if flags1, ok := dyn_val(info, DT_FLAGS_1); ok && flags1 & DF_1_NOW != 0 {
+		return .Full
+	}
+	return .Partial
+}
+
+// An absent PT_GNU_STACK is not the same as NX on: the kernel falls back to a
+// build-time default, so the honest answer is unknown.
+nx_state :: proc(info: ^ELF_Info) -> Nx {
+	for ph in info.program_hdrs {
+		if ph.type == PT_GNU_STACK {
+			return .Disabled if ph.flags & PF_X != 0 else .Enabled
+		}
+	}
+	return .Unknown
+}
+
+// ET_DYN alone does not mean PIE: shared libraries are ET_DYN too.
+pie_state :: proc(info: ^ELF_Info) -> Pie {
+	if info.header.type == ET_REL {
+		return .Rel
+	}
+	if info.header.type != ET_DYN {
+		return .None
+	}
+
+	has_interp := false
+	for ph in info.program_hdrs {
+		if ph.type == PT_INTERP {
+			has_interp = true
+			break
+		}
+	}
+	if has_interp {
+		return .Pie
+	}
+
+	flags1, _ := dyn_val(info, DT_FLAGS_1)
+	if flags1 & DF_1_PIE != 0 {
+		return .Static_Pie
+	}
+	if _, ok := dyn_val(info, DT_SONAME); ok {
+		return .Shared_Library
+	}
+	return .Shared_Library
+}
+
+has_textrel :: proc(info: ^ELF_Info) -> bool {
+	if _, ok := dyn_val(info, DT_TEXTREL); ok {
+		return true
+	}
+	flags, _ := dyn_val(info, DT_FLAGS)
+	return flags & DF_TEXTREL != 0
+}
+
+has_rwx_segment :: proc(info: ^ELF_Info) -> bool {
+	for ph in info.program_hdrs {
+		if ph.type != PT_LOAD {
+			continue
+		}
+		if ph.flags & PF_W != 0 && ph.flags & PF_X != 0 {
+			return true
+		}
+	}
+	return false
+}
+
+has_symbol :: proc(info: ^ELF_Info, name: string) -> bool {
+	for sym in info.dyn_symbols {
+		if sym.name == name {
+			return true
+		}
+	}
+	for sym in info.symbols {
+		if sym.name == name {
+			return true
+		}
+	}
+	return false
+}
+
+// __stack_chk_fail is the canary, not a fortified call, so it is excluded.
+fortify_counts :: proc(info: ^ELF_Info) -> (fortified: int, fortifiable: int, partial: int) {
+	seen_chk := make(map[string]bool)
+	seen_raw := make(map[string]bool)
+	defer delete(seen_chk)
+	defer delete(seen_raw)
+
+	for sym in info.dyn_symbols {
+		if sym.name == "" || sym.name == "__stack_chk_fail" || sym.name == "__stack_chk_guard" {
+			continue
+		}
+		if strings.has_prefix(sym.name, "__") && strings.has_suffix(sym.name, "_chk") {
+			seen_chk[sym.name[2 : len(sym.name) - 4]] = true
+		} else {
+			seen_raw[sym.name] = true
+		}
+	}
+
+	for name in FORTIFIABLE {
+		switch {
+		case seen_chk[name]:
+			fortified += 1
+			fortifiable += 1
+			if seen_raw[name] {
+				partial += 1
+			}
+		case seen_raw[name]:
+			fortifiable += 1
+		}
+	}
+	return
+}
+
+relro_str :: proc(r: Relro) -> string {
+	switch r {
+	case .Full:    return "Full"
+	case .Partial: return "Partial"
+	case .None:    return "None"
+	}
+	return "None"
+}
+
+nx_str :: proc(n: Nx) -> string {
+	switch n {
+	case .Enabled:  return "Enabled"
+	case .Disabled: return "Disabled"
+	case .Unknown:  return "Unknown (no PT_GNU_STACK)"
+	}
+	return "Unknown"
+}
+
+pie_str :: proc(p: Pie) -> string {
+	switch p {
+	case .Pie:            return "PIE"
+	case .Static_Pie:     return "Static PIE"
+	case .Shared_Library: return "Shared library (not an executable)"
+	case .Rel:            return "Relocatable object"
+	case .None:           return "None (ET_EXEC)"
+	}
+	return "None"
+}
diff --git a/elf/checksec_test.odin b/elf/checksec_test.odin
@@ -0,0 +1,113 @@
+package elf
+
+import "core:testing"
+
+checksec_info :: proc(phdrs: []Program_Header, dyn: [][2]u64) -> ELF_Info {
+	info: ELF_Info
+	info.program_hdrs = make([dynamic]Program_Header)
+	info.section_hdrs = make([dynamic]Section_Header)
+	info.symbols = make([dynamic]Symbol)
+	info.dyn_symbols = make([dynamic]Symbol)
+	info.dyn_entries = make([dynamic]Dyn_Entry)
+	info.needed = make([dynamic]string)
+	info.relocs = make([dynamic]Reloc)
+	info.header.type = ET_DYN
+
+	for p in phdrs {
+		append(&info.program_hdrs, p)
+	}
+	for e in dyn {
+		append(&info.dyn_entries, Dyn_Entry{tag = e[0], val = e[1]})
+	}
+	return info
+}
+
+@(test)
+test_relro_needs_bind_now_for_full :: proc(t: ^testing.T) {
+	none := checksec_info({}, {})
+	defer destroy(&none)
+	testing.expect_value(t, relro_state(&none), Relro.None)
+
+	partial := checksec_info({{type = PT_GNU_RELRO}}, {})
+	defer destroy(&partial)
+	testing.expect_value(t, relro_state(&partial), Relro.Partial)
+
+	full := checksec_info({{type = PT_GNU_RELRO}}, {{DT_FLAGS, DF_BIND_NOW}})
+	defer destroy(&full)
+	testing.expect_value(t, relro_state(&full), Relro.Full)
+
+	full1 := checksec_info({{type = PT_GNU_RELRO}}, {{DT_FLAGS_1, DF_1_NOW}})
+	defer destroy(&full1)
+	testing.expect_value(t, relro_state(&full1), Relro.Full)
+}
+
+@(test)
+test_nx_absent_segment_is_unknown :: proc(t: ^testing.T) {
+	unknown := checksec_info({}, {})
+	defer destroy(&unknown)
+	testing.expect_value(t, nx_state(&unknown), Nx.Unknown)
+
+	on := checksec_info({{type = PT_GNU_STACK, flags = PF_R | PF_W}}, {})
+	defer destroy(&on)
+	testing.expect_value(t, nx_state(&on), Nx.Enabled)
+
+	off := checksec_info({{type = PT_GNU_STACK, flags = PF_R | PF_W | PF_X}}, {})
+	defer destroy(&off)
+	testing.expect_value(t, nx_state(&off), Nx.Disabled)
+}
+
+@(test)
+test_pie_distinguishes_shared_library_from_executable :: proc(t: ^testing.T) {
+	pie := checksec_info({{type = PT_INTERP}}, {})
+	defer destroy(&pie)
+	testing.expect_value(t, pie_state(&pie), Pie.Pie)
+
+	lib := checksec_info({}, {{DT_SONAME, 1}})
+	defer destroy(&lib)
+	testing.expect_value(t, pie_state(&lib), Pie.Shared_Library)
+
+	spie := checksec_info({}, {{DT_FLAGS_1, DF_1_PIE}})
+	defer destroy(&spie)
+	testing.expect_value(t, pie_state(&spie), Pie.Static_Pie)
+
+	exe := checksec_info({}, {})
+	defer destroy(&exe)
+	exe.header.type = ET_EXEC
+	testing.expect_value(t, pie_state(&exe), Pie.None)
+}
+
+@(test)
+test_rwx_and_textrel_are_flagged :: proc(t: ^testing.T) {
+	clean := checksec_info({{type = PT_LOAD, flags = PF_R | PF_X}}, {})
+	defer destroy(&clean)
+	testing.expect(t, !has_rwx_segment(&clean), "R X segment is not RWX")
+	testing.expect(t, !has_textrel(&clean), "no DT_TEXTREL")
+
+	rwx := checksec_info({{type = PT_LOAD, flags = PF_R | PF_W | PF_X}}, {{DT_TEXTREL, 0}})
+	defer destroy(&rwx)
+	testing.expect(t, has_rwx_segment(&rwx), "RWX PT_LOAD must be flagged")
+	testing.expect(t, has_textrel(&rwx), "DT_TEXTREL must be flagged")
+
+	flagged := checksec_info({}, {{DT_FLAGS, DF_TEXTREL}})
+	defer destroy(&flagged)
+	testing.expect(t, has_textrel(&flagged), "DF_TEXTREL must be flagged")
+}
+
+@(test)
+test_fortify_excludes_stack_canary :: proc(t: ^testing.T) {
+	info := checksec_info({}, {})
+	defer destroy(&info)
+
+	append(&info.dyn_symbols, Symbol{name = "__stack_chk_fail"})
+	append(&info.dyn_symbols, Symbol{name = "__memcpy_chk"})
+	append(&info.dyn_symbols, Symbol{name = "memcpy"})
+	append(&info.dyn_symbols, Symbol{name = "strcpy"})
+
+	fortified, fortifiable, partial := fortify_counts(&info)
+	testing.expect_value(t, fortified, 1)
+	testing.expect_value(t, fortifiable, 2)
+	testing.expect_value(t, partial, 1)
+
+	c := checksec(&info)
+	testing.expect(t, c.canary, "__stack_chk_fail must still set the canary flag")
+}
diff --git a/elf/dynamic.odin b/elf/dynamic.odin
@@ -0,0 +1,241 @@
+package elf
+
+import "core:fmt"
+
+DT_NULL         :: 0
+DT_NEEDED       :: 1
+DT_PLTRELSZ     :: 2
+DT_PLTGOT       :: 3
+DT_HASH         :: 4
+DT_STRTAB       :: 5
+DT_SYMTAB       :: 6
+DT_RELA         :: 7
+DT_RELASZ       :: 8
+DT_RELAENT      :: 9
+DT_STRSZ        :: 10
+DT_SYMENT       :: 11
+DT_INIT         :: 12
+DT_FINI         :: 13
+DT_SONAME       :: 14
+DT_RPATH        :: 15
+DT_SYMBOLIC     :: 16
+DT_REL          :: 17
+DT_RELSZ        :: 18
+DT_RELENT       :: 19
+DT_PLTREL       :: 20
+DT_DEBUG        :: 21
+DT_TEXTREL      :: 22
+DT_JMPREL       :: 23
+DT_BIND_NOW     :: 24
+DT_INIT_ARRAY   :: 25
+DT_FINI_ARRAY   :: 26
+DT_INIT_ARRAYSZ :: 27
+DT_FINI_ARRAYSZ :: 28
+DT_RUNPATH      :: 29
+DT_FLAGS        :: 30
+
+DT_PREINIT_ARRAY   :: 32
+DT_PREINIT_ARRAYSZ :: 33
+DT_SYMTAB_SHNDX    :: 34
+DT_RELRSZ          :: 35
+DT_RELR            :: 36
+DT_RELRENT         :: 37
+
+DT_GNU_HASH     :: 0x6ffffef5
+DT_TLSDESC_PLT  :: 0x6ffffef6
+DT_TLSDESC_GOT  :: 0x6ffffef7
+DT_VERSYM       :: 0x6ffffff0
+DT_RELACOUNT    :: 0x6ffffff9
+DT_RELCOUNT     :: 0x6ffffffa
+DT_FLAGS_1      :: 0x6ffffffb
+DT_VERDEF       :: 0x6ffffffc
+DT_VERDEFNUM    :: 0x6ffffffd
+DT_VERNEED      :: 0x6ffffffe
+DT_VERNEEDNUM   :: 0x6fffffff
+DT_AUXILIARY    :: 0x7ffffffd
+DT_FILTER       :: 0x7fffffff
+
+DF_TEXTREL  :: 0x4
+DF_BIND_NOW :: 0x8
+
+DF_1_NOW :: 0x1
+DF_1_PIE :: 0x08000000
+
+DYN_SIZE_64 :: 16
+DYN_SIZE_32 :: 8
+
+Dyn_Entry :: struct {
+	tag : u64,
+	val : u64,
+}
+
+dyn_tag_str :: proc(tag: u64) -> string {
+	switch tag {
+	case DT_NULL:         return "NULL"
+	case DT_NEEDED:       return "NEEDED"
+	case DT_PLTRELSZ:     return "PLTRELSZ"
+	case DT_PLTGOT:       return "PLTGOT"
+	case DT_HASH:         return "HASH"
+	case DT_STRTAB:       return "STRTAB"
+	case DT_SYMTAB:       return "SYMTAB"
+	case DT_RELA:         return "RELA"
+	case DT_RELASZ:       return "RELASZ"
+	case DT_RELAENT:      return "RELAENT"
+	case DT_STRSZ:        return "STRSZ"
+	case DT_SYMENT:       return "SYMENT"
+	case DT_INIT:         return "INIT"
+	case DT_FINI:         return "FINI"
+	case DT_SONAME:       return "SONAME"
+	case DT_RPATH:        return "RPATH"
+	case DT_SYMBOLIC:     return "SYMBOLIC"
+	case DT_REL:          return "REL"
+	case DT_RELSZ:        return "RELSZ"
+	case DT_RELENT:       return "RELENT"
+	case DT_PLTREL:       return "PLTREL"
+	case DT_DEBUG:        return "DEBUG"
+	case DT_TEXTREL:      return "TEXTREL"
+	case DT_JMPREL:       return "JMPREL"
+	case DT_BIND_NOW:     return "BIND_NOW"
+	case DT_INIT_ARRAY:   return "INIT_ARRAY"
+	case DT_FINI_ARRAY:   return "FINI_ARRAY"
+	case DT_INIT_ARRAYSZ: return "INIT_ARRAYSZ"
+	case DT_FINI_ARRAYSZ: return "FINI_ARRAYSZ"
+	case DT_RUNPATH:      return "RUNPATH"
+	case DT_FLAGS:        return "FLAGS"
+	case DT_PREINIT_ARRAY:   return "PREINIT_ARRAY"
+	case DT_PREINIT_ARRAYSZ: return "PREINIT_ARRAYSZ"
+	case DT_SYMTAB_SHNDX:    return "SYMTAB_SHNDX"
+	case DT_RELRSZ:          return "RELRSZ"
+	case DT_RELR:            return "RELR"
+	case DT_RELRENT:         return "RELRENT"
+	case DT_GNU_HASH:        return "GNU_HASH"
+	case DT_TLSDESC_PLT:     return "TLSDESC_PLT"
+	case DT_TLSDESC_GOT:     return "TLSDESC_GOT"
+	case DT_VERSYM:          return "VERSYM"
+	case DT_RELACOUNT:       return "RELACOUNT"
+	case DT_RELCOUNT:        return "RELCOUNT"
+	case DT_FLAGS_1:         return "FLAGS_1"
+	case DT_VERDEF:          return "VERDEF"
+	case DT_VERDEFNUM:       return "VERDEFNUM"
+	case DT_VERNEED:         return "VERNEED"
+	case DT_VERNEEDNUM:      return "VERNEEDNUM"
+	case DT_AUXILIARY:       return "AUXILIARY"
+	case DT_FILTER:          return "FILTER"
+	}
+	return fmt.tprintf("0x%x", tag)
+}
+
+dyn_is_string_tag :: proc(tag: u64) -> bool {
+	switch tag {
+	case DT_NEEDED, DT_SONAME, DT_RPATH, DT_RUNPATH, DT_AUXILIARY, DT_FILTER:
+		return true
+	}
+	return false
+}
+
+dyn_string :: proc(info: ^ELF_Info, e: Dyn_Entry) -> string {
+	if !dyn_is_string_tag(e.tag) || info.dynstr == nil || e.val > u64(max(u32)) {
+		return ""
+	}
+	return get_symbol_name(info.dynstr, u32(e.val))
+}
+
+vaddr_to_offset :: proc(info: ^ELF_Info, vaddr: u64) -> (u64, bool) {
+	for ph in info.program_hdrs {
+		if ph.type != PT_LOAD || ph.filesz == 0 {
+			continue
+		}
+		if vaddr >= ph.vaddr && vaddr - ph.vaddr < ph.filesz {
+			return ph.offset + (vaddr - ph.vaddr), true
+		}
+	}
+	return 0, false
+}
+
+dyn_val :: proc(info: ^ELF_Info, tag: u64) -> (u64, bool) {
+	for e in info.dyn_entries {
+		if e.tag == tag {
+			return e.val, true
+		}
+	}
+	return 0, false
+}
+
+parse_dynamic :: proc(data: []u8, info: ^ELF_Info) {
+	offset, size, ok := find_dynamic(info)
+	if !ok || !in_bounds(data, offset, size) {
+		return
+	}
+
+	order := byte_order(info.header)
+	entsize := u64(DYN_SIZE_64) if info.is_64bit else u64(DYN_SIZE_32)
+
+	for pos := offset; pos + entsize <= offset + size; pos += entsize {
+		e: Dyn_Entry
+		if info.is_64bit {
+			e.tag = read_u64(data, int(pos), order)
+			e.val = read_u64(data, int(pos + 8), order)
+		} else {
+			e.tag = u64(read_u32(data, int(pos), order))
+			e.val = u64(read_u32(data, int(pos + 4), order))
+		}
+		if e.tag == DT_NULL {
+			break
+		}
+		append(&info.dyn_entries, e)
+	}
+
+	resolve_dynamic_strings(data, info)
+}
+
+find_dynamic :: proc(info: ^ELF_Info) -> (offset: u64, size: u64, ok: bool) {
+	for ph in info.program_hdrs {
+		if ph.type == PT_DYNAMIC && ph.filesz > 0 {
+			return ph.offset, ph.filesz, true
+		}
+	}
+	for shdr in info.section_hdrs {
+		if shdr.type == SHT_DYNAMIC && shdr.size > 0 {
+			return shdr.offset, shdr.size, true
+		}
+	}
+	return 0, 0, false
+}
+
+// DT_STRTAB is a vaddr, so this also works when section headers are stripped.
+dynstr_from_dynamic :: proc(data: []u8, info: ^ELF_Info) -> []u8 {
+	strtab_va, has_va := dyn_val(info, DT_STRTAB)
+	strsz, has_sz := dyn_val(info, DT_STRSZ)
+	if !has_va || !has_sz {
+		return nil
+	}
+	off, ok := vaddr_to_offset(info, strtab_va)
+	if !ok || !in_bounds(data, off, strsz) {
+		return nil
+	}
+	return data[off : off + strsz]
+}
+
+resolve_dynamic_strings :: proc(data: []u8, info: ^ELF_Info) {
+	strtab := dynstr_from_dynamic(data, info)
+	if strtab == nil {
+		strtab = info.dynstr
+	}
+	if strtab == nil {
+		return
+	}
+	info.dynstr = strtab
+
+	for e in info.dyn_entries {
+		if e.val > u64(max(u32)) {
+			continue
+		}
+		name := get_symbol_name(strtab, u32(e.val))
+		switch e.tag {
+		case DT_NEEDED:  append(&info.needed, name)
+		case DT_SONAME:  info.soname = name
+		case DT_RPATH:   info.rpath = name
+		case DT_RUNPATH: info.runpath = name
+		}
+	}
+}
diff --git a/elf/dynamic_test.odin b/elf/dynamic_test.odin
@@ -0,0 +1,181 @@
+package elf
+
+import "core:testing"
+
+DYN_BASE :: 0x400000
+DYN_STR  :: "\x00libtest.so.1\x00libc.so.6\x00/opt/rpath\x00/opt/runpath\x00"
+
+DYN_OFF_NEEDED_1 :: 1
+DYN_OFF_NEEDED_2 :: 14
+DYN_OFF_RPATH    :: 24
+DYN_OFF_RUNPATH  :: 35
+
+build_dynamic_elf :: proc(is64: bool, little: bool, entries: [][2]u64) -> []u8 {
+	dyn_off  :: 0x100
+	str_off  :: 0x200
+	total    :: 0x300
+
+	ph_off   := 64 if is64 else 52
+	ph_size  := PHDR_SIZE_64 if is64 else PHDR_SIZE_32
+	ent_size := 16 if is64 else 8
+
+	data := make([]u8, total)
+	data[0] = 0x7f
+	data[1] = 'E'
+	data[2] = 'L'
+	data[3] = 'F'
+	data[4] = ELF_CLASS_64 if is64 else ELF_CLASS_32
+	data[5] = ELF_DATA_LSB if little else ELF_DATA_MSB
+	data[6] = 1
+
+	put_u16(data, 16, ET_DYN, little)
+	put_u16(data, 18, EM_X86_64 if is64 else EM_ARM, little)
+
+	if is64 {
+		put_u64(data, 32, u64(ph_off), little)
+		put_u64(data, 40, 0, little)
+		put_u16(data, 54, u16(ph_size), little)
+		put_u16(data, 56, 2, little)
+	} else {
+		put_u32(data, 28, u32(ph_off), little)
+		put_u32(data, 32, 0, little)
+		put_u16(data, 42, u16(ph_size), little)
+		put_u16(data, 44, 2, little)
+	}
+
+	write_phdr(data, ph_off, is64, little, Program_Header{
+		type = PT_LOAD, offset = 0, vaddr = DYN_BASE,
+		filesz = total, memsz = total, flags = PF_R | PF_X, align = 0x1000,
+	})
+	write_phdr(data, ph_off + ph_size, is64, little, Program_Header{
+		type = PT_DYNAMIC, offset = dyn_off, vaddr = DYN_BASE + dyn_off,
+		filesz = u64(len(entries) * ent_size), memsz = u64(len(entries) * ent_size),
+		flags = PF_R | PF_W, align = 8,
+	})
+
+	pos := dyn_off
+	for e in entries {
+		if is64 {
+			put_u64(data, pos, e[0], little)
+			put_u64(data, pos + 8, e[1], little)
+		} else {
+			put_u32(data, pos, u32(e[0]), little)
+			put_u32(data, pos + 4, u32(e[1]), little)
+		}
+		pos += ent_size
+	}
+
+	copy(data[str_off:], DYN_STR)
+	return data
+}
+
+DEFAULT_DYN_ENTRIES := [?][2]u64{
+	{DT_NEEDED,  DYN_OFF_NEEDED_1},
+	{DT_NEEDED,  DYN_OFF_NEEDED_2},
+	{DT_SONAME,  DYN_OFF_NEEDED_1},
+	{DT_RPATH,   DYN_OFF_RPATH},
+	{DT_RUNPATH, DYN_OFF_RUNPATH},
+	{DT_STRTAB,  DYN_BASE + 0x200},
+	{DT_STRSZ,   u64(len(DYN_STR))},
+	{DT_NULL,    0},
+}
+
+check_dynamic :: proc(t: ^testing.T, is64: bool, little: bool) {
+	data := build_dynamic_elf(is64, little, DEFAULT_DYN_ENTRIES[:])
+	defer delete(data)
+
+	info, err := load(data)
+	defer destroy(&info)
+
+	testing.expect_value(t, err, "")
+	testing.expect_value(t, len(info.dyn_entries), 7)
+	testing.expect_value(t, len(info.needed), 2)
+	testing.expect_value(t, info.needed[0], "libtest.so.1")
+	testing.expect_value(t, info.needed[1], "libc.so.6")
+	testing.expect_value(t, info.soname, "libtest.so.1")
+	testing.expect_value(t, info.rpath, "/opt/rpath")
+	testing.expect_value(t, info.runpath, "/opt/runpath")
+}
+
+@(test)
+test_dynamic_elf64_le :: proc(t: ^testing.T) { check_dynamic(t, true,  true)  }
+
+@(test)
+test_dynamic_elf64_be :: proc(t: ^testing.T) { check_dynamic(t, true,  false) }
+
+@(test)
+test_dynamic_elf32_le :: proc(t: ^testing.T) { check_dynamic(t, false, true)  }
+
+@(test)
+test_dynamic_elf32_be :: proc(t: ^testing.T) { check_dynamic(t, false, false) }
+
+@(test)
+test_dynamic_stops_at_dt_null :: proc(t: ^testing.T) {
+	entries := [][2]u64{
+		{DT_NEEDED, DYN_OFF_NEEDED_1},
+		{DT_STRTAB, DYN_BASE + 0x200},
+		{DT_STRSZ,  u64(len(DYN_STR))},
+		{DT_NULL,   0},
+		{DT_NEEDED, DYN_OFF_NEEDED_2},
+	}
+	data := build_dynamic_elf(true, true, entries[:])
+	defer delete(data)
+
+	info, _ := load(data)
+	defer destroy(&info)
+
+	testing.expect_value(t, len(info.needed), 1)
+	testing.expect_value(t, info.needed[0], "libtest.so.1")
+}
+
+@(test)
+test_dynamic_strtab_outside_file_is_ignored :: proc(t: ^testing.T) {
+	entries := [][2]u64{
+		{DT_NEEDED, DYN_OFF_NEEDED_1},
+		{DT_STRTAB, DYN_BASE + 0xdeadbeef},
+		{DT_STRSZ,  u64(len(DYN_STR))},
+		{DT_NULL,   0},
+	}
+	data := build_dynamic_elf(true, true, entries[:])
+	defer delete(data)
+
+	info, _ := load(data)
+	defer destroy(&info)
+
+	testing.expect_value(t, len(info.dyn_entries), 3)
+	testing.expect_value(t, len(info.needed), 0)
+}
+
+@(test)
+test_vaddr_to_offset_maps_through_pt_load :: proc(t: ^testing.T) {
+	data := build_dynamic_elf(true, true, DEFAULT_DYN_ENTRIES[:])
+	defer delete(data)
+
+	info, _ := load(data)
+	defer destroy(&info)
+
+	off, ok := vaddr_to_offset(&info, DYN_BASE + 0x200)
+	testing.expect(t, ok, "vaddr inside PT_LOAD must resolve")
+	testing.expect_value(t, off, 0x200)
+
+	_, miss := vaddr_to_offset(&info, DYN_BASE + 0x99999)
+	testing.expect(t, !miss, "vaddr outside every PT_LOAD must fail")
+}
+
+@(test)
+test_no_phantom_sections_when_shoff_zero :: proc(t: ^testing.T) {
+	data := build_dynamic_elf(true, true, DEFAULT_DYN_ENTRIES[:])
+	defer delete(data)
+
+	info, _ := load(data)
+	defer destroy(&info)
+
+	testing.expect_value(t, len(info.section_hdrs), 0)
+}
+
+@(test)
+test_dyn_tag_str_unknown_falls_back_to_hex :: proc(t: ^testing.T) {
+	testing.expect_value(t, dyn_tag_str(DT_RUNPATH), "RUNPATH")
+	testing.expect_value(t, dyn_tag_str(DT_VERNEEDNUM), "VERNEEDNUM")
+	testing.expect_value(t, dyn_tag_str(0x70000001), "0x70000001")
+}
diff --git a/elf/elf.odin b/elf/elf.odin
@@ -64,11 +64,14 @@ PT_TLS     :: 7
 PT_GNU_EH_FRAME :: 0x6474e550
 PT_GNU_STACK    :: 0x6474e551
 PT_GNU_RELRO    :: 0x6474e552
+PT_GNU_PROPERTY :: 0x6474e553
 
 PF_X :: 1
 PF_W :: 2
 PF_R :: 4
 
+SHF_WRITE     :: 1
+SHF_ALLOC     :: 2
 SHF_EXECINSTR :: 4
 
 SHT_NULL     :: 0
@@ -88,6 +91,7 @@ SHT_FINI_ARRAY    :: 15
 SHT_PREINIT_ARRAY :: 16
 SHT_GROUP         :: 17
 SHT_SYMTAB_SHNDX  :: 18
+SHT_RELR          :: 19
 SHT_GNU_ATTRIBUTES :: 0x6ffffff5
 SHT_GNU_HASH       :: 0x6ffffff6
 SHT_GNU_VERDEF     :: 0x6ffffffd
@@ -164,6 +168,12 @@ ELF_Info :: struct {
 	section_hdrs  : [dynamic]Section_Header,
 	symbols       : [dynamic]Symbol,
 	dyn_symbols   : [dynamic]Symbol,
+	dyn_entries   : [dynamic]Dyn_Entry,
+	needed        : [dynamic]string,
+	relocs        : [dynamic]Reloc,
+	soname        : string,
+	rpath         : string,
+	runpath       : string,
 	is_64bit      : bool,
 	is_pie        : bool,
 	is_dynamic    : bool,
diff --git a/elf/json.odin b/elf/json.odin
@@ -0,0 +1,203 @@
+package elf
+
+import "core:encoding/json"
+import "core:fmt"
+
+Json_Symbol :: struct {
+	name  : string,
+	value : u64,
+	size  : u64,
+	bind  : string,
+	type  : string,
+	shndx : u16,
+}
+
+Json_Section :: struct {
+	index  : int,
+	name   : string,
+	type   : string,
+	addr   : u64,
+	offset : u64,
+	size   : u64,
+	flags  : u64,
+}
+
+Json_Segment :: struct {
+	type   : string,
+	flags  : string,
+	offset : u64,
+	vaddr  : u64,
+	paddr  : u64,
+	filesz : u64,
+	memsz  : u64,
+	align  : u64,
+}
+
+Json_Dyn :: struct {
+	tag   : string,
+	value : u64,
+	name  : string,
+}
+
+Json_Reloc :: struct {
+	offset : u64,
+	type   : string,
+	addend : i64,
+	symbol : string,
+	source : string,
+}
+
+Json_Checksec :: struct {
+	relro       : string,
+	nx          : string,
+	pie         : string,
+	canary      : bool,
+	textrel     : bool,
+	rwx_segment : bool,
+	rpath       : string,
+	runpath     : string,
+	stripped    : bool,
+	fortified   : int,
+	fortifiable : int,
+	partial     : int,
+}
+
+Json_Dump :: struct {
+	class           : int,
+	endian          : string,
+	type            : string,
+	machine         : string,
+	entry           : u64,
+	flags           : u32,
+	pie             : bool,
+	is_dynamic      : bool,
+	interp_offset   : u64,
+	soname          : string,
+	rpath           : string,
+	runpath         : string,
+	needed          : []string,
+	segments        : []Json_Segment,
+	sections        : []Json_Section,
+	dynamic_entries : []Json_Dyn,
+	symbols         : []Json_Symbol,
+	dyn_symbols     : []Json_Symbol,
+	relocations     : []Json_Reloc,
+	got             : map[string]u64,
+	checksec        : Json_Checksec,
+}
+
+build_json_dump :: proc(info: ^ELF_Info, allocator := context.allocator) -> Json_Dump {
+	segments := make([]Json_Segment, len(info.program_hdrs), allocator)
+	for ph, i in info.program_hdrs {
+		segments[i] = Json_Segment{
+			type   = segment_type_str(ph.type),
+			flags  = flags_to_str(ph.flags),
+			offset = ph.offset,
+			vaddr  = ph.vaddr,
+			paddr  = ph.paddr,
+			filesz = ph.filesz,
+			memsz  = ph.memsz,
+			align  = ph.align,
+		}
+	}
+
+	sections := make([]Json_Section, len(info.section_hdrs), allocator)
+	for shdr, i in info.section_hdrs {
+		sections[i] = Json_Section{
+			index  = i,
+			name   = get_section_name(info, shdr),
+			type   = section_type_str(shdr.type),
+			addr   = shdr.addr,
+			offset = shdr.offset,
+			size   = shdr.size,
+			flags  = shdr.flags,
+		}
+	}
+
+	dyn := make([]Json_Dyn, len(info.dyn_entries), allocator)
+	for e, i in info.dyn_entries {
+		dyn[i] = Json_Dyn{tag = dyn_tag_str(e.tag), value = e.val, name = dyn_string(info, e)}
+	}
+
+	relocs := make([]Json_Reloc, len(info.relocs), allocator)
+	got := make(map[string]u64, allocator)
+	for r, i in info.relocs {
+		relocs[i] = Json_Reloc{
+			offset = r.offset,
+			type   = reloc_type_str(info.header.machine, r.type),
+			addend = r.addend,
+			symbol = r.sym_name,
+			source = r.source,
+		}
+		if is_jump_slot(info.header.machine, r.type) && r.sym_name != "" {
+			got[r.sym_name] = r.offset
+		}
+	}
+
+	c := checksec(info)
+
+	return Json_Dump{
+		class           = 64 if info.is_64bit else 32,
+		endian          = "little" if info.header.data == ELF_DATA_LSB else "big",
+		type            = type_to_str(info.header.type),
+		machine         = machine_to_str(info.header.machine),
+		entry           = info.header.entry,
+		flags           = info.header.flags,
+		pie             = info.is_pie,
+		is_dynamic      = info.is_dynamic,
+		interp_offset   = info.interp_offset,
+		soname          = info.soname,
+		rpath           = info.rpath,
+		runpath         = info.runpath,
+		needed          = info.needed[:],
+		segments        = segments,
+		sections        = sections,
+		dynamic_entries = dyn,
+		symbols         = to_json_symbols(info.symbols[:], allocator),
+		dyn_symbols     = to_json_symbols(info.dyn_symbols[:], allocator),
+		relocations     = relocs,
+		got             = got,
+		checksec        = Json_Checksec{
+			relro       = relro_str(c.relro),
+			nx          = nx_str(c.nx),
+			pie         = pie_str(c.pie),
+			canary      = c.canary,
+			textrel     = c.textrel,
+			rwx_segment = c.rwx_segment,
+			rpath       = c.rpath,
+			runpath     = c.runpath,
+			stripped    = c.stripped,
+			fortified   = c.fortified,
+			fortifiable = c.fortifiable,
+			partial     = c.partial,
+		},
+	}
+}
+
+to_json_symbols :: proc(syms: []Symbol, allocator := context.allocator) -> []Json_Symbol {
+	out := make([]Json_Symbol, len(syms), allocator)
+	for sym, i in syms {
+		out[i] = Json_Symbol{
+			name  = sym.name,
+			value = sym.value,
+			size  = sym.size,
+			bind  = symbol_bind_str(sym.bind),
+			type  = symbol_type_str(sym.type),
+			shndx = sym.shndx,
+		}
+	}
+	return out
+}
+
+print_json :: proc(info: ^ELF_Info) -> string {
+	defer free_all(context.temp_allocator)
+
+	dump := build_json_dump(info, context.temp_allocator)
+	data, err := json.marshal(dump, {pretty = true, use_spaces = true}, context.temp_allocator)
+	if err != nil {
+		return fmt.tprintf("json marshal failed: %v", err)
+	}
+
+	fmt.println(string(data))
+	return ""
+}
diff --git a/elf/json_test.odin b/elf/json_test.odin
@@ -0,0 +1,50 @@
+package elf
+
+import "core:encoding/json"
+import "core:testing"
+
+// build_json_dump and checksec both use the temp allocator; a free_all inside
+// either one frees the other's slices, which crashed the whole --json path.
+@(test)
+test_json_dump_survives_checksec_allocations :: proc(t: ^testing.T) {
+	data := build_dynamic_elf(true, true, DEFAULT_DYN_ENTRIES[:])
+	defer delete(data)
+
+	info, _ := load(data)
+	defer destroy(&info)
+
+	append(&info.dyn_symbols, Symbol{name = "__memcpy_chk"})
+	append(&info.dyn_symbols, Symbol{name = "memcpy"})
+
+	dump := build_json_dump(&info, context.temp_allocator)
+	defer free_all(context.temp_allocator)
+
+	testing.expect_value(t, len(dump.needed), 2)
+	testing.expect_value(t, dump.needed[0], "libtest.so.1")
+	testing.expect_value(t, dump.runpath, "/opt/runpath")
+	testing.expect_value(t, dump.checksec.fortified, 1)
+
+	out, err := json.marshal(dump, {pretty = true}, context.temp_allocator)
+	testing.expect(t, err == nil, "marshal must succeed")
+	testing.expect(t, len(out) > 0, "marshal must produce output")
+}
+
+@(test)
+test_json_dump_round_trips_through_parser :: proc(t: ^testing.T) {
+	data := build_dynamic_elf(true, true, DEFAULT_DYN_ENTRIES[:])
+	defer delete(data)
+
+	info, _ := load(data)
+	defer destroy(&info)
+
+	dump := build_json_dump(&info, context.temp_allocator)
+	out, _ := json.marshal(dump, {}, context.temp_allocator)
+	defer free_all(context.temp_allocator)
+
+	parsed, perr := json.parse(out, allocator = context.temp_allocator)
+	testing.expect(t, perr == nil, "emitted JSON must parse back")
+
+	obj := parsed.(json.Object)
+	testing.expect_value(t, obj["soname"].(json.String), "libtest.so.1")
+	testing.expect_value(t, obj["class"].(json.Float), 64)
+}
diff --git a/elf/loader.odin b/elf/loader.odin
@@ -16,11 +16,16 @@ load :: proc(data: []u8) -> (ELF_Info, string) {
 	info.section_hdrs = make([dynamic]Section_Header)
 	info.symbols = make([dynamic]Symbol)
 	info.dyn_symbols = make([dynamic]Symbol)
+	info.dyn_entries = make([dynamic]Dyn_Entry)
+	info.needed = make([dynamic]string)
+	info.relocs = make([dynamic]Reloc)
 
 	// Sections first: PN_XNUM resolves e_phnum through section[0].
 	parse_section_headers(data, &info)
 	parse_program_headers(data, &info)
 	parse_symbols(data, &info)
+	parse_dynamic(data, &info)
+	parse_relocs(data, &info)
 	
 	return info, ""
 }
@@ -30,6 +35,9 @@ destroy :: proc(info: ^ELF_Info) {
 	delete(info.section_hdrs)
 	delete(info.symbols)
 	delete(info.dyn_symbols)
+	delete(info.dyn_entries)
+	delete(info.needed)
+	delete(info.relocs)
 }
 
 get_section_name :: proc(info: ^ELF_Info, shdr: Section_Header) -> string {
@@ -166,6 +174,9 @@ parse_section_headers :: proc(data: []u8, info: ^ELF_Info) {
 	is_64bit := info.is_64bit
 	order := byte_order(info.header)
 
+	// e_shoff == 0 means no section header table, so shnum == 0 is not the
+	// SHN_XINDEX escape hatch and section[0] would be the ELF header itself.
+	if header.shoff == 0 { return }
 	if !in_bounds(data, header.shoff, 0) { return }
 
 	min_size := SHDR_SIZE_64 if is_64bit else SHDR_SIZE_32
diff --git a/elf/printer.odin b/elf/printer.odin
@@ -3,14 +3,9 @@ package elf
 import "core:fmt"
 import "core:os"
 
-print_info :: proc(info: ^ELF_Info, verbose: bool, json: bool) {
+print_info :: proc(info: ^ELF_Info, verbose: bool) {
 	f := os.stdout
 
-	if json {
-		print_info_json(info)
-		return
-	}
-
 	fmt.fprintf(f, "ELF File Information\n")
 	fmt.fprintf(f, "====================\n\n")
 
@@ -38,6 +33,22 @@ print_info :: proc(info: ^ELF_Info, verbose: bool, json: bool) {
 		fmt.fprintf(f, "Interpreter offset: 0x%x\n", info.interp_offset)
 	}
 
+	if info.soname != "" {
+		fmt.fprintf(f, "SONAME:    %s\n", info.soname)
+	}
+	if info.rpath != "" {
+		fmt.fprintf(f, "RPATH:     %s\n", info.rpath)
+	}
+	if info.runpath != "" {
+		fmt.fprintf(f, "RUNPATH:   %s\n", info.runpath)
+	}
+	if len(info.needed) > 0 {
+		fmt.fprintf(f, "\nNeeded Libraries: %d\n", len(info.needed))
+		for lib in info.needed {
+			fmt.fprintf(f, "  %s\n", lib)
+		}
+	}
+
 	fmt.fprintf(f, "\n")
 	fmt.fprintf(f, "Program Headers: %d\n", len(info.program_hdrs))
 	fmt.fprintf(f, "Section Headers: %d\n", len(info.section_hdrs))
@@ -45,6 +56,17 @@ print_info :: proc(info: ^ELF_Info, verbose: bool, json: bool) {
 	fmt.fprintf(f, "Symbols (dynamic): %d\n", len(info.dyn_symbols))
 
 	if verbose {
+		if len(info.dyn_entries) > 0 {
+			fmt.fprintf(f, "\nDynamic Section: %d entries\n", len(info.dyn_entries))
+			for e in info.dyn_entries {
+				if name := dyn_string(info, e); name != "" {
+					fmt.fprintf(f, "  %-14s %s\n", dyn_tag_str(e.tag), name)
+				} else {
+					fmt.fprintf(f, "  %-14s 0x%x\n", dyn_tag_str(e.tag), e.val)
+				}
+			}
+		}
+
 		fmt.fprintf(f, "\n")
 		fmt.fprintf(f, "Detailed Sections:\n")
 		for i := 0; i < len(info.section_hdrs); i += 1 {
@@ -89,33 +111,6 @@ print_info :: proc(info: ^ELF_Info, verbose: bool, json: bool) {
 	}
 }
 
-print_info_json :: proc(info: ^ELF_Info) {
-	class_bits := 32
-	if info.is_64bit {
-		class_bits = 64
-	}
-	fmt.println("{")
-	fmt.printf("  \"class\": %d,\n", class_bits)
-	fmt.printf("  \"type\": \"%s\",\n", type_to_str(info.header.type))
-	fmt.printf("  \"machine\": \"%s\",\n", machine_to_str(info.header.machine))
-	fmt.printf("  \"entry\": \"0x%016x\",\n", info.header.entry)
-	pie_str := "false"
-	if info.is_pie {
-		pie_str = "true"
-	}
-	fmt.printf("  \"pie\": %s,\n", pie_str)
-	dyn_str := "false"
-	if info.is_dynamic {
-		dyn_str = "true"
-	}
-	fmt.printf("  \"dynamic\": %s,\n", dyn_str)
-	fmt.printf("  \"program_headers\": %d,\n", len(info.program_hdrs))
-	fmt.printf("  \"section_headers\": %d,\n", len(info.section_hdrs))
-	fmt.printf("  \"symbols_static\": %d,\n", len(info.symbols))
-	fmt.printf("  \"symbols_dynamic\": %d\n", len(info.dyn_symbols))
-	fmt.printf("}\n")
-}
-
 type_to_str :: proc(t: u16) -> string {
 	switch t {
 	case ET_REL:  return "REL (Relocatable)"
@@ -229,6 +224,7 @@ segment_type_str :: proc(t: u32) -> string {
 	case PT_GNU_EH_FRAME: return "GNU_EH_FRAME"
 	case PT_GNU_STACK:    return "GNU_STACK"
 	case PT_GNU_RELRO:    return "GNU_RELRO"
+	case PT_GNU_PROPERTY: return "GNU_PROPERTY"
 	}
 	return fmt.tprintf("0x%x", t)
 }
@@ -238,3 +234,57 @@ flags_to_str :: proc(flags: u64) -> string {
 	@static table := [8]string{"   ", "  X", " W ", " WX", "R  ", "R X", "RW ", "RWX"}
 	return table[flags & 7]
 }
+
+print_checksec :: proc(info: ^ELF_Info) {
+	f := os.stdout
+	c := checksec(info)
+
+	fmt.fprintf(f, "%-12s %s\n", "RELRO:", relro_str(c.relro))
+	fmt.fprintf(f, "%-12s %s\n", "Stack:", "Canary found" if c.canary else "No canary found")
+	fmt.fprintf(f, "%-12s %s\n", "NX:", nx_str(c.nx))
+	fmt.fprintf(f, "%-12s %s\n", "PIE:", pie_str(c.pie))
+	fmt.fprintf(f, "%-12s %s\n", "RPATH:", c.rpath if c.rpath != "" else "No RPATH")
+	fmt.fprintf(f, "%-12s %s\n", "RUNPATH:", c.runpath if c.runpath != "" else "No RUNPATH")
+	fmt.fprintf(f, "%-12s %s\n", "Symbols:", "No symbols" if c.stripped else "Symbols")
+	fmt.fprintf(f, "%-12s %d of %d fortifiable functions fortified", "FORTIFY:", c.fortified, c.fortifiable)
+	if c.partial > 0 {
+		fmt.fprintf(f, " (%d also imported unfortified)", c.partial)
+	}
+	fmt.fprintf(f, "\n")
+
+	if c.textrel {
+		fmt.fprintf(f, "%-12s DT_TEXTREL present, code segment is writable at load time\n", "TEXTREL:")
+	}
+	if c.rwx_segment {
+		fmt.fprintf(f, "%-12s a PT_LOAD segment is both writable and executable\n", "RWX:")
+	}
+}
+
+print_relocs :: proc(info: ^ELF_Info) {
+	f := os.stdout
+
+	if len(info.relocs) == 0 {
+		fmt.fprintf(f, "No relocations\n")
+		return
+	}
+
+	fmt.fprintf(f, "%-18s %-22s %-16s %s\n", "Offset", "Type", "Addend", "Symbol")
+	for r in info.relocs {
+		addend := fmt.tprintf("0x%x", r.addend) if r.kind == .Rela else "-"
+		fmt.fprintf(f, "0x%016x %-22s %-16s %s\n",
+			r.offset, reloc_type_str(info.header.machine, r.type), addend, r.sym_name)
+	}
+
+	fmt.fprintf(f, "\nPLT/GOT Map:\n")
+	slots := 0
+	for r in info.relocs {
+		if !is_jump_slot(info.header.machine, r.type) || r.sym_name == "" {
+			continue
+		}
+		slots += 1
+		fmt.fprintf(f, "  %-32s 0x%016x\n", r.sym_name, r.offset)
+	}
+	if slots == 0 {
+		fmt.fprintf(f, "  (no jump slots)\n")
+	}
+}
diff --git a/elf/reloc.odin b/elf/reloc.odin
@@ -0,0 +1,270 @@
+package elf
+
+import "core:fmt"
+
+Reloc_Source :: enum {
+	Rel,
+	Rela,
+	Relr,
+}
+
+Reloc :: struct {
+	offset   : u64,
+	addend   : i64,
+	sym      : u32,
+	type     : u32,
+	sym_name : string,
+	source   : string,
+	kind     : Reloc_Source,
+}
+
+REL_SIZE_64  :: 16
+RELA_SIZE_64 :: 24
+REL_SIZE_32  :: 8
+RELA_SIZE_32 :: 12
+
+parse_relocs :: proc(data: []u8, info: ^ELF_Info) {
+	if parse_relocs_from_sections(data, info) {
+		return
+	}
+	parse_relocs_from_dynamic(data, info)
+}
+
+parse_relocs_from_sections :: proc(data: []u8, info: ^ELF_Info) -> bool {
+	found := false
+	for shdr in info.section_hdrs {
+		kind: Reloc_Source
+		switch shdr.type {
+		case SHT_REL:  kind = .Rel
+		case SHT_RELA: kind = .Rela
+		case SHT_RELR: kind = .Relr
+		case:          continue
+		}
+		if !in_bounds(data, shdr.offset, shdr.size) || shdr.size == 0 {
+			continue
+		}
+		found = true
+		name := get_section_name(info, shdr)
+		syms := symbols_for_link(info, shdr.link)
+		read_reloc_table(data, info, shdr.offset, shdr.size, kind, name, syms)
+	}
+	return found
+}
+
+symbols_for_link :: proc(info: ^ELF_Info, link: u32) -> []Symbol {
+	if int(link) < len(info.section_hdrs) && info.section_hdrs[link].type == SHT_SYMTAB {
+		return info.symbols[:]
+	}
+	return info.dyn_symbols[:]
+}
+
+parse_relocs_from_dynamic :: proc(data: []u8, info: ^ELF_Info) {
+	syms := info.dyn_symbols[:]
+
+	if addr, ok := dyn_val(info, DT_RELA); ok {
+		if size, ok2 := dyn_val(info, DT_RELASZ); ok2 {
+			read_dynamic_table(data, info, addr, size, .Rela, "DT_RELA", syms)
+		}
+	}
+	if addr, ok := dyn_val(info, DT_REL); ok {
+		if size, ok2 := dyn_val(info, DT_RELSZ); ok2 {
+			read_dynamic_table(data, info, addr, size, .Rel, "DT_REL", syms)
+		}
+	}
+	if addr, ok := dyn_val(info, DT_RELR); ok {
+		if size, ok2 := dyn_val(info, DT_RELRSZ); ok2 {
+			read_dynamic_table(data, info, addr, size, .Relr, "DT_RELR", syms)
+		}
+	}
+	if addr, ok := dyn_val(info, DT_JMPREL); ok {
+		if size, ok2 := dyn_val(info, DT_PLTRELSZ); ok2 {
+			kind: Reloc_Source = .Rela
+			if pltrel, ok3 := dyn_val(info, DT_PLTREL); ok3 && pltrel == DT_REL {
+				kind = .Rel
+			}
+			read_dynamic_table(data, info, addr, size, kind, "DT_JMPREL", syms)
+		}
+	}
+}
+
+read_dynamic_table :: proc(data: []u8, info: ^ELF_Info, vaddr, size: u64, kind: Reloc_Source, source: string, syms: []Symbol) {
+	off, ok := vaddr_to_offset(info, vaddr)
+	if !ok || !in_bounds(data, off, size) {
+		return
+	}
+	read_reloc_table(data, info, off, size, kind, source, syms)
+}
+
+read_reloc_table :: proc(data: []u8, info: ^ELF_Info, offset, size: u64, kind: Reloc_Source, source: string, syms: []Symbol) {
+	if kind == .Relr {
+		read_relr_table(data, info, offset, size, source)
+		return
+	}
+
+	order := byte_order(info.header)
+	is64 := info.is_64bit
+
+	entsize := u64(0)
+	switch {
+	case kind == .Rela && is64:  entsize = RELA_SIZE_64
+	case kind == .Rela && !is64: entsize = RELA_SIZE_32
+	case kind == .Rel && is64:   entsize = REL_SIZE_64
+	case:                        entsize = REL_SIZE_32
+	}
+
+	for pos := offset; pos + entsize <= offset + size; pos += entsize {
+		r: Reloc
+		r.kind = kind
+		r.source = source
+
+		raw_info: u64
+		if is64 {
+			r.offset = read_u64(data, int(pos), order)
+			raw_info = read_u64(data, int(pos + 8), order)
+			r.sym = u32(raw_info >> 32)
+			r.type = u32(raw_info & 0xffffffff)
+			if kind == .Rela {
+				r.addend = i64(read_u64(data, int(pos + 16), order))
+			}
+		} else {
+			r.offset = u64(read_u32(data, int(pos), order))
+			raw_info = u64(read_u32(data, int(pos + 4), order))
+			r.sym = u32(raw_info >> 8)
+			r.type = u32(raw_info & 0xff)
+			if kind == .Rela {
+				r.addend = i64(i32(read_u32(data, int(pos + 8), order)))
+			}
+		}
+
+		if int(r.sym) < len(syms) {
+			r.sym_name = syms[r.sym].name
+		}
+		append(&info.relocs, r)
+	}
+}
+
+// RELR packs relative relocations: an even word is an address, an odd word is a
+// bitmap of the words that follow it.
+read_relr_table :: proc(data: []u8, info: ^ELF_Info, offset, size: u64, source: string) {
+	order := byte_order(info.header)
+	is64 := info.is_64bit
+	word := u64(8) if is64 else u64(4)
+	rel_type := relative_reloc_type(info.header.machine)
+
+	base := u64(0)
+	for pos := offset; pos + word <= offset + size; pos += word {
+		entry := read_u64(data, int(pos), order) if is64 else u64(read_u32(data, int(pos), order))
+
+		if entry & 1 == 0 {
+			append(&info.relocs, Reloc{
+				offset = entry, type = rel_type, source = source, kind = .Relr,
+			})
+			base = entry + word
+			continue
+		}
+
+		bits := entry >> 1
+		for i := u64(0); bits != 0; i += 1 {
+			if bits & 1 != 0 {
+				append(&info.relocs, Reloc{
+					offset = base + i * word, type = rel_type, source = source, kind = .Relr,
+				})
+			}
+			bits >>= 1
+		}
+		base += (word * 8 - 1) * word
+	}
+}
+
+R_X86_64_RELATIVE  :: 8
+R_386_RELATIVE     :: 8
+R_AARCH64_RELATIVE :: 1027
+R_ARM_RELATIVE     :: 23
+R_RISCV_RELATIVE   :: 3
+
+relative_reloc_type :: proc(machine: u16) -> u32 {
+	switch machine {
+	case EM_X86_64:  return R_X86_64_RELATIVE
+	case EM_386:     return R_386_RELATIVE
+	case EM_AARCH64: return R_AARCH64_RELATIVE
+	case EM_ARM:     return R_ARM_RELATIVE
+	case EM_RISCV:   return R_RISCV_RELATIVE
+	}
+	return 0
+}
+
+is_jump_slot :: proc(machine: u16, type: u32) -> bool {
+	switch machine {
+	case EM_X86_64:  return type == 7
+	case EM_386:     return type == 7
+	case EM_AARCH64: return type == 1026
+	case EM_ARM:     return type == 22
+	case EM_RISCV:   return type == 5
+	}
+	return false
+}
+
+reloc_type_str :: proc(machine: u16, type: u32) -> string {
+	switch machine {
+	case EM_X86_64:
+		switch type {
+		case 0:  return "R_X86_64_NONE"
+		case 1:  return "R_X86_64_64"
+		case 2:  return "R_X86_64_PC32"
+		case 3:  return "R_X86_64_GOT32"
+		case 4:  return "R_X86_64_PLT32"
+		case 5:  return "R_X86_64_COPY"
+		case 6:  return "R_X86_64_GLOB_DAT"
+		case 7:  return "R_X86_64_JUMP_SLOT"
+		case 8:  return "R_X86_64_RELATIVE"
+		case 9:  return "R_X86_64_GOTPCREL"
+		case 16: return "R_X86_64_DTPMOD64"
+		case 17: return "R_X86_64_DTPOFF64"
+		case 18: return "R_X86_64_TPOFF64"
+		case 37: return "R_X86_64_IRELATIVE"
+		case 41: return "R_X86_64_TLSDESC"
+		}
+	case EM_386:
+		switch type {
+		case 0:  return "R_386_NONE"
+		case 1:  return "R_386_32"
+		case 2:  return "R_386_PC32"
+		case 5:  return "R_386_COPY"
+		case 6:  return "R_386_GLOB_DAT"
+		case 7:  return "R_386_JMP_SLOT"
+		case 8:  return "R_386_RELATIVE"
+		case 42: return "R_386_IRELATIVE"
+		}
+	case EM_AARCH64:
+		switch type {
+		case 0:    return "R_AARCH64_NONE"
+		case 257:  return "R_AARCH64_ABS64"
+		case 1024: return "R_AARCH64_COPY"
+		case 1025: return "R_AARCH64_GLOB_DAT"
+		case 1026: return "R_AARCH64_JUMP_SLOT"
+		case 1027: return "R_AARCH64_RELATIVE"
+		case 1032: return "R_AARCH64_IRELATIVE"
+		}
+	case EM_ARM:
+		switch type {
+		case 0:   return "R_ARM_NONE"
+		case 2:   return "R_ARM_ABS32"
+		case 20:  return "R_ARM_COPY"
+		case 21:  return "R_ARM_GLOB_DAT"
+		case 22:  return "R_ARM_JUMP_SLOT"
+		case 23:  return "R_ARM_RELATIVE"
+		case 160: return "R_ARM_IRELATIVE"
+		}
+	case EM_RISCV:
+		switch type {
+		case 0:  return "R_RISCV_NONE"
+		case 1:  return "R_RISCV_32"
+		case 2:  return "R_RISCV_64"
+		case 3:  return "R_RISCV_RELATIVE"
+		case 4:  return "R_RISCV_COPY"
+		case 5:  return "R_RISCV_JUMP_SLOT"
+		case 58: return "R_RISCV_IRELATIVE"
+		}
+	}
+	return fmt.tprintf("%d", type)
+}
diff --git a/elf/reloc_test.odin b/elf/reloc_test.odin
@@ -0,0 +1,134 @@
+package elf
+
+import "core:testing"
+
+build_relr_elf :: proc(is64: bool, little: bool, words: []u64) -> []u8 {
+	dyn_off  :: 0x100
+	relr_off :: 0x200
+	total    :: 0x300
+
+	ph_off   := 64 if is64 else 52
+	ph_size  := PHDR_SIZE_64 if is64 else PHDR_SIZE_32
+	word     := 8 if is64 else 4
+
+	data := make([]u8, total)
+	data[0] = 0x7f
+	data[1] = 'E'
+	data[2] = 'L'
+	data[3] = 'F'
+	data[4] = ELF_CLASS_64 if is64 else ELF_CLASS_32
+	data[5] = ELF_DATA_LSB if little else ELF_DATA_MSB
+	data[6] = 1
+
+	put_u16(data, 16, ET_DYN, little)
+	put_u16(data, 18, EM_X86_64 if is64 else EM_ARM, little)
+
+	if is64 {
+		put_u64(data, 32, u64(ph_off), little)
+		put_u16(data, 54, u16(ph_size), little)
+		put_u16(data, 56, 2, little)
+	} else {
+		put_u32(data, 28, u32(ph_off), little)
+		put_u16(data, 42, u16(ph_size), little)
+		put_u16(data, 44, 2, little)
+	}
+
+	write_phdr(data, ph_off, is64, little, Program_Header{
+		type = PT_LOAD, offset = 0, vaddr = DYN_BASE,
+		filesz = total, memsz = total, flags = PF_R | PF_X, align = 0x1000,
+	})
+
+	write_phdr(data, ph_off + ph_size, is64, little, Program_Header{
+		type = PT_DYNAMIC, offset = dyn_off, vaddr = DYN_BASE + dyn_off,
+		filesz = 64, memsz = 64, flags = PF_R, align = 8,
+	})
+
+	ent := 16 if is64 else 8
+	dyn := [][2]u64{
+		{DT_RELR,    DYN_BASE + relr_off},
+		{DT_RELRSZ,  u64(len(words) * word)},
+		{DT_RELRENT, u64(word)},
+		{DT_NULL,    0},
+	}
+	pos := dyn_off
+	for e in dyn {
+		if is64 {
+			put_u64(data, pos, e[0], little)
+			put_u64(data, pos + 8, e[1], little)
+		} else {
+			put_u32(data, pos, u32(e[0]), little)
+			put_u32(data, pos + 4, u32(e[1]), little)
+		}
+		pos += ent
+	}
+
+	pos = relr_off
+	for w in words {
+		if is64 {
+			put_u64(data, pos, w, little)
+		} else {
+			put_u32(data, pos, u32(w), little)
+		}
+		pos += word
+	}
+	return data
+}
+
+@(test)
+test_relr_decodes_address_then_bitmap :: proc(t: ^testing.T) {
+	words := []u64{0x3da0, 0x03, 0x8001}
+	data := build_relr_elf(true, true, words)
+	defer delete(data)
+
+	info, _ := load(data)
+	defer destroy(&info)
+
+	testing.expect_value(t, len(info.relocs), 3)
+	testing.expect_value(t, info.relocs[0].offset, 0x3da0)
+	testing.expect_value(t, info.relocs[1].offset, 0x3da8)
+	testing.expect_value(t, info.relocs[2].offset, 0x4010)
+
+	for r in info.relocs {
+		testing.expect_value(t, r.type, u32(R_X86_64_RELATIVE))
+		testing.expect_value(t, r.kind, Reloc_Source.Relr)
+	}
+}
+
+@(test)
+test_relr_empty_bitmap_emits_nothing :: proc(t: ^testing.T) {
+	words := []u64{0x1000, 0x01}
+	data := build_relr_elf(true, true, words)
+	defer delete(data)
+
+	info, _ := load(data)
+	defer destroy(&info)
+
+	testing.expect_value(t, len(info.relocs), 1)
+	testing.expect_value(t, info.relocs[0].offset, 0x1000)
+}
+
+@(test)
+test_relr_big_endian :: proc(t: ^testing.T) {
+	words := []u64{0x2000, 0x07}
+	data := build_relr_elf(true, false, words)
+	defer delete(data)
+
+	info, _ := load(data)
+	defer destroy(&info)
+
+	testing.expect_value(t, len(info.relocs), 3)
+	testing.expect_value(t, info.relocs[0].offset, 0x2000)
+	testing.expect_value(t, info.relocs[1].offset, 0x2008)
+	testing.expect_value(t, info.relocs[2].offset, 0x2010)
+}
+
+@(test)
+test_reloc_info_split_differs_by_class :: proc(t: ^testing.T) {
+	testing.expect_value(t, reloc_type_str(EM_X86_64, 7), "R_X86_64_JUMP_SLOT")
+	testing.expect_value(t, reloc_type_str(EM_AARCH64, 1026), "R_AARCH64_JUMP_SLOT")
+	testing.expect_value(t, reloc_type_str(EM_X86_64, 999), "999")
+
+	testing.expect(t, is_jump_slot(EM_X86_64, 7), "x86-64 JUMP_SLOT")
+	testing.expect(t, is_jump_slot(EM_AARCH64, 1026), "aarch64 JUMP_SLOT")
+	testing.expect(t, !is_jump_slot(EM_X86_64, 8), "RELATIVE is not a jump slot")
+}
diff --git a/main.odin b/main.odin
@@ -13,17 +13,21 @@ Options :: struct {
 	elf_path    : string `args:"pos=0,required" usage:"ELF binary to analyse."`,
 	hexdump     : bool   `usage:"Hex dump entire file to stdout."`,
 	hexdump_out : string `usage:"Hex dump to file."`,
-	disasm      : bool   `usage:"Disassemble code sections."`,
-	disasm_out  : string `usage:"Disassemble to file."`,
+	code        : bool   `usage:"Code region report: executable sections, entry point, function starts."`,
+	code_out    : string `usage:"Code region report to file."`,
 	entropy     : bool   `usage:"Shannon entropy report."`,
 	entropy_out : string `usage:"Entropy report to file."`,
 	symbols     : bool   `usage:"Print symbol table (nm-like)."`,
 	sections    : bool   `usage:"Print section headers."`,
 	segments    : bool   `usage:"Print program headers (objdump -p style)."`,
 	show_map    : bool   `args:"name=map" usage:"Visual memory layout map."`,
-	remap       : string `usage:"Remap using this prototype file."`,
+	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."`,
+	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."`,
-	json        : bool   `usage:"Output in JSON format."`,
+	json        : bool   `usage:"Full structured dump as JSON (header, segments, sections, dynamic, symbols, relocations, GOT, checksec)."`,
 	verbose     : bool   `usage:"Verbose output."`,
 }
 
@@ -58,8 +62,23 @@ main :: proc() {
 		elf.machine_to_str(info.header.machine), bits, elf_type, dyn_type)
 	fmt.fprintln(os.stderr, "")
 
+	if opts.json {
+		if opts.hexdump || opts.hexdump_out != "" ||
+		   opts.entropy || opts.entropy_out != "" ||
+		   opts.code    || opts.code_out    != "" ||
+		   opts.show_map || opts.bin_diff != "" || opts.call_func != "" {
+			fmt.fprintln(os.stderr, "--json is not supported with --hexdump, --entropy, --code, --map, --bin-diff or --call-function")
+			os.exit(1)
+		}
+		if err := elf.print_json(&info); err != "" {
+			fmt.fprintln(os.stderr, err)
+			os.exit(1)
+		}
+		return
+	}
+
 	if opts.info {
-		elf.print_info(&info, opts.verbose, opts.json)
+		elf.print_info(&info, opts.verbose)
 		return
 	}
 
@@ -77,9 +96,21 @@ main :: proc() {
 		fmt.println("")
 	}
 
-	if opts.disasm || opts.disasm_out != "" {
-		section_header("Disassembly", opts.disasm_out)
-		report("Disasm", analysis.disasm_output(elf_data, &info, opts.disasm_out), &failed)
+	if opts.code || opts.code_out != "" {
+		section_header("Code Regions", opts.code_out)
+		report("Code", analysis.code_output(elf_data, &info, opts.code_out), &failed)
+		fmt.println("")
+	}
+
+	if opts.checksec {
+		section_header("Checksec", "")
+		elf.print_checksec(&info)
+		fmt.println("")
+	}
+
+	if opts.relocs {
+		section_header("Relocations", "")
+		elf.print_relocs(&info)
 		fmt.println("")
 	}
 
@@ -107,9 +138,15 @@ main :: proc() {
 		fmt.println("")
 	}
 
-	if opts.remap != "" {
-		section_header("Remap", "")
-		report("Remap", analysis.remap_output(elf_data, &info, opts.remap, ""), &failed)
+	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)
+		fmt.println("")
+	}
+
+	if opts.bin_diff != "" {
+		section_header("Binary Diff", "")
+		report("BinDiff", analysis.bin_diff_output(elf_data, &info, opts.bin_diff, ""), &failed)
 		fmt.println("")
 	}