感觉这几篇Go逆向写的跟Go高级开发博客一样。看上去逆向还是得稍微要一点开发基础吧。
Go逆向_3——Goroutine
简介
关于Goroutine
,协程这一概念,网上的解释很多。简单来说,就是一种轻量级线程,同时能并发数千个,且堆栈开销小,默认只会分配4kb。Goroutine
算是Go语法中最重要的一个点了,大概也是让Go语言出彩的一个重头戏。
例子1
在函数或方法调用前面加上关键字go
,就能轻松实现一个Goroutine
。
1 | package main |
我们编译并执行它:
1 | go build -gcflags="-N -l" 1.go |
得到
1 | .\1.exe |
实验了10次,发现仍然如此,hello()
函数内的打印操作并没有实现。
规则
了解Goroutine
的一些规则,将会帮助了解。
- 当新的
Goroutine
开始时,Goroutine
调用立即返回。与函数不同,Go不等待Goroutine
执行结束。当Goroutine
调用,并且Goroutine
的任何返回值被忽略之后,Go立即执行到下一行代码。 main
的Goroutine
应该为其他的Goroutine
执行。如果main
的Goroutine
终止了,程序将被终止,而其他Goroutine
将不会运行。
修改
所以说,可能是上面的main
函数的Goroutine
执行结束的太快了,导致hello
的Goroutine
没来得及打印就被强行结束了。
于是我们修改它,给它更多的时间来反应。调用time.Sleep
让main
函数暂缓1秒:
1 | package main |
再次执行后得到:
1 | .\2.exe |
执行的时候会发现,打印完Hello world goroutine
后会等待1秒左右,然后再打印main function
。
逆向分析
看一下IDA加深理解。(这一次IDA的伪代码还是靠谱的)
1 | void __cdecl main_main() |
乍一看好像啥也没有,仔细看一下调用的函数会发现,调用了newproc
函数。
newproc
这个函数在博客一讲过,向其中传入函数指针fn
,将会为这个Go程序创建一个新的用于运行函数fn
的Goroutine
。
这个off_4D5E28
正好指向了main.hello
函数。
strip
strip
后,我们再次扔进IDA,看看能不能再识别出来。
我的方案是:
- 若IDA7.6能识别出
runtime.newproc
函数,那么就可以直接确认。(不靠谱,有可能字符串会被混淆) - 若识别不出来,那就得根据
newproc
函数本身的特征来手动确认。 - 或者使用
Bindiff
直接导入函数表
实话实说,单纯就恢复函数表来说,7.6确实很靠谱。反编译结果没有任何区别,显示的很清晰。
例子2
开启多个goroutine
1 | package main |
得到
1 | .\3.exe |
程序是通过time.Sleep
来控制每个goroutine
的打印间隔的。
逆向
1 | void __cdecl main_main() |
毫无新意。
1 | __int64 __usercall main_numbers@<rax>() |
1 | __int64 __usercall main_alphabets@<rax>() |
共享内存(锁)
上面都是用time.Sleep()
这样迫真的手法来操纵goroutine
。下面稍微看一下工程上常用的手法:共享内存和通道。
这个概念就是比较常见的多线程编程理念。Go的并发虽然和多线程有很大区别,但是有些东西还是很相似的。
在Go的sync
包下有相关实现。
1 | import "sync" |
多线程的概念:
多线程基础 - 廖雪峰的官方网站 (liaoxuefeng.com)
不过这是Java教程,但是不影响概念互通。
Go语言sync包的应用详解 - 知乎 (zhihu.com)
1 | package main |
得到(结果不唯一):
1 | .\4.exe |
可以发现2counter
就打印了两次,即第二个for
循环跑了2圈不到,10个goroutine
就结束操作了。这里面,runtime.Goshed()
是一个很关键的操作;因为如果不主动分割CPU控制权的话,10个Count
就难以得到运行机会,第二个for
循环就会无效循环很久。
注释掉Goshed
后:
1 | go run .\4.go |
可以看到2counter
打印了好多个无效的1,等了好久才让Counter
拿到运行机会。
逆向
Count
1 | void __golang main_Count(volatile signed __int32 *a1) |
可以看到由_InterlockedCompareExchange
下的sync___ptr_Mutex__lockSlow()
作为开头和_InterlockedDecrement
下的sync___ptr_Mutex__unlockSlow
作为结尾;分别代表了lock.lock()
和lock.unlock()
。
runtime_convT64
是用于构造一个接口的,向其传入一个对象,它会返回一个裸指针。
同时可以发现函数传参lock *sync.Mutex
是个32位int
。
_InterlockedCompareExchange
宏就是lock cmpxchg [rcx], edx
汇编,lock
被作为汇编指令的一个前缀符存在。
_InterlockedDecrement
宏是lock xadd [rcx], eax
当然由于sync
包下的函数还是看得到的,所以暂时不打算仔细研究这些汇编的意义。
main
1 | void __cdecl main_main() |
runtime.newobject
1 | runtime_newobject((_type *)&stru_4BC700); |
先看一下runtime/malloc.go/newobject
函数
1 | // implementation of new builtin |
大致就是传入一个_type
结构体指针,然后它会根据这个来在堆上创建这个对象,然后返回指向它的指针。
IDA中的结果也是一样的
1 | __int64 __usercall runtime_newobject@<rax>(__int64 a1) |
这里遇到了一个上个博客遇到过但没仔细分析过的结构体_type
(在接口结构体中遇到过):
_type
1 | type nameOff int32 |
得到这个之后就可以在IDA内Structure
界面内实现这个结构体。
1 | 00000000 _type struc ; (sizeof=0x30, mappedto_73) |
我们得到
1 | _type < |
其中tflag
是0xF,即0b1111。
1 | const ( |
四个全中。意味着这个对象是对某结构体的取地址,且有名字,且str
域内有*
前缀。
由于现在有源码,所以现在知道是lock := &sync.Mutex{}
。为了确认这一点,现在IDAString
界面上面找一下。
1 | .rdata:00000000004AA91E aSyncEntry db 0Bh,'*sync.entry' |
根据上文对str NameOff
的描述,我们先通过IDASegments
界面找到.rdata
段的开始处。为地址0x4A8000
然后结构体中的+0x2900偏移得到0x4AA900
这里正好就是这个字符串结构体所在的位置。
创建goroutine
1 | while ( v1 < 10 ) |
这个就是
1 | for i := 0; i < 10; i++ { |
注意newproc
传参一开始可能是被搞错成只有一个参数的,要注意修改,添加一个_QWORD
。
mcall
1 | while ( 1 ) |
其中mcall
中传入的参数就是runtime.Goshed
函数。
在runtime/asm_amd64.s
中有汇编实现,其主要作用就是将栈切换至m->g0
然后执行fn
函数。了解一下原型即可。
1 | func mcall(fn func(*g)) |
通道(channel)
消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。
channel
是 Go 语言在语言级别提供的 goroutine
间的通信方式,我们可以使用 channel
在多个 goroutine
之间传递消息。channel
是进程内的通信方式,因此通过 channel
传递对象的过程和调用函数时的参数传递行为比较一致,比如也可以传递指针等。channel
是类型相关的,一个 channel
只能传递一种类型的值,这个类型需要在声明channel
时指定。
声明方式
1 | var chanName chan ElementType |
比如声明一个传递int
的channel
1 | var ch chan int |
使用make()
函数来创建channel
:
1 | ch := make(chan int) |
在channel
的用法中,最常见的包括写入和读出:
1 | // 将一个数据value写入至channel,这会导致阻塞,直到有其他goroutine从这个channel中读取数据 |
默认情况下,channel
的接收和发送都是阻塞的,除非另一端已准备好。
我们还可以创建一个带缓冲的channel
:
1 | c := make(chan int, 1024) |
此时,创建一个大小为1024的int
类型的channel
,即使没有读取方,写入方也可以一直往channel
里写入,在缓冲区被填完之前都不会阻塞。
可以关闭不再使用的channel
:
1 | close(ch) |
应该在生产者的地方关闭channel
,如果在消费者的地方关闭,容易引起panic
下面是一个官方例子:
1 | package main |
得到结果
1 | go run .\6.go |
逆向
1 | void __cdecl main_main() |
runtime.newobject
在reflect/type.go
中,定义了_type
结构体中kind
成员的枚举意义:
1 | type Kind uint |
1 | .rdata:00000000004B49C0 qword_4B49C0 dq 48 ; DATA XREF: main_main+36↑o |
11h,0x17正好对应Array
。说明这是一个列表。且大小是48字节,6*8,能放6个int
。
同时根据str
域找到相应字符串:*[6]int
更是直接证实了这个想法。
runtime.makechan
在runtime/chan.go
中
1 | func makechan(t *chantype, size int) *hchan |
在runtime/type.go
中
1 | type chantype struct { |
套了一个_type
结构体代表了chan
自身类型,然后1个指针,指向了channel
内传递元素的_type
类型。
总体来说没啥区别,不过我发现typ _type
的kind
值是50,超过了Kind
的枚举数量。暂时没搞懂为什么。
runtime.newproc
1 | func newproc(siz int32, fn *funcval) { |
在前面的分析中,由于设定的fn
都是无参函数,所以newproc
一直只有2个参数。但是当fn
有参数时,那么实际编译出来,传入的参数个数就会根据fn
的形参表而产生变化。
1 | func sum(s []int, c chan int) { |
传入的第一个参数是个slice
。讲过slice
的结构体特性:
1 | struct Slice |
需要3个QWORD,第一个是个指针,指向具体的列表数据;第二个是长度;第三个是切片的容量。
1 | void __golang main_sum(QWORD *Arr, __int64 len, __int64 cap, __int64 chan) |
sum
有4个参数,前三个对应的就是slice
结构体,最后一个自然就是chan
。
其中cap
在这个函数中并未被用到。
最后调用了
runtime.chansend1
1 | // entry point for c <- x from compiled code |
伪代码和源码也基本上是一样的(IDA7.6真香)
runtime.chanrecv1
1 | // entry points for <- c from compiled code |
fmt.Fprintln
(对空接口和变参的进一步研究)
1 | // fmt/print.go |
第一个是个io.Write
接口
1 | // io/io.go |
那么就是一个iface
结构体,则占用2个QWORD。
1 | x = fmt_Fprintln(&off_4F0488, qword_567628, v11, 3LL, 3LL); |
中,&off_4F0488, qword_567628
就是一个_type
指针,一个是data unsafe.Pointer
。
qword_567628
根据交叉引用会发现大抵是得到
1 | os_newFile(qword_5AAE00, (__int64)"/dev/stdout", 11LL, (__int64)"file", 4LL); |
也就是输出流。
Go源码中的第二个参数是个很常见的...interface{}
结构,又是变参,又是空接口。
在
这篇文章的Vararg Calls
一节中讲到了变参的特征。
简而言之,调用者函数会在栈上准备一个slice
对象,然后让一个同样在栈上的位置参数指针(positional arguments)指向它;然后将这个slice
当作固定位置参数(fixed-position argument)传给被调用函数。
同时如果变参类型是个空接口,那么一般类型转换成接口类型的行为也会和变参转换同时发生。
函数原型中要求的空接口,意思就是让传入的参数自动转换成其对应的接口类型再传进去。
博客二分析的空接口,则是创建出来的接口对象,由于其初始化没传任何参数而变成了空接口类型。
例子1——f(...int)
1 | package main |
1 | .text:0000000000462740 mov rcx, gs:28h |
总结一下,
1 | [0x18-0x30]=x,y,z,w |
关于rsp+0x40-rsp+0x50
处这个多余的slice
对象,我觉得应该是由于没开优化导致的冗余代码。平时设置-gcflags="-N -l"
似乎也是经常遇到这种现象,暂时不管。
通过修复结构体定义,在IDA中我们得到
1 | void main_caller() |
十分清晰。
例子2——f(...interface{})
1 | package main |
其实本质上是差不多的,只不过多了一个向接口类型的转换。
大致恢复好结构体interface
和slice
后的IDA反编译结果:
1 | 00000000 interface struc ; (sizeof=0x10, mappedto_13) |
注意slice.data
类型最好定义为interface*
,这样更直观
1 | void main_caller() |
抛开寄存器参数不谈,栈上的第一个参数便是slice s
1 | slice s; // [rsp+0h] [rbp-A0h] |
和刚刚的一样,这个显然是传入函数的参数。在函数最后
1 | s.data = inter_; |
s.data
指向了interface[4]
数组,作为这个slice
中的元素。
然后是4个QWORD,对应w, z, y, x
全局变量。但是这还不够,需要变换成接口类型,然后提供给上面所述的slice s
。
1 | interface inter[4]; // [rsp+58h] [rbp-48h] BYREF |
其中中间又有一个没用冗余的slice s2
1 | slice s2; // [rsp+40h] [rbp-60h] |
所以回到一开始的通道程序
1 | x = fmt_Fprintln(&off_4F0488, qword_567628, v11, 3LL, 3LL); |
后面3个参数那显然就是slice
对象了,且其元素都是转化为interface
类型过的。恢复过程和刚刚的一样手法。
总结
go
语法糖的本质就是调用newproc(sz, fn)
创建新的Goroutine
。mcall
用于调用将会切换goroutine
的函数runtime.newobject(*_type)
表明这里创建了某种对象。sync___ptr_Mutex__lockSlow
和sync___ptr_Mutex__UnlockSlow
sync
库使用起来,逆向难度会增大不少。channel
比sync
更容易逆向。
其他
strip
Go程序
Go的strip
似乎不能直接用GNU链里的strip
程序,搞出来的程序虽然确实没有调试信息了,但是似乎也没法正确运行。
1 | go build -ldflags "-s -w" xxx.go |
通过设置链接时的标志,才能进行strip
。
1 | -s disable symbol table |
go-strip
GitHub - boy-hack/go-strip: 清除Go编译时自带的信息
这位兄弟开源了,但是没完全开源,到头来还是需要花钱进知识星球才能拿到源码。(本人穷逼)
不过他的博客是有参考价值的。
并发模型
GO语言基础进阶教程:Go语言的并发模型 - 知乎 (zhihu.com)
根据描述,Go使用的是两级线程模型。
同时对Go的M,P,G调度模型进行了比较清晰的阐述。
参考
GO语言基础进阶教程:Go语言的协程——Goroutine - 知乎 (zhihu.com)
Golang 之协程详解 - 星火燎原智勇 - 博客园 (cnblogs.com)
多线程基础 - 廖雪峰的官方网站 (liaoxuefeng.com)
GO语言基础进阶教程:Go语言的并发模型 - 知乎 (zhihu.com)
由浅入深剖析 go channel - 简书 (jianshu.com)
- 本文作者: Taardis
- 本文链接: https://taardisaa.github.io/2022/03/03/Go Reverse 3/
- 版权声明: 本博客所有文章除特别声明外,均采用 Apache License 2.0 许可协议。转载请注明出处!