1. 项目概述:从一道赛题看Go语言PWN的独特之处
最近在复盘CISCN(全国大学生信息安全竞赛)的题目时,遇到了一道非常有意思的Go语言PWN题,题目名叫“Shellwego”。这道题不仅考察了传统的栈溢出和ROP链构造技巧,更因为其底层是Go语言编译的二进制程序,而带来了许多与常规C/C++程序截然不同的挑战和特性。很多刚接触二进制安全的朋友,一看到Go语言可能就有点发怵,觉得它的运行时、协程调度、内存管理太复杂,无从下手。但我想说,恰恰是这些特性,让Go PWN成为了一块检验我们基本功和理解深度的试金石。通过彻底拆解这道“Shellwego”赛题,我们不仅能掌握一次具体的漏洞利用,更能建立起一套分析Go语言二进制程序的通用方法论。
简单来说,这道题是一个存在栈溢出漏洞的Go语言网络服务程序。攻击者的目标很明确:通过精心构造的输入数据,覆盖函数的返回地址,劫持程序的控制流,最终在服务器上执行任意命令(getshell)。整个过程涉及对Go语言函数调用约定、栈布局、漏洞点定位、ROP Gadget寻找以及最终payload构造的完整分析。相比于传统PWN,你需要额外关注Go的启动过程、
main.main
函数如何被调用、参数如何传递、以及Go运行时特有的内存保护机制(如栈保护、非执行位等)是否被开启或如何绕过。
如果你是一名CTF选手,或者对二进制安全、逆向工程感兴趣,希望通过一个具体案例深入理解Go语言底层的安全机制,那么这篇解析正是为你准备的。我会假设你具备基本的栈溢出原理和ROP概念,但不需要你是Go语言专家。我们将从零开始,一步步还原解题思路,并重点分享那些在官方文档里找不到的“踩坑”经验和调试技巧。
2. 环境准备与逆向分析起点
工欲善其事,必先利其器。分析Go语言二进制程序,工具链的选择和配置至关重要,一些细微的差别可能导致逆向分析效率天差地别。
2.1 工具选型与配置心得
首先,你需要一个能良好解析Go符号的逆向工具。IDA Pro(7.0以上版本)对Go的支持已经比较完善,能够自动识别并恢复大量的Go运行时函数和用户函数名,这能节省大量时间。Ghidra也是一个不错的选择,尤其是其开源免费的特性,但可能需要手动加载Go的特定解析器或脚本(如
golang_renamer.py
)来恢复符号。我个人在实战中更倾向于IDA Pro,因为它对Go二进制文件的分析和反编译(F5)通常更稳定、准确。
其次,调试器必不可少。Linux环境下,
gdb
是标配,但原生
gdb
对Go协程栈的显示并不友好。这里强烈推荐安装
pwndbg
或
gef
这类增强插件。它们能提供更直观的上下文信息,比如直接显示canary值、识别出栈帧等。对于这道题,使用
pwndbg
能让你在动态调试时一眼看出栈的布局变化。
最后,别忘了
objdump
、
readelf
、
checksec
这些基础工具。
checksec
可以快速查看程序开启了哪些保护机制(如NX, PIE, Canary等),这是我们制定利用方案的第一步。对于Go程序,由于编译器默认行为,PIE(地址空间布局随机化)和NX(堆栈不可执行)通常是开启的,而栈保护(Canary)则不一定,这需要具体分析。
注意:不同版本的Go编译器(如1.16, 1.18, 1.20)在代码生成、函数序言(prologue)和调用约定上可能有细微差别。建议在解题时,尽量使用与题目相同或相近版本的Go环境进行编译测试,以减少因版本差异导致的偏差。你可以通过
strings binary | grep ‘go1.’来快速判断大致的编译版本。
2.2 初步逆向与漏洞点定位
拿到“Shellwego”二进制文件后,第一步不是直接扔进IDA,而是先跑起来看看。运行程序,发现它监听某个端口(比如9999),是一个简单的网络服务。用
nc
连上去,尝试发送一些数据,观察回显或崩溃行为,这能给你最直观的感受。
接着用
checksec
检查:
checksec ./shellwego
输出可能显示
NX enabled
和
PIE enabled
,但
Stack Canary
大概率是
disabled
的。这是因为在Go语言中,除非显式使用
-race
标志进行竞争检测编译,或者函数非常特殊,否则默认不插入栈保护cookie。这是一个非常重要的突破口,意味着我们可能面对的是一个没有Canary的栈溢出漏洞。
用IDA Pro加载程序。由于是Go语言编译,你会看到入口点并非
main
,而是一个名为
runtime.rt0_go
的函数。这是Go运行时的启动入口,它会进行一系列初始化,最后调用我们编写的
main.main
函数。直接搜索字符串“main.main”,然后跳转过去,这才是我们用户代码的起点。
在
main.main
函数中,通常会看到网络监听、接受连接、创建新goroutine处理请求的逻辑。我们需要找到处理客户端数据的函数。通常,这个函数里会有一个
read
或
recv
之类的系统调用,或者更常见的是,Go语言封装好的
net.Conn.Read
方法。沿着数据流的传递路径进行跟踪。
关键技巧来了:在IDA的字符串窗口(Shift+F12)搜索一些可能的关键字,如“input”、“read”、“buf”、“error”等,或者直接搜索程序崩溃或打印信息中的字符串,然后交叉引用(Xref)找到使用它们的地方。在这道题中,你可能会发现一个函数,它接收一个连接(conn)作为参数,然后在一个循环中读取数据。仔细看这个读数据的缓冲区是如何定义的。
Go语言的栈帧结构比较规整。一个典型的函数开头会是:
push rbp
mov rbp, rsp
sub rsp, XXh ; 在栈上分配局部变量空间
...
局部变量就会存放在
[rbp-XXh]
到
[rbp]
这个区间。如果你在函数中看到了对某个栈地址进行循环读取操作的代码,并且循环次数或读取长度依赖于用户可控的输入,那么这里就可能是漏洞点。在“Shellwego”中,我们最终定位到一个函数,它使用了一个固定大小的字节数组(比如
[128]byte
)作为缓冲区,但却使用了一个从数据包中解析出来的、用户可控的长度值作为
read
或
copy
操作的次数,从而导致了经典的栈缓冲区溢出。
3. Go语言栈溢出利用的核心挑战
找到了溢出点,是不是感觉胜利在望了?别急,对于Go语言程序,直接覆盖返回地址
rip
只是万里长征第一步,前面还有好几座大山需要翻越。
3.1 Go语言的调用约定与栈布局
这是与传统C程序最大的不同之一。在x86-64架构下,C语言使用System V AMD64 ABI调用约定,前六个整数或指针参数依次通过寄存器
rdi, rsi, rdx, rcx, r8, r9
传递,多余的才通过栈传递。而Go语言有自己的调用约定,它
几乎全部通过栈来传递参数和返回值
。
这意味着什么呢?在Go函数的汇编代码中,你很少会看到像
mov rdi, rax
这样准备参数的指令。相反,调用者函数会在自己的栈帧顶部(也就是当前栈指针
rsp
附近)预留出一块空间,将要传递给被调用函数的参数依次存入这片内存区域,然后执行
call
指令。被调用函数则直接从固定的栈偏移位置去读取这些参数。
这种设计对漏洞利用的影响是巨大的。首先,它使得栈上的内容非常密集,除了返回地址、保存的基址指针(
rbp
)外,还充斥着大量的函数参数和局部变量。我们在构造溢出数据时,不仅要精准地覆盖返回地址,还要注意不要破坏那些即将被使用的参数,否则程序可能在跳转到我们的gadget之前就因为读取到错误参数而崩溃了。其次,Go语言的栈帧通常比较大(因为协程栈初始就有2KB),且生长方向是向低地址扩展,这需要我们在计算偏移量时格外小心。
3.2 寻找可用Gadget的困境
由于参数通过栈传递,我们常用的
pop rdi; ret
这样的gadget在Go程序中变得几乎无用武之地。因为即使你通过
pop rdi
将栈上的一个值弹到了
rdi
寄存器,Go的函数也不会去读
rdi
,它依然会去读栈上某个固定偏移处的值作为第一个参数。
因此,在Go语言的ROP链构造中,我们寻找gadget的策略必须转变。我们的目标不再是控制寄存器,而是
直接控制栈上的数据
。我们需要的是能够实现“栈翻转”(stack pivot)的gadget,或者能够将栈指针
rsp
指向我们完全可控的内存区域(比如我们注入的payload所在的缓冲区)的gadget。
一类极其有用的gadget是像
add rsp, XXX; ret
这样的指令序列。它能够直接调整栈指针,跳过一些我们无法精确控制或不想破坏的数据,让
ret
指令从我们期望的新位置取下一条指令地址。另一类更强大的是
xchg
或
mov
指令,能够将
rsp
与其他寄存器(如
rax
,
rdx
)交换或赋值。如果我们能先通过某个漏洞或gadget将一个可控地址(如堆地址或.bss段地址)加载到某个寄存器,再通过
mov rsp, rax; ret
这样的gadget,就能实现栈的完全迁移,从而在一个宽敞、可控的空间里布置复杂的ROP链。
3.3 地址随机化(PIE)与信息泄露
现代CTF赛题和真实环境中的Go程序,几乎100%默认开启PIE。这意味着代码段(.text)的基地址在每次程序运行时都是随机化的。我们虽然能通过逆向分析得到指令相对于基地址的偏移(offset),但不知道程序运行时的实际加载地址(base)。
因此,利用的第一步往往是
信息泄露
。我们需要利用程序的某种功能(比如打印错误信息、回显部分数据),将某个代码指针或库函数地址泄露出来。由于PIE随机化是针对整个模块的,一旦我们泄露了一个地址,就能推算出整个代码段的基地址,进而计算出所有gadget和有用函数(如
system
)的实际运行时地址。
在“Shellwego”中,漏洞函数可能在溢出后导致程序崩溃,并将某些栈上或寄存器中的内容作为错误信息返回给客户端。我们需要仔细审计代码,看看有没有这样的机会。例如,如果程序在
read
之后,会将读取的字节数或缓冲区的某个指针用于日志打印,我们或许就能通过精心构造的输入,让这个指针指向一个我们想要泄露的地址(比如
main.main
的地址)。
实操心得:动态调试是解决信息泄露的关键。在gdb中,在疑似泄露点(如调用
fmt.Printf或write系统调用)下断点,观察传入的参数是什么。有时候,泄露可能发生在崩溃时的panic处理流程中,Go的panic会打印堆栈信息,里面可能包含有价值的地址。多尝试几种崩溃方式,观察输出。
4. 漏洞利用链的详细构造过程
假设我们已经通过某种方式泄露了代码段基地址,并且确认了栈溢出漏洞点和偏移量。接下来就是构造利用链的实战环节。
4.1 计算精确偏移与控制流劫持
首先,我们需要确定从我们输入的缓冲区起始位置,到目标函数返回地址存储位置之间的精确字节偏移。
最可靠的方法是通过模式字符串(pattern)来定位。我们可以使用
pwntools
的
cyclic
功能生成一段长而无重复的字符串,发送给程序触发崩溃,然后程序崩溃时
rip
寄存器的值(或者栈上的值)会指向这个模式字符串的某个部分。再用
cyclic_find
计算这个值在模式串中的位置,就能得到精确偏移。
from pwn import *
context.binary = ‘./shellwego‘
p = process(‘./shellwego‘)
# 或者 remote(‘host‘, port)
# 生成200个字符的模式串
pattern = cyclic(200)
p.sendline(pattern)
p.wait() # 等待崩溃
# 从core dump或崩溃信息中获取rip的值,假设是0x6161616c
rip_value = 0x6161616c
offset = cyclic_find(rip_value) # 计算偏移
print(f“Offset to RIP is: {offset}“)
在Go语言中,由于栈帧较大且结构固定,这个偏移量通常是一个固定值。得到偏移后,我们的payload结构就清晰了:
payload = b‘A‘ * offset + p64(target_address)
。
4.2 构建适用于Go的ROP链
现在到了最核心的部分:构建ROP链。我们的目标通常是调用
execve(“/bin/sh“, NULL, NULL)
来获取shell。在开启了NX保护的系统中,我们不能直接执行栈上的shellcode,必须复用程序已有的代码片段。
-
寻找系统调用指令 :在64位Linux下,
execve的系统调用号是59(0x3b)。我们需要通过syscall指令来触发。在Go语言的二进制文件中,syscall指令并不少见,因为Go运行时需要用它来进行真正的系统调用。我们可以用ROPgadget这样的工具搜索:ROPgadget --binary ./shellwego | grep “syscall“或者用
objdump配合grep。找到一个syscall; ret的gadget地址,记作gadget_syscall。 -
设置系统调用参数 :按照x86-64系统调用约定,需要将
rax设为59,rdi设为“/bin/sh“字符串的地址,rsi和rdx设为0。但如前所述,Go语言不常用寄存器传参,我们很难直接控制rdi。怎么办?策略是: 寻找能直接控制栈内容的gadget,然后让
syscall指令“认为”栈上的布局符合系统调用约定 。但这几乎不可能,因为syscall是从特定寄存器读参数的。因此,更可行的策略是寻找一个“万能”的gadget,或者一个小型的、能设置寄存器的代码片段。Go运行时库(
runtime)或系统库(如libc,如果静态链接了的话)里包含许多设置寄存器的指令。我们需要搜索如pop rax; ret、pop rdi; ret、pop rsi; ret、pop rdx; ret这样的序列。由于Go二进制文件体积庞大,找到这些基础gadget的概率其实不低。 -
布置参数与字符串 :我们需要在内存中找到一个可写且地址已知的区域写入字符串“/bin/sh“。通常可以选择程序的
.data或.bss段(存储未初始化全局变量)。用readelf -S ./shellwego查看节区信息,找到.bss或.data的起始虚拟地址(VMA)。这个地址在PIE开启时也是随机的,但我们可以用泄露的代码基地址加上固定的节区偏移来计算。 假设泄露的代码地址是leak_addr,.bss段在二进制中的偏移是0xCAFE00,那么.bss段的运行时基地址就是bss_base = leak_addr - (leak_addr的偏移) + 0xCAFE00。我们需要将这个计算过程自动化到exp中。 -
链式调用 :最终的ROP链可能长这样(假设我们找到了所有需要的pop gadget):
[溢出偏移处开始] p64(pop_rdi_ret) # 1. 将‘/bin/sh‘地址弹入rdi p64(bin_sh_addr) # 参数:字符串地址 p64(pop_rsi_ret) # 2. 将0弹入rsi p64(0) # 参数:argv p64(pop_rdx_ret) # 3. 将0弹入rdx p64(0) # 参数:envp p64(pop_rax_ret) # 4. 将59弹入rax p64(59) # 系统调用号 p64(syscall_ret) # 5. 执行syscall但实际情况往往更复杂,可能因为寄存器干扰、栈对齐等问题,需要插入一些
ret(相当于nop)指令或调整顺序的gadget(如xchg rdi, rsi; ret)来调整。
4.3 “Shellwego”赛题中的特定链构造
在“Shellwego”这道题中,经过逆向发现,程序本身静态链接了Go运行时和网络库,但没有链接libc。这意味着我们无法使用libc中的
system
函数。同时,寻找完整的
pop rdi; ret
序列可能比较困难。
但是,我们发现了另一个有利条件:程序在初始化时,会将一个全局变量设置为某个libc函数地址(比如用于信号处理的)。虽然不直接使用libc,但这个地址的泄露足以让我们计算出libc的基地址。更重要的是,我们在程序的代码段中找到了一个极其有用的片段:
mov rdx, qword [rsp+0x18]
mov rsi, qword [rsp+0x10]
mov edi, dword [rsp+0x8]
call rax
...
ret
这个片段的功能是:从栈上
[rsp+0x8]
、
[rsp+0x10]
、
[rsp+0x18]
的位置分别读取三个参数到
edi
、
rsi
、
rdx
,然后调用
rax
寄存器指向的函数。这简直是一个为调用
execve
量身定做的“三参数函数调用器”gadget!我们将其命名为
gadget_call_rax
。
那么利用链就简化为:
-
将字符串“/bin/sh“的地址写入
.bss段。 -
将
execve的函数地址(来自libc)放入rax。我们需要一个pop rax; ret的gadget。 -
在栈上布置好三个参数:
rdi(字符串地址)、rsi(0)、rdx(0)。注意这里edi是32位,但地址通常在高位,不过如果地址在低32位范围内(即小于4GB),也是可行的。否则需要寻找其他gadget。 -
将栈指针调整到合适的位置,然后跳转到
gadget_call_rax。
如何写内存呢?我们发现程序中存在一个类似
memcpy
的函数,其参数同样通过栈传递。我们可以通过ROP链调用这个
memcpy
,将“/bin/sh“字符串从我们的payload中复制到
.bss
段。这就需要我们控制栈来布置
memcpy
的三个参数(目标地址、源地址、长度)和返回地址。
最终的payload布局变得像“俄罗斯套娃”:第一层ROP链调用
memcpy
写字符串;执行完后返回到第二层ROP链,设置
rax
和三个参数;最后跳转到
gadget_call_rax
。
5. 动态调试与利用脚本编写
理论分析得再完美,也需要动态调试来验证和调整。
5.1 使用GDB/Pwndbg调试Go程序
用
pwndbg
附加到运行中的“Shellwego”进程:
gdb -p $(pidof shellwego)
或者在启动时调试:
gdb ./shellwego
run
Go程序在收到信号(如段错误)时,默认行为是打印堆栈跟踪并退出,这不利于我们观察崩溃现场。可以在gdb中设置
handle SIGSEGV nostop noprint pass
,让gdb接管SIGSEGV信号,这样程序崩溃时会停在非法指令处,方便我们查看寄存器和内存。
关键断点设置:
-
在漏洞函数(如
main.handleConnection)的读数据指令处下断点,观察输入数据如何被存入栈中。 -
在函数返回指令(
ret)处下断点,单步执行,观察rip是否被成功覆盖为我们预设的地址。 - 在ROP链中的第一个gadget地址处下断点,确认控制流成功跳转。
调试时,多用
pwndbg
的
stack
命令查看栈内容,用
context
命令查看当前上下文。特别注意栈指针
rsp
的变化,确保它始终指向我们期望的、可控的数据区域。
5.2 编写稳健的Exploit脚本
利用脚本(exp)的编写要追求稳健和自动化。使用
pwntools
库是标准做法。
#!/usr/bin/env python3
from pwn import *
context.binary = ‘./shellwego‘
context.log_level = ‘debug‘ # 调试时开启,可以看到发送接收的详细数据
context.terminal = [‘tmux‘, ‘splitw‘, ‘-h‘] # 方便分屏调试
def leak_address():
# 构造触发信息泄露的payload
# 例如,发送特定数据使程序打印出一个指针
# p.recvuntil(b‘some prompt:‘)
# p.send(b‘A‘ * offset_to_leak + b‘%p‘) # 假设有格式化字符串漏洞
# leaked = int(p.recvline().strip(), 16)
# return leaked
pass
def exploit():
if args.REMOTE:
p = remote(‘靶机IP‘, 端口)
else:
p = process(‘./shellwego‘)
# 如果本地调试,可以在这里attach gdb
# gdb.attach(p, gdbscript=‘‘‘
# break *0x555555555555
# continue
# ‘‘‘)
# 1. 信息泄露阶段
code_base = leak_address() - 0x123456 # 减去泄露点的固定偏移
log.success(f“Code base: {hex(code_base)}“)
# 计算所有gadget和地址的运行时地址
gadget_pop_rax = code_base + 0xaaaaa
gadget_mov_rdx_from_stack = code_base + 0xbbbbb # 那个三参数调用器
bss_addr = code_base + 0xca000
memcpy_plt = code_base + 0xddddd
# 2. 构造payload
offset = 136 # 之前计算出的到返回地址的偏移
# 第一段:调用memcpy,将‘/bin/sh‘写入.bss段
# 我们需要在栈上布置memcpy的参数:rdi=dest(bss), rsi=src(我们payload中的字符串地址), rdx=len(8)
# 由于参数通过栈传,我们需要布置栈帧。
# 假设我们控制返回地址后,rsp指向的位置是我们可以连续布置数据的地方。
payload = b‘A‘ * offset
payload += p64(gadget_pop_rax) # 返回地址:跳转到pop rax; ret
payload += p64(memcpy_plt) # pop rax会将这个值弹入rax,现在rax=memcpy地址
payload += p64(gadget_mov_rdx_from_stack) # 接下来执行这个gadget,它会从栈上读参数并call rax
# gadget_mov_rdx_from_stack期望的栈布局:[ret_addr] [edi] [rsi] [rdx] ... 它从[rsp+8]开始读
# 所以我们在它之后布置参数
payload += p64(0xdeadbeef) # 这个地址是call rax之后的返回地址,我们先随便填
payload += p64(bss_addr & 0xffffffff) # edi (低32位目标地址)
payload += p64(0) # 填充,因为edi是4字节,但栈是8字节对齐
payload += p64(???如何提供字符串地址???) # rsi (源地址,这是一个难题!)
payload += p64(8) # rdx (长度)
# 这里遇到一个难题:字符串“/bin/sh“的地址在哪里?
# 它就在我们的payload里!但我们需要知道它在内存中的运行时地址。
# 这通常需要另一个信息泄露,或者利用栈地址相对固定的特性进行爆破。
# 在“Shellwego”中,我们发现栈地址可以通过之前的信息泄露获得,或者利用程序本身的某些输出。
# 更常见的做法是分两步:
# 第一步ROP链:将字符串写入已知地址(如.bss)。
# 第二步ROP链:设置参数并调用execve。
# 我们需要一个“栈翻转”gadget(如add rsp, XXX; ret)来连接这两步。
p.sendline(payload)
p.interactive()
if __name__ == ‘__main__‘:
exploit()
编写exp是一个不断调试、修改、再调试的过程。经常遇到的问题包括:栈对齐问题(64位Linux要求栈在
call
指令前16字节对齐)、地址包含空字节导致输入截断、以及最重要的——栈地址预测不准。
5.3 常见问题与避坑指南
-
栈对齐问题
:如果跳转到某些libc函数(如
system)时发生崩溃,很可能是栈没有对齐到16字节。解决方案是在ROP链中插入一个只包含ret指令的gadget(通常有很多)。这个ret不改变栈内容,但会让rsp减8,从而调整对齐。 -
地址中的空字节
:如果目标地址是
0x7fxxxxxx这样的形式,其内存表示中可能包含0x00字节(空字节)。这在使用strcpy之类的函数时会导致截断。解决方法是寻找不包含空字节的替代地址,或者使用read等函数逐字节写入。 -
onegadget的尝试
:在libc中,存在一些称为“onegadget”的特殊地址,跳转到那里可以直接执行
execve(“/bin/sh“, NULL, NULL),前提是满足某些寄存器约束(如rsp+0x30为NULL,rsp+0x70为NULL等)。在拥有libc地址的情况下,可以尝试这些onegadget,比构造复杂的ROP链更稳定。用one_gadget工具可以查找。 -
Go runtime的干扰
:Go程序有复杂的垃圾回收和调度机制。在长时间运行的ROP链中,可能会触发goroutine调度或内存操作,导致意外崩溃。因此,利用链应尽可能短小精悍,尽快完成
execve调用。 - 远程与本地环境差异 :本地调试成功的exp,打到远程服务器可能失败。原因包括libc版本不同(导致偏移不同)、网络延迟导致数据粘包、或者服务器有连接超时限制。exp中应加入健壮的错误处理和重试逻辑,并确保所有地址偏移都是从远程泄露动态计算的,而不是写死的。
通过“Shellwego”这道题,我们深入走完了Go语言PWN的完整流程:从环境准备、逆向分析、漏洞定位,到理解Go特有的ABI挑战、设计绕过PIE和NX的ROP链,最后通过动态调试完成利用。这个过程比传统C程序PWN更曲折,但也更有挑战性和学习价值。它强迫你去理解程序更深层的运行机制,而不仅仅是套用模板。掌握这套方法后,你再面对其他Go语言的二进制安全题目,甚至是分析一些真实的Go语言服务端程序时,都会更有底气和思路。
411

被折叠的 条评论
为什么被折叠?



