Lin

Monkey Patch

猴子补丁(Monkey Patch)可以在程序运行期间动态修改函数行为。也有人把这种技术叫「打桩」。这种技术在Python或者Ruby这样的动态语言中比较常见。但这并不意味着静态语言不能实现类似的效果。

引用

https://bou.ke/blog/monkey-patching-in-go/

https://github.com/agiledragon/gomonkey

单元测试

func foo() int64 {
    n := time.Now()
    r := rand.Int63()

    return n.UnixNano() + r
}

对于这样一个用例,测试起来是很困难的。我们可以将 n 与 r 都认为是一个随机的数值。golang 官方推荐使用下面的写法来重构方法以便于测试。

func foo1(fn func() time.Time, fr func() int64) int64 {
    n := fn()
    r := fr()

    return n.UnixNano() + r
}

这种将 n 与 r 通过依赖注入,以接口的方式进行定义来分层测试。无疑是清晰的,但增加了业务代码的复杂度。

如果我们能在单元测试期间,主动接管 n 与 r 的实现而不需要增加额外代码。这就是猴子补丁的作用。对于 python 等动态语言来说,这种实现是轻而易举的。直接替换 import map 内的指向函数即可。但对于 golang 等编译型语言,动态修改函数内容通过常规方式是不可行的。

Patch

func foo(a, b int) int {
    return a + b
}

func bar(a, b int) int {
    return a - b
}

func main() {
    patch(foo, bar)
    println(foo(1,2)) // --> -1
}

对于上述函数,最基础的能力是实现一个函数,将 foo 替换为 bar。为了实现这个结果,我们分三步进行:

找到 a()b() 机器码所在的内存地址

f := reflect.ValueOf(from).Pointer() -->  uintptr
t := reflect.ValueOf(to).Pointer()
reflect.Valuereflect包中的一个结构类型,用于表示一个值的反射信息。它的结构如下:
type Value struct {
    // 包含值的具体类型信息
    typ   *rtype
    // 值的指针
    ptr   unsafe.Pointer
    // 值的标志信息
    flag  flag
    // 值的索引
    index []int
}

在上面的结构定义中,字段的含义如下:

这些字段提供了对值的类型、数据、标志和索引等信息的访问和操作。使用这些信息,可以通过reflect.Value来获取和修改值的各种属性,如类型信息、字段值、方法调用等。

reflect.Value.Pointer() 是 reflect.Value 类型的一个方法,用于返回值的指针。

具体来说,如果 reflect.Value 表示的是一个可寻址的值,即值的地址可以被获取,那么调用 Pointer() 方法将返回该值的指针。否则,如果值是不可寻址的(如基本类型的零值),或者值是一个接口类型的值,那么 Pointer() 方法将返回 nil。

需要注意的是,通过 Pointer() 方法获取的指针不一定是安全的,因为它可能会绕过 Go 语言的类型安全检查。

uintptr 是 Go 语言中的一种整数类型,它是一个无符号整数类型,具体的大小取决于底层计算机的架构。

uintptr 类型用于存储指针的数值表示,而不关心指针指向的具体类型。它通常用于需要进行指针操作或进行指针算术运算的场景。在某些情况下,使用 uintptr 类型可以绕过 Go 语言的类型系统和类型安全性,因此需要小心使用。 在反射中,uintptr 类型常用于表示指针的整数值。通过 reflect.Value.Pointer() 方法返回的就是一个 uintptr 类型的整数值,它表示了值的指针。

需要注意的是,使用 uintptr 类型时需要格外小心,因为它不提供类型安全性,并且可能导致不正确的内存访问或数据损坏。在使用 uintptr 类型时,务必要保证正确的指针转换和类型转换,以及遵循 Go 语言的内存安全规则。

总之,uintptr 是一个无符号整数类型,用于存储指针的数值表示,常用于需要进行指针操作或指针算术运算的场景。在反射中,通过 reflect.Value.Pointer() 方法返回的是一个 uintptr 类型的整数值,表示了值的指针。

构建跳转指令

movabs rdx, 0x?? ; 将内存地址存到寄存器 rdx
jmp rdx ; 跳转到寄存器 rdx 中存储的内存地址,继续执行后面的指令

这句汇编代码 movabs rdx, 0x?? 是 x86-64 汇编语言中的一条指令,用于将一个立即数(immediate value)赋值给寄存器 rdx。

