记一次(FAKA)CMS漏洞审计
张某 漏洞分析 5218浏览 · 2021-11-17 06:34

这里是无意中在一道ctf中遇到了这道题目,认为有必要去审计记录一下各漏洞触发点,所以将其发表出来.
这里我们进入环境后:


成功找到了后台登录地址,那么现在我们就是要想办法去使用我们的admin用户登录了

那么这里我们下载好了源代码,那么我们进去看看:


这里给了我们一个sql文件,我们导入看看:
这里我们可以直接去我们的sql中来进行导入:
首先进入到我们想要导入的数据库里面,

然后我们使用这个命令导入:

source +sql文件的路径导入


导入成功后我们查看:


这里我们导入了这么多的数据,那么接下来我们去

查看system_user的账号和密码:


这里我们获取到了用户名和密码,那么我们把密码拿去md5解码:

admincccbbb123

文件上传漏洞

这里我们成功登录到了后台,那么接下来我们就去找我们的文件上传点:(这里是常规思路,一般进入后台后都会去找有无文件上传点这种功能块)
这里我们在这个地方:


找到了我们可以上传文件的类型,那么我们可以往里面增加php类型

在站点信息这里我们发现了文件上传的点:


那么我们随便上传一个图片抓包看看:


这里有这几个值,但是我们知道要实现文件上传,那么我们首先就是要知道路径

点击放行之后,我们确实看到了这个文件上传的目录:


(这里和我们后面分析的逻辑是一样的)

那么这里我们如何知道这几个值和我们的路径有什么关系呢,那么这里我们就要去审计我们的源代码了:


这里我们就需要去找到对应的源代码:

这里对应的就是我们的这个upstate函数:


那么首先我们先来看到这个函数,首先我们的变量$filename会由我们post的md5值和我们的filename值组成,这里我们来看看这个
join函数:
join() 函数返回由数组元素组合成的字符串。


这里会以join的第一个为分割符号分割每一个数组元素

那么我们再来看看这个str_split函数:


所以这里的这句话的意思就是:

假如这里我们的md5值是:

1dd114c26d2e32d9afee242d946cd61f

经过这句话:

join('/', str_split($post['md5'], 16))

之后就变成了:

1dd114c26d2e32d9/afee242d946cd61f


然后再和我们的pathinfo函数的值进行拼接,当这个函数的第二个参数为4的时候,表示的是截取.jpg文件后缀,所以这里我们的filename变为了:

1dd114c26d2e32d9/afee242d946cd61f.jpg


接下来是对我们请求参数的处理,这里我们知道上面我们的请求参数是local,那么我们去看看处理方式

会把我们上面生成的这个$filename和我们的session_id拼接在一起,然后再来一次md5值,这里我们去看看这个session_id()函数的作用:


session_id() 可以用来获取/设置 当前会话 ID。


而在这个时候我们看:

我们是没有当前会话的,因为没有phpsess_ID,所以这里默认为空

所以这里我们的这个token值其实是我们可以控制的,因为我们可以在post的时候修改md5值
然后会去调用到:


这个result函数:


在这里面似乎并没有什么其他的操作,那么现在在这个upstate的函数中我们能分析的已经分析完了,那么接下来我们就来到了另外一个函数upload:


这里我们首先我们的$file是再怎么来的,我们看看这里的file函数:


这里传入进来的name参数是字符串file,我们看到这个类是用来处理我们上传的文件的

这里处理逻辑看不懂,我们就先略过,那么我们再往下看:


这里的作用依旧是取出我们上传文件的后缀,那么继续往下:


这里会把我们刚才的post的md5以16个字符为一组分割为数组


然后再拼接起来,那么这里我们达到的效果其实和上面函数是一样的,那么如果正常情况下这里的$filename和上面那个函数的$filname是一样的
那么我们再往下看:


这里很明显是一些过滤,首先它要求我们的这个文件的后缀不能是php,或者不是storage_local_exts里面的,这个是可以通过管理面板改配置来控制的。其次就是于要求我们post的token值要和我们刚才拼接而成的这个$filename的md5值相等(这里我们的session_id()默认为空)

如果这两个地方的验证都通过了,那么就会把我们的文件移动到这个:

/static/upload/$md5[0]/$md5[1]

这个目录下,这里的这个$md5[0]和$md5[1]就是上面根据我们md5值以16位的长度进行分割而形成的数组的值

最终就会上传文件成功,那么这里我们的目的就是去绕过第一个if,这里飞一个if说了我们的后缀不可以是php,那么我们可不可以在我们的最后的$md5[1]里面构造出一个.php呢?

那么这里我们先不管,我们先跟进我们的move函数中去:


首先这里我们的$savename变量是我们想要操作的,那么我们重点看这个变量,这里当刚刚传入的时候这个变量是:

$md5[1]也就是我们可以构造的这段值:afee242d946cd61f

前面是一些对图片的检测:

其中这个check函数,会对我们图片的一些基本信息进行检测:

