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

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

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



然后我们再看看程序的其他部分,其中有个main_executeCommand函数引入注目,其中有如下两个子函数:
1 | call os_exec_Command |
其中前者用于创建一个 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 | 0x449852 mov rdi, rsi RDI => 0x4141414141414155 ('UAAAAAAA') |
我们稍微往前看就可以发现这里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 | from pwn import * |
- 标题: 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 进行许可。