From adc76ccb5d2b00b6af0d3d2338d2b88bb3621a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A2=E8=AF=AD?= Date: Thu, 19 Mar 2026 20:00:55 +0800 Subject: [PATCH] feat: add macOS ARM64 (Apple Silicon) support Three issues prevented this library from working on macOS ARM64: 1. Missing ARM64 jump instruction generation - added monkey_arm64.go with LDR/BR sequence (20 bytes) to jump through a funcvalue pointer. 2. macOS W^X memory protection - ARM64 macOS strictly forbids pages from being both writable and executable. Using mprotect(RWX) or mach_vm_protect(RW) would crash because the patcher's own CGo code can reside on the same 16KB page as the target function. Solved by using a page-remap approach: allocate a temp RW page, copy original content + patch, mark R-X, then atomically replace via mach_vm_remap. No executing page ever loses its execute permission. 3. ASLR address slide - macOS ARM64 PIE binaries are loaded at random addresses. Symbol table addresses from nm.Parse are compile-time values that must be adjusted by the ASLR slide. Added slideAnchor function to compute the offset at init time. Also expanded test coverage with tests for Unpatch, UnpatchAll, PatchGuardRestore, PatchInstanceMethod, and multi-argument functions. --- go.sum | 13 +++++ internal/bouk/monkey_arm64.go | 18 ++++++ internal/bouk/replace_darwin.go | 98 +++++++++++++++++++++++++++++++++ internal/bouk/replace_unix.go | 2 +- supermonkey.go | 26 +++++++++ supermonkey_test.go | 97 +++++++++++++++++++++++++++++++- 6 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 internal/bouk/monkey_arm64.go create mode 100644 internal/bouk/replace_darwin.go 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 +}