HackTheBox-RopeTwo-[pwn-rshell]
前言
此篇文章专门记录hack the box - ropetwo机器中获取user权限的pwn rshell部分, Tcache堆利用。
下载binary文件分析
1 | chromeuser@rope2:~$ find / -perm -u=s -type f 2>/dev/null |
下载rshell文件
1 | chromeuser@rope2:/usr/bin$ python3 -m http.server 8001 |
1 | ┌──(root💀kali)-[~/hackthebox/machine/rope2] |
主要循环
使用Ghidra打开看了看。在搜索字符串“command not found” 时,定位在0x1019be处的函数,将其命名为process_input。调用这个函数的函数是0x101b93,将调用main_loop
使用ghidra重命名函数名和变量名之后的伪代码如下所示,便于阅读:
1 | void main_loop(void) |
它初始化一些东西,然后进入一个无限循环,打印$,读取最多200个字符,null结束输入,并将其传递给process_input, process_input根据输入中的前几个字符采取行动:
输入开始 | 采取动作 |
---|---|
“ls “ | 调用 do_ls [0x1015cf] |
“add “ | 调用 do_add(user_input[4:]) [0x101345] |
“rm “ | 调用 do_rm(user_input[3:]) [0x10166d] |
“echo “ | 输出字符串的其余部分 |
“edit “ | 调用 do_edit(user_input[5:]) [0x1017d8] |
“whoami” | 打印 “r4j” |
“id” | 打印 “uid=1000(r4j) gid=1000(r4j) groups=1000(r4j)” |
其它 | 打印 “rshell: %s: command not found” |
Commands
该程序一次最多保存两个“文件”,其中file1 on是0x104060的全局文件,file2是0x104130。对于每个文件,前8个字节包含一个指向用户提供的包含文件内容的malloc创建的堆缓冲区的指针。有一种检查只允许大小小于或等于0x70字节。最后一个0xC8(200)字节保存文件的名称。add函数确保第二个文件不能与现有文件同名,并且不能添加超过两个文件。这将是利用该二进制文件的最大限制,因为每次只使用两个文件就很难将堆操作到您想要的位置。
ls命令在两个槽上循环,如果指针非空,它将打印文件的名称。它不打印任何内容,而且无法合法地获取文件的内容,这为开发提供了另一个需要克服的主要障碍。
rm命令在两个槽上循环,查看偏移量为8的字符串,如果字符串与输入匹配,则将字符串文件名全部设置为null,释放包含内容的堆内存,并将指针设置为null。
edit命令是漏洞存在的地方。
使用ghidra将FUN_001017d8函数中各个变量命名成如下形式,便于阅读:
1 | void do_edit(char *param_1) |
这个循环首先检查i是否大于1,这表示它已经遍历了两个配置,但没有找到要编辑的匹配文件,在这种情况下,它打印一条错误消息并退出。然后,它检查当前项,看看名称是否与输入匹配,如果匹配,它会提示输入一个新的大小(确保小于0x71),并在现有缓冲区上使用realloc。然后读取并存储内容,并完成。
配置
- 利用脚本
为了与这个二进制文件交互,使用一些工具。首先,启动一个Python利用脚本。首先,它将只有与rshell中的各种命令交互的方法(这个脚本假设我的ssh公钥在chromeuser的authorized_keys文件中):
- exp.py
1 |
|
- Libc
使用与RopeTwo相同的libc
1 | chromeuser@rope2:~$ ldd /usr/bin/rshell |
使用scp来获取libc.so.6, (在我的主机把它命名为libc.so.6-ropetwo)和ld-linux-x86-64.so.2。
想要libc的debug符号,所以使用与PlayerTwo相同的过程:
1 | ┌──(root💀kali)-[~/hackthebox/machine/rope2] |
现在将使用patchelf告诉rshell使用这些:
1 | ┌──(root💀kali)-[~/hackthebox/machine/rope2] |
本地rshell正在使用这两个库:
1 | ┌──(root💀kali)-[~/hackthebox/machine/rope2] |
- gdb
对于初始环境,在主机上运行
1 | echo 0 > /proc/sys/kernel/randomize_va_space |
来转向ASLR。这将允许在想要的地方放一个断点,而不必每次都调整。
为了跟踪堆,使用Pwngdb(不要与pwndbg混淆)。heapinfo命令打印出各种freed bins的漂亮视图。
将所有这些放在一起,为gdb创建了以下初始化文件:
1 | set pagination off |
这将在读取下一个命令之前,在0xc2b处设置一个断点。在这个断点处,它将计算出两个文件的名称和内容,并打印结果。然后,它将从堆中打印48个单词在工作的区域(确实修改了这个地址,因为关注的是堆的不同部分)。最后,它将运行heapinfo来显示bins。然后它会继续。通过这种方式,可以逐步遍历Python脚本并从那里运行函数,gdb窗口将继续在每个命令之后打印状态。
确实在~/.gdbinit文件中注释了Peda的来源,因为无法让它停止在每个break上打印上下文,这会将正在打印的所有信息从屏幕上删除。
放在一起
现在,启动Python脚本,Python调试器(pdb)设置为停止在最后一行,以便可以继续运行命令:
1 | ┌──(root💀kali)-[~/hackthebox/machine/rope2] |
现在在另一个面板中,将附加gdb,然后告诉它继续:
1 | ┌──(root💀kali)-[~/hackthebox/machine/rope2] |
当添加一个文件:
1 | (Pdb) add('test', 0x60, 'this is a test') |
gdb更新:
1 | [0x000055555555b260] test : this is a test |
注释
开发这种漏洞利用程序太疯狂了。每次进入下一个步骤时,都必须返回并完全重新构建前一个,移动方块,尝试不同大小的bins。不可能在本文中展示所有这些步骤,但是,将尝试在完成的脚本中完成不同的目标,并展示如何使用脚本操作堆来完成所需要的任务。但是值得补充的是,当第一次在堆上获得libc地址时,代码看起来完全不同。当进入下一个步骤时,将使用相同的通用技术,但需要完全重新设计不同bins的间距和大小,以完成已经实现的目标和下一个目标。
弱点
- 描述
这里的漏洞在于编辑命令中的realloc,以及它如何根据当前大小和新大小做出反应:
大小比较 | 采取动作 |
---|---|
new_size == 0 | free(buffer), return null |
new_size > 0 && new_size < orig_size | 分成两个块,返回相同的地址和更小的块,并在旧块的末尾留下空闲块 |
new_size > 0 && new_size > orig_size | 释放旧块,为更大的块分配新块,并返回该地址。 |
上面的代码检查返回值是否为null,但随后只打印一条消息,而不更改存储的指针。这会给用户留下一个指向已释放内存的指针,这是很糟糕的。
- 例子
为了看到这一点,将运行以下代码:
1 | (Pdb) add('1', 0x50) |
这样就剩下了以下内容(<– 由我添加):
1 | ----------------------------------- |
这是可利用的,因为还有一个指向edit2的指针,尽管它已经被释放了。这意味着可以通过编辑2来更改tcache链表。
在堆上得到libc
- 策略
从这个小漏洞到代码执行的路径并不明显。因为ASLR,需要做的第一件事就是从libc泄露一些信息。要做到这一点,首先需要从libc获得一些东西到堆上。当释放这些bins时,它们将进入tcache,这是一堆从libc开始的单链表,然后列表容器中的每一项都是指向下一项的指针。但其他类型是双链表。这意味着每个节点既指向它后面的节点,也指向它前面的节点,因此第一个节点在libc中具有起始节点的地址。可以通过用7个给定大小的bins填充tcache,然后再释放一个bin,将一些东西放入未排序的bins中。
这将需要大量的跳跃,当被限制在只有两个“文件”的程序在同一时间。
- 间距
ASLR和Full RELRO将改变每个单词除了低12位以外的所有内容。可以一遍又一遍地运行程序,只要以相同的顺序定义相同的块,每个地址的低12位将是相同的。这意味着可以找到一些地方,在那里可以只覆盖地址中的低字节,从而在不知道高字节的情况下更改它。这也意味着有些时候想从某个特定的空间开始。如果在0x2e0处创建了一个假块,并且不能覆盖当前指向0x320的指针。但是如果在开始时只向堆中添加一小块,并将它们移到0x310和0x350,现在可以用0x10覆盖较低的0x50。
- 假块
创建一个假的重叠块,它可以写入下一个块的元数据。可以在上面的示例中选择,但不是第一个块写入任何感兴趣的内容,而是让它写入一些看起来像堆元数据的内容,例如,0x61:
1 | add('A', 0x40, p64(0)*5 + p64(0x61) + p64(0)) # A at 260 in f1 |
当运行这个时,堆看起来像:
1 | 0x55555555c250: 0x0000000000000000 0x0000000000000051 <-- A meta |
释放A,然后释放B,把它编辑为0。然后在添加另一个0x40 bin时,将使两个文件指向同一个位置。从这里开始,移除第一个B将会进入想要攻击的地方:
1 | rm('A') # A in 0x50 tcache, f1 empty |
堆循环如下:
1 | 0x55555555c250: 0x0000000000000000 0x0000000000000051 <-- A meta |
当现在用一个字符编辑B2, 0x90,它将覆盖tcache指针:
1 | edit('B2', 0x40, '\x90') # B -> fake1 in 0x50 tcache, f1 -> B |
Heap:
1 | 0x55555555c250: 0x0000000000000000 0x0000000000000051 |
heapinfo也显示了这个:
1 | (0x50) tcache_entry[3](2): 0x55555555c2b0 --> 0x55555555c290 (overlap chunk with 0x55555555c2a0(freed) ) |
如果想要访问这个块,首先需要去掉2b0。不能把它拿出来释放,否则它会回到原来的地方。所以会得到它,把它编辑成更小的大小,然后释放它,让两个更小的块进入tcache,留下假块准备好被使用。
1 | add('B3', 0x40) # fake in 0x50 tcache, f2 -> B |
现在,将添加一个0x40条目,它将返回290,可以使用它覆盖到b中。在这样做之前,有另一个问题。想释放B2,但不能,因为它会返回一个双自由错误。它正在检查size元数据后面0x10的键,所以需要更改它。幸运的是,有了新的重叠部分。因此,将创建它,使它保持0x51不变,为下一个tcache添加一个null,并更改键。
1 | add('fake1', 0x40, p64(0)*3 + p64(0x51) + p64(0)) |
现在堆显示key不再匹配坏值:
1 | 0x55555555c250: 0x0000000000000000 0x0000000000000051 |
将释放B2和fake1,它们都进入tcache,需要一种向任意地址写入的方法时,可以在最后使用它们。
1 | rm('fake1') |
tcache现在看起来像:
1 | (0x20) tcache_entry[0](1): 0x55555555c2e0 |
- 第二假块
现在,将用大致相同的方式创建另一个假块 (在间隔一些时间之后):
1 | add('x', 0x30) # add for spacing, but used later |
在这里做同样的事情,尽管这次有另一个块,在偷取指针的那个块和有那个指针的那个块之间。最后,留下了两个重叠块的句柄。假块报告的大小是0xa1(尽管不能在这个程序中创建那么大的块),另一个是在它能够写入这个块之前。
1 | ----------------------------------- |
- 得到未分类的bin
这允许做的是释放fake2,然后使用B来更改密钥,然后再次释放它。将总共释放它8次,前7次进入tcache,最后一次进入未排序的bins,这将把双链表指针留在堆上。还需要确保一个元数据后面的0xa0字节是一个有效的元数据,这就是为什么当创建E时,在里面喷洒了很多0x21字节。
1 | # 释放fake来填充tcache, 然后从保护双重free覆盖key |
现在打开了两个文件,栈上有一个libc地址:
1 | ----------------------------------- |
- 这个指针是什么?
让gdb打印这个地址显示它在main_arena:
1 | (gdb) x/xg 0x00007ffff7fc3ca0 |
这篇文章很好地解释了main_arena是什么:
库glibc有一个全局的“struct malloc_state”对象,名为main_arena,它是所有托管堆内存的root。
gdb显示了它如何包含堆中使用的所有不同指针的:
1 | (gdb) p main_arena |
泄露Libc
- FSOP泄露
使用一种名为面向文件系统编程(FSOP)的技术来泄漏libc地址。FSOP的思想是攻击文件流对象的GLIBC实现。这篇2018年的论文详细介绍了文件流和文件对象结构。如果程序自己创建了一个用fopen打开的文件,就能找到那个结构并打乱它。文件对象中还有函数指针,可以覆盖它们以执行代码。但还不能这样做,因为还不知道应该写什么地址。
所能做的是找到并覆盖_flag和_IO_write_base指针,以便在写入该文件流的下一个操作中写入其他内容。
当然,所有这些都假设有一个FILE对象。stdin和stdout是保存在LIBC空间中的文件对象。因为使用的libc带有debug符号,可以通过名称打印stdout,并使用&获得地址:
1 | (gdb) p _IO_2_1_stdout_ |
因此,如果可以将_IO_write_base更改为_IO_write_end之前的某个值,将诱使stdout认为内容已被缓存并等待发送。当前指针是0x7ffff7fc47e3。如果只写一个空字节,那么它将是0x7ffff7fc4700:
1 | (gdb) x/12xg 0x00007ffff7fc4700 |
如果打印过来,就会泄露libc绕过ASLR。
- 改变指向_IO_2_1_stdout的指针
当最后结束时,已经将main_arena中的这个指针写到堆中,并设法释放了两个“文件”。也可以访问一个重叠块之前的main_arena指针:
1 | 0x55555555c3a0: 0x0000000000000000 0x0000000000000081 <-- chunk B, currently in tcache |
想写0x7ffff7fc4760。将获取B,并使用它修改libc地址的低两个字节,指向_IO_2_1_stdout_:
1 | add('B', 0x70) |
现在在堆上有了想要写入的地址:
1 | 0x55555555c3a0: 0x0000000000000000 0x0000000000000081 |
当发现启用了ASLR时,这可能会失败。这是因为地址的低三个字节(0x760)是一致的,高一点的字节(在本例中是4)将改变ASLR。一旦它正常工作,就会打开ASLR,这样它答对的概率就会是1/16, 6.25% 尽管如此,仍然可以反复进行攻击,所以在10次尝试中,有50%的几率成功,在20次尝试中,我有75%的几率成功。将更新脚本以循环失败。
- 定位写到_IO_2_1_stdout
现在,想在这个结构上创建一个块,将使用与以前相同的tcache毒化技术。经过一堆重写,设法达到这一点的tcache看起来像:
1 | (0x20) tcache_entry[0](1): 0x55555555c2e0 |
如果目标地址在0x300s中,那么0x40 tcache bins看起来是一个很好的目标。将使用与上面相同的技巧编辑指针,在460处获取bin,将其编辑为0,使第二个文件指向它,释放第一个文件,以便在有一个句柄的时候它回到tcache中,然后编辑地址。
1 | add('F', 0x30) # fetch 460 chunk from tcache, 300 still in 0x40 tcache |
此时,0x40 tcache列表指向_IO_2_1_stdout_:
1 | (0x40) tcache_entry[2](0): 0x7ffff7fc4760 --> 0xfbad2887 (invalid memory) |
- 写_IO_2_1_stdout
如上所述,将攻击stdout的文件结构。在gdb中展示了上面的结构体,但源代码也很有用:
1 | 245 struct _IO_FILE { |
将覆盖_flags、三个_IO_read_*地址和_IO_write_ptr的低字节。_flags在这里定义:
1 | 92 #define _IO_MAGIC 0xFBAD0000 /* Magic number */ |
两个高字节将是0xfbad,这是该结构的神奇数字。对于底部的两个字节,将打开_io_currently_puts和_IO_IS_APPENDING。这些标记是在其他CTF文章中看到过的,在这里表明这些数据已经准备好了。
不认为在三个_IO_read_*字段中写什么是非常重要的,因为没有东西从stdout读取。然后,想要一个在_IO_write_ptr空字节的低字节。
将得到一个机会写到_IO_2_1_stdout_,因为调用edit将调用realloc,这将失败,因为它将发现意外的数据,它期待堆元。仔细观察add,它使用fgets,所以它将读取到完整的大小,或者直到换行。因为不会写完整的大小,所以sendline的换行符会在那里。它还在输入的末尾追加一个null。
把所有这些放在一起,将创建如下的bin:
1 | add('stdout', 0x30, p64(0xfbad1800) + p64(0)*2 + b"\x00"*7) |
- 泄露
为了看到实际操作,将运行对_IO_2_1_stdout_的写入操作,然后附加gdb并在0x0000555555555b92处设置一个额外的断点。这是在添加完成之后,但在打印任何内容之前(一旦打印完成,文件中的所有指针都将被更新)。现在,将跳过Python脚本中的泄漏指令,gdb将达到这个断点。将检查_IO_2_1_stdout_:
1 | Breakpoint 2, 0x0000555555555b92 in ?? () |
当允许继续这样做(并且通过在调用的末尾添加日志级别为DEBUG的Python脚本运行),可以看到数据返回:
1 | [DEBUG] Sent 0xb bytes: |
跟之前看到的和预期的相符。可以从8-15字节中提取libc地址。
当第一次解决这个问题时,没有添加返回任何东西,但更新它返回任何返回的东西,所以可以捕捉它,并从它获得libc地址。
将查看这个地址和/proc/$(pidof rshell)/maps文件,以查看从它到libc基址的偏移量是0x1e7570。
- 追加ASLR说明
前面提到过,这里有一些关于ASLR的问题。正在修改底部的两个字节(或四个字节)。但只知道想要的low three nibbles是什么。因此,在猜测第四点。有2^4 = 16个可能的值,所以1/16的机会是正确的,6.25%尽管如此,仍然可以反复进行攻击,所以在10次尝试中,我有50%的几率成功,在20次尝试中,有75%的几率成功。将更新脚本以循环失败。
执行
- 写到__free_hook
既然泄露了libc的消息,就可以被判死刑了。将用system覆盖__free_hook,然后释放一个包含”/bin/sh”的块。
遇到的第一个挑战是,不能释放指向_IO_2_1_stdout_的 “文件”,因为它将导致崩溃。这意味着从现在开始,只能使用一个文件。给出的第一个关于如何创建一个假块的例子实际上就是为这个做准备的。当最初到达这一点时,必须返回并在一开始添加它,以便它可以从这里获取(这意味着重新工作所有的间距等)。一旦这样做了,就达到了以下状态:
1 | ----------------------------------- |
因此,可以获得块fake1,并使用它来修改当前位于0x50 tcache中的块B。如果用一个指针替换当前为null(表示链表的结束)的第一个单词,该指针有效地连接了tcache列表。
1 | add('K', 0x50, p64(0)*3 + p64(0x51) + p64(libc.symbols['__free_hook']-8)) # add free hook to tcache 0x50 |
运行完这些之后,指针被添加:
1 | 0x55555555c280: 0x0000000000000000 0x0000000000000061 |
在heapinfo中显示:
1 | (0x50) tcache_entry[3](1): 0x55555555c2b0 --> 0x7ffff7fc65a0 |
需要去掉2b0块:
1 | # clear from tcache with add, shrink, rm |
现在只要添加一个0x40字节的条目,然后释放它。
1 | add('hook', 0x40, b"/bin/sh\x00" + p64(libc.symbols['system'])) |
- __free_hook Payload
payload也有点棘手。一旦写入__free_hook,不能释放它而不造成崩溃,所以两个文件都被有效地使用了。 为了解决这个问题,将把这个块指向__free_hook之前的8个字节。将用”/bin/sh\x00”覆盖这些字节,然后继续系统地址。这样,当程序读取块内容(或将其传递给_free_hook)时,就会得到字符串“/bin/sh”。
shell
最后的脚本在这里。因为PwnTools中的shell不是很好,所以为远程目标添加了一行代码,以便自动编写创建authorized_keys文件,这样就可以直接跳转到SSH连接。
- pwn_rshell.py
1 |
|
运行它会返回一个shell:
1 | ┌──(root💀kali)-[~/hackthebox/machine/rope2] |
SSH也可以正常连接:
1 | ┌──(root💀kali)-[~/hackthebox/machine/rope2] |
Damn! Really Crazy! Ha?