具体的代码 movabs rdx, 0x?? 中的 0x?? 表示一个立即数,其中 0x 表示这是一个十六进制数,?? 代表具体的数值。 指令中的 movabs 是指 move with absolute addressing,它用于将一个 64 位的立即数直接赋值给 64 位的寄存器。这个指令使用绝对寻址方式,因此可以使用 64 位的立即数。 例如,如果指令是 movabs rdx, 0x1234567890abcdef,那么它的作用是将十六进制数 0x1234567890abcdef 赋值给寄存器 rdx。

jmp rdx 是 x86-64 汇编语言中的一条无条件跳转指令,用于将程序的执行流程跳转到寄存器 rdx 中存储的目标地址。 具体地,jmp 是 jump 的缩写,表示无条件跳转。指令中的 rdx 是一个通用寄存器,用于存储数据和地址。 当执行到 jmp rdx 这条指令时,它会直接将程序的执行流程转移到 rdx 寄存器中存储的目标地址处继续执行,而不再按照顺序执行后续的指令。

需要注意的是,jmp rdx 指令的有效性取决于 rdx 中存储的目标地址是否合法和有效。如果 rdx 中存储的是一个有效的指令地址,那么跳转将会成功,程序将会继续执行目标地址处的指令。如果 rdx 中存储的地址无效或不合法,那么跳转可能导致程序崩溃或出现不可预测的行为。

在 Go 中,可以使用 []byte 类型来表示汇编代码。每个字节都对应着汇编指令的一个字节。 下面是将两句汇编代码使用 []byte 表示的示例:

package main

import "fmt"

func main() {
        asmCode := []byte{
                0x48, 0xBA, 0x78, 0x56, 0x34, 0x12, 0x90, 0xAB, 0xCD, 0xEF, 0x1234567890abcdef0xFF, 0xE2, // jmp rdx
        }

        fmt.Println(asmCode)
}

在上面的示例中,我们使用 []byte 类型创建了一个名为 asmCode 的变量,其中包含了两句汇编代码的字节表示。 第一句汇编代码 movabs rdx, 0x1234567890abcdef 对应的字节表示是 0x48, 0xBA, 0x78, 0x56, 0x34, 0x12, 0x90, 0xAB, 0xCD, 0xEF。

第二句汇编代码 jmp rdx 对应的字节表示是 0xFF, 0xE2。 0x1234567890abcdef 是怎么通过 []byte 表示的?

在 Go 的 []byte 中表示 0x1234567890abcdef 这个值时,需要将它分解为 8 个字节并按照特定的字节顺序存储。 对于 0x1234567890abcdef 这个 64 位整数,在内存中的表示取决于机器的字节序(大端序或小端序)。以下示例假设使用的是小端序(Little-Endian)。

package main

import (
        "encoding/binary""fmt"
)

func main() {
        num := uint64(0x1234567890abcdef)
        bytes := make([]byte, 8)

        binary.LittleEndian.PutUint64(bytes, num)

        fmt.Printf("%#v\n", bytes)
}

在上面的示例中,我们使用 encoding/binary 包中的 LittleEndian.PutUint64 方法,将 0x1234567890abcdef 这个 64 位整数转换为 []byte 类型。 输出结果为:

[]byte{0xef, 0xcd, 0xab, 0x90, 0x78, 0x56, 0x34, 0x12}

可以看到,0x1234567890abcdef 被转换为了 []byte 类型,并按照小端序存储在 bytes 中,即 [0xef, 0xcd, 0xab, 0x90, 0x78, 0x56, 0x34, 0x12]。

这种表示方式将一个 64 位整数拆分成了 8 个字节,并按照特定的字节顺序存储在 []byte 中,从而可以在需要的时候以字节为单位进行读取、传输或处理。

什么是小端序,什么是大端序

大端序(Big-Endian)和小端序(Little-Endian)是两种字节序(byte order)的表示方式,用于确定多字节数据在内存中的存储顺序。 字节序指定了在多字节数据(如整数、浮点数)存储时字节的顺序,即哪个字节存储在低地址,哪个字节存储在高地址。

在计算机网络中,大多数协议(如 TCP/IP)使用大端序作为网络字节序,即数据在网络传输时以大端序方式进行传输。而在 x86 架构的个人计算机中,通常使用小端序。因此,在进行网络数据传输或处理不同字节序数据时,需要进行字节序转换。

修改a()函数的机器码

直接使用copy将jmp返回的[]byte拷贝到a()函数指向的内存不就完了吗?其实不然。

