diff --git a/go.sum b/go.sum index db80f2f..7d54e39 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,16 @@ +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= golang.org/x/arch v0.0.0-20200826200359-b19915210f00 h1:cfd5G6xu8iZTFmjBYVemyBmE/sTf0A3vpE3BmoOuLCI= golang.org/x/arch v0.0.0-20200826200359-b19915210f00/go.mod h1:flIaEI6LNU6xOCD5PaJvn9wGP0agmIOqjrtsKGRguv4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/bouk/monkey_arm64.go b/internal/bouk/monkey_arm64.go new file mode 100644 index 0000000..a8775eb --- /dev/null +++ b/internal/bouk/monkey_arm64.go @@ -0,0 +1,18 @@ +package bouk + +// Assembles a jump to a function value +func JmpToFunctionValue(to uintptr) []byte { + return []byte{ + 0x71, 0x00, 0x00, 0x58, // LDR X17, .+12 (load funcvalue pointer from literal pool) + 0x31, 0x02, 0x40, 0xF9, // LDR X17, [X17] (dereference funcvalue to get code pointer) + 0x20, 0x02, 0x1F, 0xD6, // BR X17 (branch to code pointer) + byte(to), + byte(to >> 8), + byte(to >> 16), + byte(to >> 24), + byte(to >> 32), + byte(to >> 40), + byte(to >> 48), + byte(to >> 56), // funcvalue pointer (little-endian) + } +} diff --git a/internal/bouk/replace_darwin.go b/internal/bouk/replace_darwin.go new file mode 100644 index 0000000..d34fb0e --- /dev/null +++ b/internal/bouk/replace_darwin.go @@ -0,0 +1,98 @@ +//go:build darwin + +package bouk + +/* +#include +#include +#include +#include + +// patch_code writes data to a code page without ever removing execute permission +// from any currently-executing page. It works by: +// 1. Allocating a temporary RW page +// 2. Copying the original page content + patch into the temp page +// 3. Making the temp page R-X +// 4. Using mach_vm_remap to atomically replace the target page +// +// This avoids the W^X issue on ARM64 macOS: we never make an executing page +// writable, and we never execute from a writable page. +static int +patch_code(uintptr_t target, void *data, size_t data_size) +{ + kern_return_t kr; + mach_vm_size_t ps = vm_page_size; + mach_vm_address_t page_start = target & ~(ps - 1); + mach_vm_address_t page_end = ((target + data_size - 1) & ~(ps - 1)) + ps; + mach_vm_size_t region_size = page_end - page_start; + + // 1. Allocate temporary RW region + mach_vm_address_t temp = 0; + kr = mach_vm_allocate(mach_task_self(), &temp, region_size, VM_FLAGS_ANYWHERE); + if (kr != KERN_SUCCESS) return (int)kr; + + // 2. Copy original page content (original page is R-X, so reading is fine) + memcpy((void *)temp, (void *)page_start, (size_t)region_size); + + // 3. Apply the patch into the temp copy + size_t offset = target - page_start; + memcpy((void *)(temp + offset), data, data_size); + + // 4. Make temp region R-X + kr = mach_vm_protect(mach_task_self(), temp, region_size, 0, + VM_PROT_READ | VM_PROT_EXECUTE); + if (kr != KERN_SUCCESS) { + mach_vm_deallocate(mach_task_self(), temp, region_size); + return (int)kr; + } + + // 5. Atomically replace the original page with our patched copy. + // VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE overwrites the existing mapping. + // copy=TRUE so the kernel creates a private copy (we can free temp after). + vm_prot_t cur_prot, max_prot; + mach_vm_address_t dst = page_start; + kr = mach_vm_remap(mach_task_self(), &dst, region_size, 0, + VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, + mach_task_self(), temp, TRUE, + &cur_prot, &max_prot, VM_INHERIT_DEFAULT); + + // 6. Flush instruction cache (critical on ARM64) + sys_icache_invalidate((void *)page_start, (size_t)region_size); + + // 7. Free temporary region + mach_vm_deallocate(mach_task_self(), temp, region_size); + + return (int)kr; +} +*/ +import "C" + +import ( + "fmt" + "unsafe" +) + +// CopyToLocation copies data to a code memory location on macOS. +// +// On ARM64 macOS, the system enforces a strict W^X policy: a page cannot be +// both writable and executable. Using mprotect or mach_vm_protect to make a +// code page writable would remove execute permission, crashing any code on +// that same 16KB page (including potentially our own patcher code). +// +// Instead, we use a remap-based approach: +// 1. Allocate a fresh writable page +// 2. Copy the original page content plus our patch +// 3. Mark the fresh page as R-X +// 4. Atomically replace the original page via mach_vm_remap +// +// This ensures no executing page ever loses its execute permission. +func CopyToLocation(location uintptr, data []byte) { + ret := C.patch_code( + C.uintptr_t(location), + unsafe.Pointer(&data[0]), + C.size_t(len(data)), + ) + if ret != 0 { + panic(fmt.Sprintf("patch_code failed: kern_return=%d", int(ret))) + } +} diff --git a/internal/bouk/replace_unix.go b/internal/bouk/replace_unix.go index b2b6923..8457d8d 100644 --- a/internal/bouk/replace_unix.go +++ b/internal/bouk/replace_unix.go @@ -1,4 +1,4 @@ -//+build !windows +//go:build !windows && !darwin package bouk diff --git a/supermonkey.go b/supermonkey.go index 4dd6815..123d55d 100644 --- a/supermonkey.go +++ b/supermonkey.go @@ -62,6 +62,32 @@ func init() { addrUint, _ := strconv.ParseUint(addr, 16, 64) symbolTable[funcSymbol] = uintptr(addrUint) } + + // On macOS ARM64 (and other PIE platforms), the binary is loaded at a + // random base address (ASLR). The symbol table contains compile-time + // addresses that must be adjusted by the ASLR slide to get the actual + // runtime addresses. We compute the slide by comparing the runtime + // address of a known function with its symbol table address. + applyASLRSlide() +} + +//go:noinline +func slideAnchor() {} + +func applyASLRSlide() { + const anchorSymbol = "github.com/cch123/supermonkey.slideAnchor" + symbolAddr, ok := symbolTable[anchorSymbol] + if !ok || symbolAddr == 0 { + return + } + runtimeAddr := reflect.ValueOf(slideAnchor).Pointer() + slide := runtimeAddr - symbolAddr + if slide == 0 { + return + } + for k, v := range symbolTable { + symbolTable[k] = v + slide + } } // Finds similar symbols diff --git a/supermonkey_test.go b/supermonkey_test.go index f7dad15..40321ae 100644 --- a/supermonkey_test.go +++ b/supermonkey_test.go @@ -1,6 +1,7 @@ package supermonkey import ( + "reflect" "testing" . "github.com/smartystreets/goconvey/convey" @@ -22,7 +23,7 @@ func TestPatchByFullSymbolName(t *testing.T) { } func TestPatch(t *testing.T) { - Convey("[TestPatchByFullSymbolName]", t, func() { + Convey("[TestPatch]", t, func() { patchGuard := Patch((*person).do, func(_ *person, s string) string { return "Linda" }) @@ -32,9 +33,103 @@ func TestPatch(t *testing.T) { }) } +func TestPatchAndUnpatch(t *testing.T) { + Convey("[TestPatchAndUnpatch] patching replaces behavior, unpatching restores it", t, func() { + So(hey(), ShouldEqual, "hello") + + patchGuard := PatchByFullSymbolName("github.com/cch123/supermonkey.hey", func() string { + return "patched" + }) + So(hey(), ShouldEqual, "patched") + + patchGuard.Unpatch() + So(hey(), ShouldEqual, "hello") + }) +} + +func TestPatchInstanceMethod(t *testing.T) { + Convey("[TestPatchInstanceMethod] patches an exported instance method by name", t, func() { + p := &person{name: "Alice"} + So(p.Greet("hi"), ShouldEqual, "Alice says hi") + + patchGuard := PatchInstanceMethod(reflect.TypeOf(p), "Greet", func(_ *person, s string) string { + return "intercepted:" + s + }) + So(p.Greet("hi"), ShouldEqual, "intercepted:hi") + + patchGuard.Unpatch() + So(p.Greet("hi"), ShouldEqual, "Alice says hi") + }) +} + +func TestUnpatchAll(t *testing.T) { + Convey("[TestUnpatchAll] removes all patches at once", t, func() { + pg1 := PatchByFullSymbolName("github.com/cch123/supermonkey.hey", func() string { + return "pg1" + }) + pg2 := Patch((*person).do, func(_ *person, s string) string { + return "pg2" + }) + _ = pg1 + _ = pg2 + + So(hey(), ShouldEqual, "pg1") + p := &person{} + So(p.do("x"), ShouldEqual, "pg2") + + UnpatchAll() + + So(hey(), ShouldEqual, "hello") + So(p.do("x"), ShouldEqual, "x") + }) +} + +func TestPatchGuardRestore(t *testing.T) { + Convey("[TestPatchGuardRestore] Restore re-applies the patch after Unpatch", t, func() { + patchGuard := Patch((*person).do, func(_ *person, s string) string { + return "mock" + }) + p := &person{} + So(p.do("real"), ShouldEqual, "mock") + + patchGuard.Unpatch() + So(p.do("real"), ShouldEqual, "real") + + patchGuard.Restore() + So(p.do("real"), ShouldEqual, "mock") + + patchGuard.Unpatch() + So(p.do("real"), ShouldEqual, "real") + }) +} + +//go:noinline +func add(a, b int) int { + return a + b +} + +func TestPatchFuncWithArgs(t *testing.T) { + Convey("[TestPatchFuncWithArgs] patches a function that takes and returns values", t, func() { + So(add(1, 2), ShouldEqual, 3) + + patchGuard := Patch(add, func(a, b int) int { + return a * b + }) + So(add(3, 4), ShouldEqual, 12) + + patchGuard.Unpatch() + So(add(3, 4), ShouldEqual, 7) + }) +} + type person struct{ name string } //go:noinline func (p *person) do(s string) string { return s } + +//go:noinline +func (p *person) Greet(s string) string { + return p.name + " says " + s +}