也就是检测我们上传的是否是一张图片,一般这种类型的检测,如检测大小和Mime类型或者后缀等,我们可以使用这个图片头来进行一个绕过

那么我们接着往下看:


对于这里的path也就是我们第一个参数,也就是这里的
/static/upload/$md5[0]
rtrim() 函数移除字符串右侧的空白字符或其他预定义字符。
也就是这里是去除后边的第一个/符号,然后再拼接上一个/符号,总之最后会变成:

/static/upload/$md5[0]/

那么们看最后的这个参数,$save变量,这里会传入到我们的这个buildSaveName函数中去,那么我们跟进去看看:


因为这里我们的$savename不是true,所以这里我们会进入到最后一个if,这里我们看最后一个if判断就有问题,因为这里我们先来看这个strpos函数:

也就是说这里检测到要是没有出现.的话那么就在我们的$savename变量后边拼接上.上传文件后缀(.jpg),如果我们的这个变量中有.的话那么就直接return出去了,那么如果我们的文件名变为了:

xxxxxxxxxx.php

的话那么就直接把这个名字return出去了

这里我们构造我们的16位长度的值不就可以了吗?

所以我们可以这样构造:

下面是我们总的可以控制的md5值

1dd114c26d2e32d9afee242d946cd61f

那么我们改为:

1dd114c26d2e32d9afee242d946c.php

依旧要保持总的长度为32位,让后面的为16位,那么这里return出去后也就成了:

/static/upload/1dd114c26d2e32d9/afee242d946c.php

这里因为我们的这个$filename会变为:

/static/upload/1dd114c26d2e32d9/afee242d946c.php.jpg

所以这个if那儿进不去,会返回给我们上传失败,但是文件上传的部分我们在move函数中已经完成了,所以还是上传成功了

这里我们上传的时候还是先找一张普通的这个png图片,然后把我们的代码如<?php phpinfo()?>放到后面去就可以了,或者直接使用图片马

这里我们还是选择制作一个图片马:

1.使用记事本打开图片


2.不管里面的内容有多少,只保留前面三行(因为jpg,png的头保存在前三行,若删除则无法被识别成图片文件)


3.之后我们保存下来

然后我们抓包:


j将上面的md5值修改为:

e52360073082563ea6d4a31029d7.php

(注意这里生成的md5值是后端自动生成的,然后我们再去获取这个值,所以当我们改了后在我们这个upstate函数中获取到的md5值就是我们修改后的md5值了)


然后我们先利用上面这段md5值去生成一个token:

<?php
$md5="e52360073082563ea6d4a31029d7.php";
$md5=str_split($md5,16);
$ext="jpg";
$filename = join('/', $md5) . ".{$ext}";
echo md5($filename);

这里的token值是:

00ee0c7f512728e8529cfd35d6f77ad4

然后我们放上去,抓包修改后放包,直到遇到在upload函数处理的时候再次准备修改数据包:


这个时候我们看到它生成的用于比对的token值果然改变了:


但是这里在upload函数中生成的用于对比的token为这一阶段:


而这里去获取的依旧是后端自动生成的:


所以我们同样需要去修改upload函数中用于生成的用来和前面进行对比token值的md5的值,修改为一样的:


然后我们发送:


这里显示的是上传文件失败,但是前面我们分析了为什么显示 失败但是我们还是上传上去了的原因

那么我们去访问到这个目录:

/static/upload/e52360073082563e/a6d4a31029d7.php


成功实现php文件上传,那么接下来我们去上传一个一句话木马:


到此我们文件上传的漏洞就利用成功了

未授权访问添加超级用户

那么在最上面,其实那个登录后台的地方还存在一个问题
也就是说当我们如果说我们是在真实环境里面,我们是没有这个sql用户表的,那么更不可能去拿着我们的什么md5加密后的东西拿去解密了,那么这里我们还是来到我们的这个后台登录界面抓包看看:

这里我们发现抓取的数据包中并没有什么异常

这个时候我们有两个思路,第一个思路是看能不能修改后台管理员的账号密码,第二个思路就是看能不能自己创建一个拥有权限的用户,然后登录

这里我们在这个目录下:

D:\phpstudy_pro\WWW\webfaka\web_faka\html\application\admin\controller\Index.php

发现了两个函数:
1.第一个是pass函数:

public function pass()
    {
        if (intval($this->request->request('id')) !== intval(session('user.id'))) {
            $this->error('只能修改当前用户的密码!');
        }
        if ($this->request->isGet()) {
            $this->assign('verify', true);
            return $this->_form('SystemUser', 'user/pass');
        }
        $data = $this->request->post();
        if ($data['password'] !== $data['repassword']) {
            $this->error('两次输入的密码不一致,请重新输入!');
        }
        $user = Db::name('SystemUser')->where('id', session('user.id'))->find();
        if (md5($data['oldpassword']) !== $user['password']) {
            $this->error('旧密码验证失败,请重新输入!');
        }
        if (DataService::save('SystemUser', ['id' => session('user.id'), 'password' => md5($data['password'])])) {
            $this->success('密码修改成功,下次请使用新密码登录!', '');
        }
        $this->error('密码修改失败,请稍候再试!');
    }

