Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
18 changes: 18 additions & 0 deletions internal/bouk/monkey_arm64.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
98 changes: 98 additions & 0 deletions internal/bouk/replace_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//go:build darwin

package bouk

/*
#include <mach/mach.h>
#include <mach/mach_vm.h>
#include <libkern/OSCacheControl.h>
#include <string.h>

// 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)))
}
}
2 changes: 1 addition & 1 deletion internal/bouk/replace_unix.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//+build !windows
//go:build !windows && !darwin

package bouk

Expand Down
26 changes: 26 additions & 0 deletions supermonkey.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 96 additions & 1 deletion supermonkey_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package supermonkey

import (
"reflect"
"testing"

. "github.com/smartystreets/goconvey/convey"
Expand All @@ -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"
})
Expand All @@ -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
}