漏洞概述
禅道是第一款国产的开源项目管理软件,也是国内最流行的项目管理软件。该系统在2023年初被爆出在野命令执行漏洞,官方已于2023年1月12日发布了漏洞修复补丁。该漏洞是由于禅道项目管理系统权限认证存在缺陷导致,攻击者可利用该漏洞在未授权的情况下,通过权限绕过在服务器执行任意命令。
本文以安全研究为目的,分享对该漏洞的研究和复现过程,仅供学习和参考。由于传播、利用此文档提供的信息而造成任何直接或间接的后果及损害,均由使用者本人负责,文章作者不为此承担任何责任。
影响范围
禅道系统 | 影响版本 |
开源版 | 17.4以下的未知版本<=version<=18.0.beta1 |
旗舰版 | 3.4以下的未知版本<=version<=4.0.beta1 |
企业版 | 7.4以下的未知版本<=version<=8.0.beta1 8.0.beta2 |
复现环境
操作系统:macOS 13.1
运行环境:nginx1.5 php7.4 mysql5.7
软件版本:zentaopms-zentaopms_18.0.beta1
权限绕过-漏洞分析
权限绕过的关键点在module/common/model.php文件中checkPriv函数,此函数是检查权限的函数,验证当前登陆用户是否有访问module与method的权限。分析代码后得知在没有访问权限时会抛出异常,但是代码中并没有终止程序,只是输出权限不足的内容。具体代码如下:
public function checkPriv()
{
try
{
$module = $this->app->getModuleName();
$method = $this->app->getMethodName();
if($this->app->isFlow)
{
$module = $this->app->rawModule;
$method = $this->app->rawMethod;
}
$beforeValidMethods = array(
'user' => array('deny', 'logout'),
'my' => array('changepassword'),
'message' => array('ajaxgetmessage'),
);
if(!empty($this->app->user->modifyPassword) and (!isset($beforeValidMethods[$module]) or !in_array($method, $beforeValidMethods[$module]))) return print(js::locate(helper::createLink('my', 'changepassword')));
if($this->isOpenMethod($module, $method)) return true;
if(!$this->loadModel('user')->isLogon() and $this->server->php_auth_user) $this->user->identifyByPhpAuth();
if(!$this->loadModel('user')->isLogon() and $this->cookie->za) $this->user->identifyByCookie();
if(isset($this->app->user))
{
if(in_array($module, $this->config->programPriv->waterfall) and $this->app->tab == 'project' and $method != 'browse') return true;
$this->app->user = $this->session->user;
if(!commonModel::hasPriv($module, $method))
{
if($module == 'story' and !empty($this->app->params['storyType']) and strpos(",story,requirement,", ",{$this->app->params['storyType']},") !== false) $module = $this->app->params['storyType'];
$this->deny($module, $method);
}
}
else
{
$uri = $this->app->getURI(true);
if($module == 'message' and $method == 'ajaxgetmessage')
{
$uri = helper::createLink('my');
}
elseif(helper::isAjaxRequest())
{
die(json_encode(array('result' => false, 'message' => $this->lang->error->loginTimeout))); // Fix bug #14478.
}
$referer = helper::safe64Encode($uri);
die(js::locate(helper::createLink('user', 'login', "referer=$referer")));
}
}
catch(EndResponseException $endResponseException)
{
echo $endResponseException->getContent();
}
}
其中commonModel::hasPriv()函数是内置公共的验证权限,代码中可以看出无权限访问就会执行deny 方法,而deny 最后验证的结果是无权限则执行helper::end(),该方法是直接抛出异常,就会进入上面的try cache逻辑。
public static function end($content = '')
{
throw EndResponseException::create($content);
}
在进入权限检查的流程前需要在$this->app->user 不为空的情况下将 $this->session->user赋值给 $this->app->user ,然后再做权限检查。因此我们还需要构造一个$this->session->user,即写一个session['user']才能进行绕过。所以现在思路很清晰了,只需$this->session->user 存在就可以通过⽤户是否登录的检查,使权限检查的函数如同虚设。 根据这个思路逆推可以得出结论:只要有任意⼀个⽤户session就可以调⽤任意模块的任意⽅法。
经过代码审计发现captcha函数可以直接写入一个自定义key的session,此段代码本意是设置生成一个自定义session的key的验证码,开发者应该是想写一个公共的验证码生成函数让其他开发者做新功能需要的时候可以直接调用,正好可以利用生成一个key为user的session。
public function captcha($sessionVar = 'captcha', $uuid = '')
{
$obLevel = ob_get_level();
for($i = 0; $i < $obLevel; $i++) ob_end_clean();
header('Content-Type: image/jpeg');
$captcha = $this->app->loadClass('captcha');
$this->session->set($sessionVar, $captcha->getPhrase());
$captcha->build()->output();
}
通过上述思路可以成功实现权限绕过,不过经过实际测试发现,能绕过访问的皆为公共模块。因为在禅道的功能权限验证中还有一部分是验证userid或level。就好比某些用户有“项目1”的权限,某些用户有“项目2”的权限,所以类似这类的数据任然不能访问获取。
命令执行-漏洞分析
实际上整个利用链最关键的一环就在上面的权限绕过上,禅道系统后台本身存在多个sql注入及命令执行漏洞,本文给出一种后台命令执行的方法供参考,其他利用点感兴趣的小伙伴可自行研究。
在权限绕过后,接下来我们需要分析后台命令执行点的位置。通过代码审计,最终锁定在module/repo/model.php文件,其中checkConnection函数会进行SCM=Subversion判断,$client是导致命令注入的参数点,一条完整的函数间调用的利用过程如下所示:
module/repo/model.php->create()
module/repo/control.php->edit ()
module/repo/model.php->update($repoID)->checkConnection()->exec($versionCommand,$versionOutput, $versionResult);
PS:为什么要创建仓库,因为在查看checkConnection调用函数为create和update,但是在create的时候必须经过checkClient 的判断,必须要创建一个文件才行,如果SCM指定为Gitlab就不需要通过checkClient判断。
具体复现思路如下:
1.进入创建仓库的函数:module/repo/model.php
public function create()
{
if(!$this->checkClient()) return false;
if(!$this->checkConnection()) return false;
$isPipelineServer = in_array(strtolower($this->post->SCM), $this->config->repo->gitServiceList) ? true : false;
$data = fixer::input('post')
->setIf($isPipelineServer, 'password', $this->post->serviceToken)
->setIf($this->post->SCM == 'Gitlab', 'path', '')
->setIf($this->post->SCM == 'Gitlab', 'client', '')
->setIf($this->post->SCM == 'Gitlab', 'extra', $this->post->serviceProject)
->setIf($isPipelineServer, 'prefix', '')
->setIf($this->post->SCM == 'Git', 'account', '')
->setIf($this->post->SCM == 'Git', 'password', '')
->skipSpecial('path,client,account,password')
->setDefault('product', '')
->join('product', ',')
->setDefault('projects', '')->join('projects', ',')
->get();
$data->acl = empty($data->acl) ? '' : json_encode($data->acl);
if($data->SCM == 'Subversion')
{
$scm = $this->app->loadClass('scm');
$scm->setEngine($data);
$info = $scm->info('');
$infoRoot = urldecode($info->root);
$data->prefix = empty($infoRoot) ? '' : trim(str_ireplace($infoRoot, '', str_replace('\\', '/', $data->path)), '/');
if($data->prefix) $data->prefix = '/' . $data->prefix;
}
当SCM类型指定为Subversion时,后续控制$client才可以完成命令注入。
2.编辑代码仓库进入module/repo/control.php中的edit函数,post传参会进入到update函数。
public function edit($repoID, $objectID = 0)
{
$this->commonAction($repoID, $objectID);
$repo = $this->repo->getRepoByID($repoID);
if($_POST)
{
$noNeedSync = $this->repo->update($repoID);
if(dao::isError()) return $this->send(array('result' => 'fail', 'message' => dao::getError()));
$newRepo = $this->repo->getRepoByID($repoID);
$actionID = $this->loadModel('action')->create('repo', $repoID, 'edited');
$changes = common::createChanges($repo, $newRepo);
$this->action->logHistory($actionID, $changes);
跟踪update函数到module/repo/model.php,需要将scm设置为Subversion,此时会去检测svn服务器是否可以连接。
public function update($id)
{
$repo = $this->getRepoByID($id);
if(!$this->checkConnection()) return false;
$isPipelineServer = in_array(strtolower($this->post->SCM),$this->config->repo->gitServiceList) ? true : false;
$data = fixer::input('post')
->setIf($isPipelineServer, 'password', $this->post->serviceToken)
->setIf($this->post->SCM == 'Gitlab', 'path', '')
->setIf($this->post->SCM == 'Gitlab', 'client', '')
->setIf($this->post->SCM == 'Gitlab', 'extra', $this->post->serviceProject)
->setDefault('prefix', $repo->prefix)
->setIf($this->post->SCM == 'Gitlab', 'prefix', '')
->setDefault('client', 'svn')
->setDefault('product', '')
->skipSpecial('path,client,account,password')
跟踪该函数,$this->post->SCM等于Subversions时会去check svn服务器version,此刻会把$this->post->client拼接到执行的versionCommand 中,造成命令执行。
if(empty($_POST)) return false;
$scm = $this->post->SCM;
$client = $this->post->client;
$account = $this->post->account;
$password = $this->post->password;
$encoding = strtoupper($this->post->encoding);
$path = $this->post->path;
if($encoding != 'UTF8' and $encoding != 'UTF-8') $path = helper::convertEncoding($path, 'utf-8', $encoding);
if($scm == 'Subversion')
{
/* Get svn version. */
$versionCommand = "$client --version --quiet 2>&1";
exec($versionCommand, $versionOutput, $versionResult);
if($versionResult)
{
$message = sprintf($this->lang->repo->error->output, $versionCommand, $versionResult, join("<br />", $versionOutput));
dao::$errors['client'] = $this->lang->repo->error->cmd . "<br />" . nl2br($message);
return false;
}
$svnVersion = end($versionOutput);
命令执行最终效果截图:
修复建议
目前禅道官方已正式发布修复版本,建议受影响用户尽快升级至安全版本。
如不能升级,可在module/common/model.php文件中的echo $endResponseException->getContent();后面加上exit(); 来修复权限绕过漏洞。
公众号回复“zentaopoc”可获取漏洞自检脚本,该脚本仅用于检测自有系统是否存在漏洞,若确认漏洞存在请尽快进行版本升级和修复。