这个函数对应有一个未授权就可以访问的路由:

/admin/index/pass

这里我们看到源代码发现要想修改我们的密码,我们必须还是要知道原来的密码,那么这里就行不通了


并且抓包也没有发现任何可更改数据


那么我们再往下看:

public function info()
    {
        if (intval($this->request->request('id')) === intval(session('user.id'))) {
            return $this->_form('SystemUser', 'user/form');
        }
        $this->error('只能修改当前用户的资料!');
    }

这里还有一个这个info函数,那么我们去访问对应的路由:


发现同样可以访问到,这里也是一个未授权就可以访问到的路由,那么我们看这个路由的作用是用来添加我们用户的,那么这里我们随便添加一个试试看:


这里我们打算去添加一个admin用户


发现这里不可以添加的原因是因为已经有了一个admin用户了,那么我们去添加一个其他用户看看:


成功添加了一位用户,那么我们去登录一下:


这里成功登录了上去,说明我们添加用户成功

但是这里我们可以看到该用户的功能特别的少,那么这里我们应该知道是我们的身份(权限)不对,那么这里我们继续去审计源代码:

这里我们跟进这个_form函数:

protected function _form($dbQuery = null, $tplFile = '', $pkField = '', $where = [], $extendData = [])
    {
        $db = is_null($dbQuery) ? Db::name($this->table) : (is_string($dbQuery) ? Db::name($dbQuery) : $dbQuery);
        $pk = empty($pkField) ? ($db->getPk() ? $db->getPk() : 'id') : $pkField;
        $pkValue = $this->request->request($pk, isset($where[$pk]) ? $where[$pk] : (isset($extendData[$pk]) ? $extendData[$pk] : null));
        // 非POST请求, 获取数据并显示表单页面
        if (!$this->request->isPost()) {
            $vo = ($pkValue !== null) ? array_merge((array)$db->where($pk, $pkValue)->where($where)->find(), $extendData) : $extendData;
            if (false !== $this->_callback('_form_filter', $vo)) {
                empty($this->title) || $this->assign('title', $this->title);
                return $this->fetch($tplFile, ['vo' => $vo]);
            }
            return $vo;
        }
        // POST请求, 数据自动存库
        $data = array_merge($this->request->post(), $extendData);
        if(isset($data['password'])){
            if( !empty($data['password'])) {
                $data['password'] = md5($data['password']);
            }else{
                unset($data['password']);
            }
        }
        if (false !== $this->_callback('_form_filter', $data)) {
            $result = DataService::save($db, $data, $pk, $where);
            if (false !== $this->_callback('_form_result', $result)) {
                if ($result !== false) {
                    $this->success('恭喜, 数据保存成功!', '');
                }
                $this->error('数据保存失败, 请稍候再试!');
            }
        }
    }

前面没有什么特别的操作,主要是看到这里对我们含有密码的$data数组处理:


这里会将我们的data变量传入进我们的回调函数_form_filter中,那么我们跟进这个_form_filter函数看看:

这里我们全局搜索这个函数:


最终在这里找到了它:

public function _form_filter(&$data)
    {
        if ($this->request->isPost()) {
            if (isset($data['authorize']) && is_array($data['authorize'])) {
                $data['authorize'] = join(',', $data['authorize']);
            }
            if (isset($data['id'])) {
                unset($data['username']);
            } elseif (Db::name($this->table)->where(['username' => $data['username']])->count() > 0) {
                $this->error('用户账号已经存在,请使用其它账号!');
            }
        } else {
            $data['authorize'] = explode(',', isset($data['authorize']) ? $data['authorize'] : '');
            $this->assign('authorizes', Db::name('SystemAuth')->where(['status' => '1'])->select());
        }
    }

我们发现这里在我们的$data数组中还有一个authorize的键值,而根据在这个键值的意思这个应该是权限的意思,那么这里我们去看到我们admin用户的该值是多少:


我们可以看到是3,那么这里默认是没有设置这个值的,那么它就是一个普通用户,而我们需要在post的时候自带这个值:


那么我们放包之后 :


注册成功,我们去登录:


成功登录该用户,那么这样我们admin权限用户就伪造成功了

任意文件下载:

其实在这个时候,本来是打算已经结束审计的了,但是在功能点处又看到了这样的字样:


那么我们知道,备份一般会存在一个下载备份文件的这么一个操作,我们点进去看,发现果然可以下载我们的备份文件


我们点击下载后抓包:


发现了一个我们熟悉的可能存在漏洞的形式:下载路径可控

那么这里我们尝试着去测试一下:


我们成功下载了下来,改为txt格式尝试:


果然读取成功了
到这里所发现的漏洞点已经全部审计完毕,这次审计也是一般的思路流程:
后台--->功能点---->漏洞点

2 条评论
某人
表情
可输入 255