Skip to content

Commit 7a77523

Browse files
committed
refactor: split EhFrameFilter into EhFrameDetector and a pure filter
Add CandidateDetector type symmetric with CandidateFilter, and WithDetectors option symmetric with WithFilters. DetectFunctions and DetectFunctionsFromELF are both public user-facing APIs at the same level. disasmDetector (unexported) bridges between them inside the pipeline. EhFrameDetector emits CFI candidates from .eh_frame FDE records. EhFrameFilter is now a pure filter: retains only FDE-confirmed candidates and upgrades their confidence. It never appends. Default pipeline in DetectFunctionsFromELF: detectors: [disasmDetector, EhFrameDetector] filters: [CETFilter, EhFrameFilter, PLTFilter] PLT stubs introduced by EhFrameDetector are now correctly evicted by PLTFilter. Supersedes #42. Refs #43. Signed-off-by: maxgio92 <maxgio92@pm.me>
1 parent 4a711aa commit 7a77523

8 files changed

Lines changed: 325 additions & 385 deletions

File tree

callsite_test.go

Lines changed: 70 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package resurgo_test
22

33
import (
44
"bytes"
5+
"debug/elf"
56
"os"
67
"os/exec"
78
"path/filepath"
@@ -417,108 +418,6 @@ func TestDetectCallSitesFromELF_InvalidReader(t *testing.T) {
417418
}
418419
}
419420

420-
func TestDetectFunctions(t *testing.T) {
421-
// Test combined prologue + call site detection
422-
// Create code with:
423-
// 1. A function with prologue that is also called
424-
// 2. A function with prologue but not called
425-
// 3. A called address without prologue
426-
427-
// AMD64 code:
428-
// 0x00: push rbp (0x55)
429-
// 0x01: mov rbp, rsp (0x48 0x89 0xe5)
430-
// 0x04: call 0x20 (0xE8 0x17 0x00 0x00 0x00) - calls function at 0x20
431-
// 0x09: ret (0xC3)
432-
// 0x0A: padding
433-
// ...
434-
// 0x20: push rbp (0x55) - function with prologue, called from 0x04
435-
// 0x21: mov rbp, rsp (0x48 0x89 0xe5)
436-
// 0x24: ret (0xC3)
437-
// 0x25: padding
438-
// ...
439-
// 0x30: push rbx (0x53) - function with prologue, not called
440-
// 0x31: ret (0xC3)
441-
// 0x32: padding
442-
// ...
443-
// 0x40: ret (0xC3) - called target without prologue
444-
445-
code := make([]byte, 0x50)
446-
// Function at 0x00 with prologue
447-
code[0x00] = 0x55 // push rbp
448-
code[0x01] = 0x48 // mov rbp, rsp
449-
code[0x02] = 0x89
450-
code[0x03] = 0xe5
451-
// Call to 0x20 (rel32 = 0x20 - (0x04 + 5) = 0x17)
452-
code[0x04] = 0xE8 // call
453-
code[0x05] = 0x17
454-
code[0x06] = 0x00
455-
code[0x07] = 0x00
456-
code[0x08] = 0x00
457-
code[0x09] = 0xC3 // ret
458-
459-
// Function at 0x20 with prologue, called
460-
code[0x20] = 0x55 // push rbp
461-
code[0x21] = 0x48 // mov rbp, rsp
462-
code[0x22] = 0x89
463-
code[0x23] = 0xe5
464-
code[0x24] = 0xC3 // ret
465-
466-
// Add RET before function at 0x30 to establish function boundary
467-
code[0x2F] = 0xC3 // ret
468-
469-
// Function at 0x30 with prologue, not called
470-
code[0x30] = 0x53 // push rbx (callee-saved)
471-
code[0x31] = 0xC3 // ret
472-
473-
// No code at 0x40 (would be called target without prologue in a real scenario)
474-
475-
candidates, err := resurgo.DetectFunctions(code, 0, resurgo.ArchAMD64)
476-
if err != nil {
477-
t.Fatalf("unexpected error: %v", err)
478-
}
479-
480-
// Should find at least 3 candidates:
481-
// - 0x00: prologue-only (main function, might not be called internally)
482-
// - 0x20: both (prologue + called)
483-
// - 0x30: prologue-only (not called)
484-
485-
if len(candidates) < 3 {
486-
t.Fatalf("expected at least 3 candidates, got %d: %+v", len(candidates), candidates)
487-
}
488-
489-
// Find candidate at 0x20 (should have both prologue and call target)
490-
var candidate0x20 *resurgo.FunctionCandidate
491-
for i := range candidates {
492-
if candidates[i].Address == 0x20 {
493-
candidate0x20 = &candidates[i]
494-
break
495-
}
496-
}
497-
498-
if candidate0x20 == nil {
499-
t.Fatal("expected candidate at address 0x20, got none")
500-
}
501-
502-
if candidate0x20.DetectionType != resurgo.DetectionPrologueCallSite {
503-
t.Errorf("expected detection type 'both', got %s", candidate0x20.DetectionType)
504-
}
505-
506-
if candidate0x20.Confidence != resurgo.ConfidenceHigh {
507-
t.Errorf("expected high confidence, got %s", candidate0x20.Confidence)
508-
}
509-
510-
if len(candidate0x20.CalledFrom) == 0 {
511-
t.Error("expected at least one caller, got none")
512-
}
513-
514-
t.Logf("Found %d function candidates", len(candidates))
515-
for _, c := range candidates {
516-
t.Logf(" 0x%x: %s (confidence: %s, called from: %d, jumped from: %d)",
517-
c.Address, c.DetectionType, c.Confidence,
518-
len(c.CalledFrom), len(c.JumpedFrom))
519-
}
520-
}
521-
522421
func TestDetectCallSitesAMD64_ENDBR(t *testing.T) {
523422
// ENDBR64 (f3 0f 1e fa) followed by a call should detect the call,
524423
// skipping the ENDBR64 instruction transparently.
@@ -582,59 +481,36 @@ func TestDetectCallSites_EmptyInput(t *testing.T) {
582481
}
583482
}
584483