前面通过反射拿到了函数指针,但它的类型是uintptr,这相当于C语言中的void *。因为没有具体的类型信息,Go编译器没法直接操作它所指向的内存。另一方面,copy方法的签名是func copy(dst, src []Type) int,要求dst和src类型相同。这里src是跳转指令,其类型为[]byte,所以dst的类型也必须是[]byte。

可dst是函数指针,它的类型是uintptr呀。怎么将它转成[]byte呢?这就需要用到unsafe这个包了:

func rawMemoryAccess(p uintptr, length int) []byte {
  return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{
    Data: p,
    Len:  length,
    Cap:  length,
  }))
}

[]byte就是一个三元组(Data, Len, Cap),其中Data保存内存区域开始的地址,其他字段含义请参考这篇文章。通过reflect.SliceHeader,我们动态构建了一个[]byte切片的底层对象。但这个对象没办法直接转换成[]byte。为此,我们需要借助unsafe.Ponter。但是我们只能把指针强转成unsafe.Pointer,所以我们需要对reflect.SliceHeader取地址。这样转成unsafe.Pointer之后得到的实际上是一个指向[]byte的指针。unsafe.Pointer理论上是可以强转成任意类型的指针,编译器不会再检查对应的类型,如果底层类型不一致,后果自负。所以我们可以强转成*[]byte然后通过*提取到指向的内容,也就是[]byte对象。

整个过程比较绕,而且unsafe.Pointer也很不常用,所以过程比较麻烦。Go官方也觉得不好理解,所以在 v1.17 引入了unsafe.Slice方法,上面的代码可以简化成:

func rawMemoryAccess(p uintptr, length int) []byte {
  return *(*[]byte)(unsafe.Slice(p, length))
}

这样我们就可以将原函数的内容转换为 []byte,从而被跳转指令替换。

go run -gcflags '-N -l' foo.go

我在自己的 mac 上执行会直接报错:

unexpected fault address 0x10a32c0
fatal error: fault
[signal SIGBUS: bus error code=0x2 addr=0x10a32c0 pc=0x1064fbe]

goroutine 1 [running]:
...
exit status 2

这又是为何?跟文件一样,程序的内存数据也是有权限的概念的,有可读、可写和可执行三种权限。代码段的数据因为是从二进制文件加载的,一般是只有可读和可执行两种权限,默认不能在程序的运行过程中修改。如果直接修改,操作系统就会报错。那有办法改权限吗?当然有。这就得用到syscall.Mprotect这个系统调用。

先别高兴地太早,这个syscal.Mprotect没法随意修改内存的权限,必须要以内存的页(page)为单位进行修改。也就是说,我们想要修改a()的代码区,就必须找到一共要修改哪些页。要想确定有哪些页,最重要的是确定第一页内存地址。为此,我们需要使用下面的函数:

func pageStart(ptr uintptr) uintptr {  return ptr & ^(uintptr(syscall.Getpagesize() - 1))}

其中ptr是要修改的函数指针(内存地址),syscall.Getpagesize()是当前系统的页的长度。这个算法的本质是ptr &^ (pageSize - 1)。那怎么理解这个算法呢?(我不是计算机科班出身,搞了半天才弄明白😭)

假设ptr落在了某一页,它的页起始地址是p0。定义d = ptr - p0,则一定有d < pageSize,否则ptr就会落入下一页。要想得到p0,我们需要确定d。我们不妨假设pageSize = 2^3 = 8,对应二进制为0b1000。因为d小于8,所以d的范围一定是0b0000-0b0111,d的最大值是0b0111。也就是说d只有低n位有值(n = 3),其他位肯定都是零,否则d的值就会超过0b0111。

为了计算p0 = ptr - d,我会需要从ptr中减掉d。而d的值只占据了ptr的低n位(2^n = pageSize),所以我们只需要把这低n位清零就能得到p0。而2^n - 1的低n位正好全为1,取反之后再跟ptr按位做逻辑与操作(也就是&^),正好把低n位清零,得到ptr - d也就是p0。

一旦确定了第一页的地址,后面的页就好说了:

func mprotectCrossPage(addr uintptr, length int, prot int) {
    pageSize := syscall.Getpagesize()
    for p := pageStart(addr); p < addr+uintptr(length); p += uintptr(pageSize) {
       page := rawMemoryAccess(p, pageSize)
       if err := syscall.Mprotect(page, prot); err != nil {
          panic(err)
       }
    }
}

copyTo把传入的函数指针转成var f []byte,然后调用mprotectCrossPage修改内存权限,最后使用copy将跳转指令拷贝到目标函数代码区。为了安全起见,函数结束需要把内存的权限改回来。

缺陷

Published on 2023-06-07