修正monkey补丁在windows下失败的问题
起因
公司内正在做的一个项目最近正在写接口集成测试的脚本,但是由于项目内部分代码使用了全局单例,这部分很难进行mock测试,虽然通过gomonkey可以进行一定程度的patch,但是由于gomonkey当前是非协程安全的,也就是没办法做并行的测试,然而我们的项目用例很多,如果进行串行测试,整个CI流程走下来要接近10分钟,这会非常影响开发的效率,故我开始寻求解决方案。
转机
最终在网上发现了有人做了一个支持多协程的monkey补丁go-kiss/monkey,看了作者的实现文章后,其基本原理就是通过写入一段汇编实现的switch代码段,通过当前协程ID的比较,进行分支跳转到不同的patch上,虽然当前的版本比较粗糙,安全性据说也没有完善处理,但是作为测试我觉得是够用了,所以打算在项目里先试试。
问题
测试这个monkey库的时候发现了一个问题,在AMD64 + Linux 的环境下是能够正常跑通独立patch的逻辑,但是AMD64 + Windows下却失效了,然而我司不少小伙伴的开发机还是使用Windows平台,如果在Windows下无法正常使用,就需要用wsl等方式来跑自测,这样会影响到原先的开发流程。既然作者把基本原理都提供了,故我打算尝试解决这个Bug。
排查
排查Hook流程
写了一段测试代码用于排查,执行后停在input处,拿x86debug附加进去,找到协程判断的汇编段
import (
"fmt"
"github.com/go-kiss/monkey"
)
//go:noinline
func Add(a, b int) int {
return a + b
}
func Add1(a, b int) int {
return 10
}
func main() {
monkey.Patch(Add, Add1)
var input string
fmt.Scanln(&input)
fmt.Printf("g1 add = %d\n", Add(99, 99))
}
发现其判断协程ID的逻辑没有通过,然而测试代码只有一个协程,明显和我们的预期相悖,按理想逻辑,cmp应该成功,不走jne跳转,实际上却判断失败,跳过了patch,手工将跳转nop掉之后,能够正常的执行patch代码,所以初步定位是协程ID获取的地方有错误,导致程序中获取的协程ID和汇编代码中获取的协程ID不一致。
// 错误的代码
func getg() []byte {
return []byte{
// mov r12,QWORD PTR gs:0x28
0x65, 0x4C, 0x8B, 0x24, 0x25, 0x28, 0x00, 0x00, 0x00,
}
}
比较Linux和Windows下获取协程ID的差异
按照原文作者的方法,写了一段代码用于原始获取汇编指令
import "github.com/huandu/go-tls/g"
func main() {
_ = g.G() // 这里其实没有必要按原文那样修改源码,直接这样也能找到的
}
在Linux和Windows下分别编译
go build -ldflags=-w -gcflags '-N -l' main.go
dump
// linux
go tool objdump main > main.txt
// windows
go tool objdump main.exe > main.txt
找到getg的汇编代码比较
Linux
TEXT github.com/huandu/go-tls/g.getg.abi0(SB) /root/go/pkg/mod/github.com/huandu/go-tls@v1.0.1/g/getg_amd64.s
getg_amd64.s:10 0x455420 64488b0425f8ffffff MOVQ FS:0xfffffff8, AX
getg_amd64.s:11 0x455429 4889442408 MOVQ AX, 0x8(SP)
getg_amd64.s:12 0x45542e c3 RET
Windows
TEXT github.com/huandu/go-tls/g.getg.abi0(SB) C:/Users/lzq/go/pkg/mod/github.com/huandu/go-tls@v1.0.1/g/getg_amd64.s
getg_amd64.s:9 0x45ab80 65488b0c2528000000 MOVQ GS:0x28, CX
getg_amd64.s:10 0x45ab89 488b8100000000 MOVQ 0(CX), AX
getg_amd64.s:11 0x45ab90 4889442408 MOVQ AX, 0x8(SP)
getg_amd64.s:12 0x45ab95 c3 RET
可以发现其实非原文所说的单纯只是偏移和寄存器不一致,Windows下还多了一行
getg_amd64.s:10 0x45ab89 488b8100000000 MOVQ 0(CX), AX
也就是说在Windows下其实是2级指针,从GS:0x28
获取出来的是一个二级指针,还需要一次move才能获取到g的指针
修复和测试
修复
知道问题了,于是尝试修改一波,不过我对汇编不是很熟,大致想法是先将二级指针移到r13,在二次取值回r12,这样只需要修改getg的逻辑,其他的代码可以保持原有的逻辑,写入的协程ID在后面的代码中会覆盖掉r12,照我的理解应该是没有问题。
func getg() []byte {
return []byte{
// mov r13,QWORD PTR gs:0x28
0x65, 0x4C, 0x8B, 0x2C, 0x25, 0x28, 0x00, 0x00, 0x00,
// mov r12,QWORD PTR [r13]
0x4D, 0x8B, 0x65, 0x00,
}
}
修复后的代码 我也提了个pr给原仓库
测试
import (
"fmt"
//"github.com/go-kiss/monkey"
"github.com/kkbblzq/monkey"
)
//go:noinline
func Add(a, b int) int {
return a + b
}
func Add1(a, b int) int {
return 10
}
func Add2(a, b int) int {
return 100
}
func main() {
go func() {
monkey.Patch(Add, Add1)
fmt.Printf("g1 add = %d\n", Add(99, 99))
}()
go func() {
monkey.Patch(Add, Add2)
fmt.Printf("g2 add = %d\n", Add(99, 99))
}()
var input string
fmt.Scanln(&input)
}
输出
C:\Users\lzq\AppData\Local\Temp\GoLand\___go_build_monkeyDemo.exe
g1 add = 10
g2 add = 100
测试符合预期,收工。
总结
目前monkey patch这块还是比较hack的行为,由于和底层汇编实现强关联,需要单独去适配不同的平台、不同的架构还是挺蛋疼的,不知道是否有更好的方式来实现,比如使用go的中间层汇编在执行的时候转换成字节码再patch进去,不过目前好像没有找到实现方式,待定吧~