RuoYi issue5: Department Hierarchy Rebinding

RuoYi issue5: Department Hierarchy Rebinding

Vulnerability call chain

1.1 Summary

RuoYi has a missing authorization vulnerability: Department Hierarchy Rebinding. 越权创建或移动部门,改变 DataScope 使用的部门层级授权路径。

  • Attack precondition: 拥有 system:dept:addsystem:dept:edit
  • Affected authorization property: ``sys_dept.parent_id, sys_dept.ancestors
  • Security impact: 越权创建或移动部门,改变 DataScope 使用的部门层级授权路径。

1.2 Exploit path

POST /system/dept/add/system/dept/edit,提交不可见父部门 parentId

1.3 Key code evidence

  1. ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java

Evidence location: https://github.com/yangzongzhuan/RuoYi/blob/master/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/SysDeptController.java#L77

   74      @PostMapping("/add")75      @ResponseBody76      public AjaxResult addSave(@Validated SysDept dept)77      {78          if (!deptService.checkDeptNameUnique(dept))79          {80              return error("新增部门'" + dept.getDeptName() + "'失败,部门名称已存在");81          }82          dept.setCreateBy(getLoginName());83          return toAjax(deptService.insertDept(dept));84      }85  86      /**87       * 修改部门
  1. ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDeptServiceImpl.java

Evidence location: https://github.com/yangzongzhuan/RuoYi/blob/master/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysDeptServiceImpl.java#L193

  190       * @return 结果191       */192      @Override193      public int insertDept(SysDept dept)194      {195          SysDept info = deptMapper.selectDeptById(dept.getParentId());196          // 如果父节点不为"正常"状态,则不允许新增子节点197          if (!UserConstants.DEPT_NORMAL.equals(info.getStatus()))198          {199              throw new ServiceException("部门停用,不允许新增");200          }201          dept.setAncestors(info.getAncestors() + "," + dept.getParentId());202          return deptMapper.insertDept(dept);203      }204  205      /**206       * 修改保存部门信息207       * 208       * @param dept 部门信息209       * @return 结果210       */211      @Override212      @Transactional213      public int updateDept(SysDept dept)214      {215          SysDept newParentDept = deptMapper.selectDeptById(dept.getParentId());216          SysDept oldDept = selectDeptById(dept.getDeptId());217          if (StringUtils.isNotNull(newParentDept) && StringUtils.isNotNull(oldDept))218          {219              String newAncestors = newParentDept.getAncestors() + "," + newParentDept.getDeptId();220              String oldAncestors = oldDept.getAncestors();221              dept.setAncestors(newAncestors);222              updateDeptChildren(dept.getDeptId(), newAncestors, oldAncestors);223          }224          int result = deptMapper.updateDept(dept);225          if (UserConstants.DEPT_NORMAL.equals(dept.getStatus()) && StringUtils.isNotEmpty(dept.getAncestors())226                  && !StringUtils.equals("0", dept.getAncestors()))227          {
  1. ruoyi-system/target/classes/mapper/system/SysDeptMapper.xml

Evidence location: https://github.com/yangzongzhuan/RuoYi/blob/master/ruoyi-system/target/classes/mapper/system/SysDeptMapper.xml#L89

   86  		select count(*) from sys_dept where status = 0 and del_flag = '0' and find_in_set(#{deptId}, ancestors)87  	</select>88  	89  	<insert id="insertDept" parameterType="SysDept">90   		insert into sys_dept(91   			<if test="deptId != null and deptId != 0">dept_id,</if>92   			<if test="parentId != null and parentId != 0">parent_id,</if>93   			<if test="deptName != null and deptName != ''">dept_name,</if>94   			<if test="ancestors != null and ancestors != ''">ancestors,</if>95   			<if test="orderNum != null">order_num,</if>96   			<if test="leader != null and leader != ''">leader,</if>97   			<if test="phone != null and phone != ''">phone,</if>98   			<if test="email != null and email != ''">email,</if>99   			<if test="status != null">status,</if>100   			<if test="createBy != null and createBy != ''">create_by,</if>101   			create_time102   		)values(103   			<if test="deptId != null and deptId != 0">#{deptId},</if>104   			<if test="parentId != null and parentId != 0">#{parentId},</if>105   			<if test="deptName != null and deptName != ''">#{deptName},</if>106   			<if test="ancestors != null and ancestors != ''">#{ancestors},</if>107   			<if test="orderNum != null">#{orderNum},</if>108   			<if test="leader != null and leader != ''">#{leader},</if>109   			<if test="phone != null and phone != ''">#{phone},</if>110   			<if test="email != null and email != ''">#{email},</if>111   			<if test="status != null">#{status},</if>112   			<if test="createBy != null and createBy != ''">#{createBy},</if>113   			sysdate()114   		)115  	</insert>116  	117  	<update id="updateDept" parameterType="SysDept">118   		update sys_dept119   		<set>120   			<if test="parentId != null and parentId != 0">parent_id = #{parentId},</if>121   			<if test="deptName != null and deptName != ''">dept_name = #{deptName},</if>122   			<if test="ancestors != null and ancestors != ''">ancestors = #{ancestors},</if>123   			<if test="orderNum != null">order_num = #{orderNum},</if>124   			<if test="leader != null">leader = #{leader},</if>125   			<if test="phone != null">phone = #{phone},</if>126   			<if test="email != null">email = #{email},</if>127   			<if test="status != null and status != ''">status = #{status},</if>128   			<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>129   			update_time = sysdate()130   		</set>131   		where dept_id = #{deptId}132  	</update>133  	134  	<update id="updateDeptChildren" parameterType="java.util.List">
  1. ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/DataScopeAspect.java

Evidence location: https://github.com/yangzongzhuan/RuoYi/blob/master/ruoyi-framework/src/main/java/com/ruoyi/framework/aspectj/DataScopeAspect.java#L100

   97                  scopeCustomIds.add(Convert.toStr(role.getRoleId()));98              }99          });100  101          for (SysRole role : user.getRoles())102          {103              String dataScope = role.getDataScope();104              if (conditions.contains(dataScope) || StringUtils.equals(role.getStatus(), UserConstants.ROLE_DISABLE))105              {106                  continue;107              }108              if (StringUtils.isNotEmpty(permission) && !StringUtils.containsAny(role.getPermissions(), Convert.toStrArray(permission)))109              {110                  continue;111              }112              if (DATA_SCOPE_ALL.equals(dataScope))113              {114                  sqlString = new StringBuilder();115                  conditions.add(dataScope);
  1. deptService.c

Evidence location: https://github.com/yangzongzhuan/RuoYi/blob/master/deptService.c

3. Root Cause Analysis

Root Cause 1: Missing server-side authorization on the vulnerable operation.

The endpoint accepts user-controlled authorization-sensitive identifiers or fields, but the write/read path does not prove that the current caller may operate on the target object.

Root Cause 2: Missing object-scope or grant-bound validation.

The implementation relies on endpoint access, UI filtering, or object existence checks instead of enforcing target ownership, tenant boundary, role ceiling, or grantable-resource constraints at the service layer.

add/edit 保存前对 parentId 调用 deptService.checkDeptDataScope(parentId),并阻止跨授权范围移动部门。

5. Verification after fix

  • Unauthorized callers receive HTTP 403 or equivalent rejection.
  • Out-of-scope target identifiers are rejected before database writes or sensitive reads.
  • Role, permission, tenant, organization, ownership, or grant-bound ceilings are enforced server-side.
  • Direct HTTP requests are rejected even when front-end controls are hidden.