漏洞概述禪道是第一款國產的開源項目管理軟件,也是國內最流行的項目管理軟件。該系統在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
復現環境
操作系統:macOS13.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(),該方法是直接拋出異常,就會進入上面的trycache邏輯。
publicstaticfunctionend($content='')
{
throwEndResponseException: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服務器是否可以連接。
publicfunctionupdate($id){
$repo=$this-getRepoByID($id);
if(!$this-checkConnection())returnfalse;
$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))returnfalse;
$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')
{
/*Getsvnversion.*/
$versionCommand='$client--version--quiet21';
exec($versionCommand,$versionOutput,$versionResult);
if($versionResult)
{
$message=sprintf($this-lang-repo-error-output,$versionCommand,$versionResult,join('
',$versionOutput));
dao:$errors['client']=$this-lang-repo-error-cmd.'
'.nl2br($message);
returnfalse;
}
$svnVersion=end($versionOutput);
命令執行最終效果截圖: