PoC Archive PoC Archive
Medium CVE-2026-44166 / [GHSA-pq7p-mc74-g65w](https://github.com/pocketbase/pocketbase/security/advisories/GHSA-pq7p-mc74-g65w) unpatched

PocketBase OAuth2 Account Pre-Hijacking (CVE-2026-44166)

by Alardiians · 2026-07-05

CVSS 6.1/10
Severity
Medium
CVE
CVE-2026-44166 / [GHSA-pq7p-mc74-g65w](https://github.com/pocketbase/pocketbase/security/advisories/GHSA-pq7p-mc74-g65w)
Category
web
Affected product
[PocketBase](https://github.com/pocketbase/pocketbase) (Go backend / BaaS)
Affected versions
< 0.22.42 and >= 0.30.0, < 0.37.4
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / ResearcherAlardiians
CVE / AdvisoryCVE-2026-44166 / GHSA-pq7p-mc74-g65w
Categoryweb
SeverityMedium
CVSS Score6.1 (CVSS 4.0)
StatusPoC
Tagspocketbase, oauth2, account-takeover, account-pre-hijacking, cwe-287, improper-authentication, go
RelatedCVE-2024-38351 (incomplete prior fix for the same class of bug)

Affected Target

FieldValue
Software / SystemPocketBase (Go backend / BaaS)
Versions Affected< 0.22.42 and >= 0.30.0, < 0.37.4
Language / PlatformGo web backend, exploited over its REST API
Authentication RequiredNo admin auth needed — attacker only needs their own account on a configured OAuth2 provider
Network Access RequiredYes — HTTP access to the target’s auth-with-oauth2 endpoint

Summary

PocketBase’s auth-with-oauth2 endpoint accepts a client-supplied createData object when provisioning a new user record during OAuth2 sign-up, but never validates the email field inside it against the email address actually verified by the OAuth2 provider. An attacker who authenticates with their own account on any configured provider (e.g. GitHub, Google, a custom OIDC provider) can set createData.email to a victim’s email address, creating a PocketBase record that carries the victim’s identity but is linked to the attacker’s OAuth2 login. Depending on how many providers are configured, this either permanently locks the real owner out (single provider) or lets the attacker silently co-own the account indefinitely (two or more providers). This is a more complete bypass of the same account-pre-hijacking class that CVE-2024-38351 tried, and failed, to fully close.


Vulnerability Details

Root Cause

In apis/record_auth_with_oauth2.go, the oauth2Submit() new-record branch clones the client-supplied createData and only falls back to the OAuth2-provider-verified email if the client didn’t supply one:

1
2
3
4
payload := maps.Clone(e.CreateData)
if v, _ := payload[core.FieldNameEmail].(string); v == "" {
    payload[core.FieldNameEmail] = e.OAuth2User.Email
}

If the attacker supplies createData: {"email": "victim@company.com"}, that value wins. The new record is created unverified (since the record email doesn’t match the OAuth2-verified email) but is linked to the attacker’s {provider, providerId} via the _externalAuths table. The prior fix for CVE-2024-38351 only rotates the record’s password when a pre-registered record is later claimed — it never removes the attacker’s stale _externalAuths link, so the OAuth2 login path survives even when password auth is neutralized.

Attack Vector

  1. Attacker identifies a PocketBase deployment with an auth collection that has OAuth2 sign-up enabled and open registrations (the default setup for “Sign in with GitHub/Google”).
  2. Attacker obtains a valid OAuth2 code for their own account on one of the configured providers.
  3. Attacker calls POST /api/collections/<name>/auth-with-oauth2 with their own code but createData: {"email": "<victim>@<target-domain>"}.
  4. PocketBase creates a new record with the victim’s email, linked to the attacker’s {provider, providerId} in _externalAuths.
  5. Single provider: when the real victim later authenticates via the same provider, a unique-index collision on _externalAuths causes their request to fail with a generic 400 Failed to authenticate. — permanent lockout, no self-service recovery.
  6. Two or more providers: the victim authenticates through a different provider than the attacker used; their link installs cleanly and the record becomes verified, but the attacker’s link is never removed, so both parties retain independent working logins to the same record indefinitely.

Impact

Account takeover / persistent unauthorized access to a victim’s account without any interaction from the victim (lockout variant), or silent, durable co-ownership of the victim’s account (multi-provider variant). No admin credentials or victim interaction required — only a throwaway account on one of the app’s configured OAuth2 providers.


Environment / Lab Setup

The included lab is fully offline: fetch_binaries.py downloads the vulnerable (0.37.3)
and patched (0.37.4) official PocketBase binaries; mock_oauth2_server.py stands in for
a real IdP (issues tokens/userinfo for a fixed "attacker" and "victim" identity). No
real GitHub/Google OAuth app or network target is needed.

  python fetch_binaries.py     # pulls vulnerable 0.37.3 + patched 0.37.4 for your OS
  python poc.py                # pre-claim and lockout against the vulnerable build
  python verify_fix.py 0.37.4  # same steps on the patched build, attacker gets evicted

Proof of Concept

PoC Script

See poc.py, mock_oauth2_server.py, fetch_binaries.py, verify_fix.py, and WRITEUP.md in this folder.

1
2
3
python fetch_binaries.py
python poc.py
python verify_fix.py 0.37.4

Running poc.py against vulnerable 0.37.3 shows the attacker pre-claiming victim@company.com, the victim’s real login failing with HTTP 400 -- Failed to authenticate., and the attacker still able to re-authenticate into the same record. Running verify_fix.py against 0.37.4 shows the victim’s login succeeding and evicting the attacker’s stale link.


Detection & Indicators of Compromise

No distinctive default log entry is produced by this attack — detection must
happen at the data layer, not the log layer.

Signs of compromise:

  • Records in _externalAuths whose linked provider identity’s email does not match the owning record’s email field.
  • Unverified records with a populated, business-relevant (e.g. corporate-domain) email that were created via an OAuth2 sign-up flow.
  • A single record carrying multiple provider links that the application’s UX shouldn’t produce (e.g. both google and oidc links on one record).
  • Spikes in 400 Failed to authenticate. responses on OAuth2 callback endpoints, which can indicate victims hitting the lockout variant.

Remediation

ActionDetail
Primary fixUpgrade PocketBase to 0.37.4 (0.30.x line) or 0.22.42 (LTS line), which deletes any stale _externalAuths links on a record before linking the legitimate OAuth2 owner.
Interim mitigationIf upgrading isn’t immediately possible, patch/validate that createData.email (when supplied) must match OAuth2User.Email from the provider, rejecting the request otherwise.

References


Notes

Mirrored from https://github.com/Alardiians/pocketbase-CVE-2026-44166 on 2026-07-05.

fetch_binaries.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#!/usr/bin/env python3
"""Download the vulnerable (0.37.3) and patched (0.37.4) PocketBase binaries
for the current OS/arch into ./bin_<version>/. Cross-platform, stdlib only."""
import os, sys, platform, zipfile, urllib.request

VERSIONS = ["0.37.3", "0.37.4"]
HERE = os.path.dirname(os.path.abspath(__file__))

def asset_os():
    s = sys.platform
    if s.startswith("win"): return "windows"
    if s == "darwin":       return "darwin"
    return "linux"

def asset_arch():
    m = platform.machine().lower()
    if m in ("x86_64", "amd64"): return "amd64"
    if m in ("arm64", "aarch64"): return "arm64"
    return "amd64"

def main():
    o, a = asset_os(), asset_arch()
    for v in VERSIONS:
        name = f"pocketbase_{v}_{o}_{a}.zip"
        url = f"https://github.com/pocketbase/pocketbase/releases/download/v{v}/{name}"
        zip_path = os.path.join(HERE, f"pb_{v}.zip")
        out_dir = os.path.join(HERE, f"bin_{v}")
        print(f"[*] {v}: downloading {url}")
        urllib.request.urlretrieve(url, zip_path)
        with zipfile.ZipFile(zip_path) as z:
            z.extractall(out_dir)
        exe = os.path.join(out_dir, "pocketbase.exe" if o == "windows" else "pocketbase")
        if o != "windows":
            os.chmod(exe, 0o755)
        os.remove(zip_path)
        print(f"[+] {v}: ready at {exe}")

if __name__ == "__main__":
    main()