585-
func TestDetectFunctions_UnsupportedArch(t *testing.T) {
586-
_, err := resurgo.DetectFunctions([]byte{0x00}, 0, resurgo.Arch("mips"))
587-
if err == nil {
588-
t.Fatal("expected error for unsupported architecture, got nil")
589-
}
590-
}
591-
592-
func TestDetectFunctions_JumpTarget(t *testing.T) {
593-
// Verify that unconditional JMPs create jump-target candidates
594-
// (fix #2: medium-confidence edges now pass the filter).
595-
//
596-
// AMD64 code:
597-
// 0x00: jmp 0x10 (E9 0B 00 00 00) - jump to 0x10
598-
// 0x05: nop padding...
599-
// 0x10: ret (C3) - jump target without prologue
600484

485+
func TestDetectCallSites_JumpTarget(t *testing.T) {
486+
// Verify that unconditional JMPs create jump-target candidates.
487+
// jmp 0x10: E9 0B 00 00 00 at 0x00, target = 0x10
601488
code := make([]byte, 0x20)
602-
// jmp to 0x10: rel32 = 0x10 - (0x00 + 5) = 0x0B
603489
code[0x00] = 0xE9
604490
code[0x01] = 0x0B
605491
code[0x02] = 0x00
606492
code[0x03] = 0x00
607493
code[0x04] = 0x00
608494
code[0x10] = 0xC3 // ret
609495

610-
candidates, err := resurgo.DetectFunctions(code, 0, resurgo.ArchAMD64)
496+
edges, err := resurgo.DetectCallSites(code, 0, resurgo.ArchAMD64)
611497
if err != nil {
612498
t.Fatalf("unexpected error: %v", err)
613499
}
614500

615-
// Look for a jump-target candidate at 0x10
616-
var found *resurgo.FunctionCandidate
617-
for i := range candidates {
618-
if candidates[i].Address == 0x10 {
619-
found = &candidates[i]
501+
var found *resurgo.CallSiteEdge
502+
for i := range edges {
503+
if edges[i].TargetAddr == 0x10 {
504+
found = &edges[i]
620505
break
621506
}
622507
}
623-
624508
if found == nil {
625-
t.Fatal("expected candidate at address 0x10, got none")
626-
}
627-
628-
if found.DetectionType != resurgo.DetectionJumpTarget {
629-
t.Errorf("expected detection type 'jump-target', got %s", found.DetectionType)
630-
}
631-
632-
if len(found.JumpedFrom) == 0 {
633-
t.Error("expected at least one JumpedFrom entry, got none")
509+
t.Fatal("expected jump edge to 0x10, got none")
634510
}
635511
}
636512

637-
func TestDetectFunctionsFromELF(t *testing.T) {
513+
func TestDetectFunctions(t *testing.T) {
638514
binPath := filepath.Join(t.TempDir(), "demo-app")
639515
args := []string{"build", "-o", binPath, "testdata/demo-app.go"}
640516

@@ -644,13 +520,13 @@ func TestDetectFunctionsFromELF(t *testing.T) {
644520
t.Fatalf("failed to compile demo-app: %v\n%s", err, out)
645521
}
646522

647-
f, err := os.Open(binPath)
523+
f, err := elf.Open(binPath)
648524
if err != nil {
649-
t.Fatalf("failed to open compiled binary: %v", err)
525+
t.Fatalf("failed to open ELF binary: %v", err)
650526
}
651527
defer f.Close()
652528

653-
candidates, err := resurgo.DetectFunctionsFromELF(f)
529+
candidates, err := resurgo.DetectFunctions(f)
654530
if err != nil {
655531
t.Fatalf("unexpected error: %v", err)
656532
}
@@ -659,24 +535,75 @@ func TestDetectFunctionsFromELF(t *testing.T) {
659535
t.Fatal("expected at least one function candidate, got none")
660536
}
661537

662-
// Count by detection type
663538
counts := make(map[resurgo.DetectionType]int)
664539
for _, c := range candidates {
665540
counts[c.DetectionType]++
666541
}
667542
t.Logf("total candidates: %d, by type: %v", len(candidates), counts)
668543

669-
// Should have both prologue-only and call-target candidates at minimum
670544
if counts[resurgo.DetectionPrologueOnly] == 0 {
671545
t.Error("expected at least one prologue-only candidate")
672546
}
673547
}
674548

675-
func TestDetectFunctionsFromELF_InvalidReader(t *testing.T) {
549+
// TestDisasmDetector verifies that DisasmDetector, when run against a real ELF
550+
// binary, produces candidates with the expected detection types and that
551+
// functions both called and matching a prologue pattern are promoted to
552+
// DetectionPrologueCallSite with ConfidenceHigh.
553+
func TestDisasmDetector(t *testing.T) {
554+
binPath := filepath.Join(t.TempDir(), "demo-app")
555+
cmd := exec.Command("go", "build", "-o", binPath, "testdata/demo-app.go")
556+
cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOARCH=amd64")
557+
if out, err := cmd.CombinedOutput(); err != nil {
558+
t.Fatalf("failed to compile demo-app: %v\n%s", err, out)
559+
}
560+
561+
f, err := elf.Open(binPath)
562+
if err != nil {
563+
t.Fatalf("failed to open ELF binary: %v", err)
564+
}
565+
defer f.Close()
566+
567+
candidates, err := resurgo.DisasmDetector(f)
568+
if err != nil {
569+
t.Fatalf("DisasmDetector: %v", err)
570+
}
571+
572+
if len(candidates) == 0 {
573+
t.Fatal("expected at least one candidate, got none")
574+
}
575+
576+
counts := make(map[resurgo.DetectionType]int)
577+
for _, c := range candidates {
578+
counts[c.DetectionType]++
579+
}
580+
t.Logf("total candidates: %d, by type: %v", len(candidates), counts)
581+
582+
// Disasm must find functions via prologue pattern.
583+
if counts[resurgo.DetectionPrologueOnly] == 0 && counts[resurgo.DetectionPrologueCallSite] == 0 {
584+
t.Error("expected prologue-based candidates, got none")
585+
}
586+
587+
// Functions confirmed by both prologue and call-site must be ConfidenceHigh
588+
// and must carry at least one caller address.
589+
for _, c := range candidates {
590+
if c.DetectionType == resurgo.DetectionPrologueCallSite {
591+
if c.Confidence != resurgo.ConfidenceHigh {
592+
t.Errorf("0x%x: expected ConfidenceHigh for prologue-callsite, got %s", c.Address, c.Confidence)
593+
}
594+
if len(c.CalledFrom) == 0 && len(c.JumpedFrom) == 0 {
595+
t.Errorf("0x%x: prologue-callsite candidate has no caller or jump source", c.Address)
596+
}
597+
}
598+
}
599+
}
600+
601+
func TestDetectFunctions_InvalidELF(t *testing.T) {
676602
r := bytes.NewReader([]byte{0x00, 0x01, 0x02, 0x03})
677-
_, err := resurgo.DetectFunctionsFromELF(r)
603+
f, err := elf.NewFile(r)
678604
if err == nil {
679-
t.Fatal("expected error for invalid ELF data, got nil")
605+
f.Close()
606+
t.Fatal("expected elf.NewFile to fail on invalid data")
680607
}
681608
}
682609

convergence_test.go

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -31,56 +31,78 @@ func arm64BranchInsn(opBase uint32, source, target uint64) uint32 {
3131
return opBase | imm26
3232
}
3333

34-
// assertConvergence calls DetectFunctions, logs all candidates, and asserts
35-
// minimum convergence between prologue and call-site detection.
34+
// assertConvergence checks convergence between prologue and call-site detection
35+
// by running both independently and counting addresses found by both signals.
3636
// minTotal is the minimum number of candidates expected, minBoth the minimum
37-
// number of "prologue-callsite" candidates, and minRatio the minimum convergence ratio
38-
// (both / total).
37+
// number of addresses confirmed by both signals, and minRatio the minimum
38+
// convergence ratio (both / total).
3939
func assertConvergence(t *testing.T, code []byte, baseAddr uint64, arch resurgo.Arch, minTotal, minBoth int, minRatio float64) {
4040
t.Helper()
4141

42-
candidates, err := resurgo.DetectFunctions(code, baseAddr, arch)
42+
prologues, err := resurgo.DetectPrologues(code, baseAddr, arch)
4343
if err != nil {
44-
t.Fatalf("DetectFunctions: %v", err)
44+
t.Fatalf("DetectPrologues: %v", err)
45+
}
46+
edges, err := resurgo.DetectCallSites(code, baseAddr, arch)
47+
if err != nil {
48+
t.Fatalf("DetectCallSites: %v", err)
4549
}
4650

47-
counts := make(map[resurgo.DetectionType]int)
48-
for _, c := range candidates {
49-
counts[c.DetectionType]++
50-
t.Logf(" 0x%x: %-15s (prologue: %s, calls: %d, jumps: %d)",
51-
c.Address, c.DetectionType, c.PrologueType,
52-
len(c.CalledFrom), len(c.JumpedFrom))
51+
prologueSet := make(map[uint64]resurgo.PrologueType, len(prologues))
52+
for _, p := range prologues {
53+
prologueSet[p.Address] = p.Type
54+
}
55+
callSet := make(map[uint64]struct{}, len(edges))
56+
for _, e := range edges {
57+
callSet[e.TargetAddr] = struct{}{}
5358
}
5459

55-
total := len(candidates)
56-
both := counts[resurgo.DetectionPrologueCallSite]
57-
ratio := float64(both) / float64(total)
60+
allAddrs := make(map[uint64]struct{})
61+
for _, p := range prologues {
62+
allAddrs[p.Address] = struct{}{}
63+
}
64+
for _, e := range edges {
65+
allAddrs[e.TargetAddr] = struct{}{}
66+
}
5867

59-
t.Logf("total=%d both=%d prologue-only=%d call-target=%d jump-target=%d ratio=%.3f",
60-
total, both,
61-
counts[resurgo.DetectionPrologueOnly],
62-
counts[resurgo.DetectionCallTarget],
63-
counts[resurgo.DetectionJumpTarget],
64-
ratio)
68+
var bothCount, prologueOnly, callTarget int
69+
for addr := range allAddrs {
70+
_, hasPrologue := prologueSet[addr]
71+
_, hasCall := callSet[addr]
72+
switch {
73+
case hasPrologue && hasCall:
74+
bothCount++
75+
t.Logf(" 0x%x: %-15s (prologue: %s)", addr, resurgo.DetectionPrologueCallSite, prologueSet[addr])
76+
case hasPrologue:
77+
prologueOnly++
78+
t.Logf(" 0x%x: %-15s (prologue: %s)", addr, resurgo.DetectionPrologueOnly, prologueSet[addr])
79+
case hasCall:
80+
callTarget++
81+
t.Logf(" 0x%x: %-15s", addr, resurgo.DetectionCallTarget)
82+
}
83+
}
84+
85+
total := len(allAddrs)
86+
ratio := float64(bothCount) / float64(total)
87+
88+
t.Logf("total=%d both=%d prologue-only=%d call-target=%d ratio=%.3f",
89+
total, bothCount, prologueOnly, callTarget, ratio)
6590

6691
if total < minTotal {
6792
t.Errorf("expected >= %d candidates, got %d", minTotal, total)
6893
}
69-
if both < minBoth {
70-
t.Errorf("expected >= %d 'both' candidates, got %d", minBoth, both)
94+
if bothCount < minBoth {
95+
t.Errorf("expected >= %d 'both' candidates, got %d", minBoth, bothCount)
7196
}
7297
if ratio < minRatio {
7398
t.Errorf("convergence ratio %.3f < %.3f", ratio, minRatio)
7499
}
75-
if counts[resurgo.DetectionPrologueOnly] < 1 {
100+
if prologueOnly < 1 {
76101
t.Error("expected at least one prologue-only candidate")
77102
}
78-
if counts[resurgo.DetectionCallTarget] < 1 {
103+
if callTarget < 1 {
79104
t.Error("expected at least one call-target candidate")
80105
}
81-
if counts[resurgo.DetectionJumpTarget] < 1 {
82-
t.Error("expected at least one jump-target candidate")
83-
}
84106
}
85107

86108
// buildSyntheticAMD64 builds a synthetic AMD64 .text section with 12 functions

0 commit comments

Comments
 (0)