React/Next.js Detection Notes
The following notes summarize the behavior of React / Next.js when spawning child processes, particularly in development and production modes with a focus on how the Node.js `child…
React/Next.js Detection Notes
The following notes summarize the behavior of React / Next.js when spawning child processes, particularly in development and production modes with a focus on how the Node.js child_process functions would look like from a process tree perspective, and was inspired by the React2Shell vulnerability.
Shell Defaults in Node.js child_process
Hardcoded Shell Values
if (process.platform === 'win32') {
if (typeof options.shell === 'string')
file = options.shell;
else
file = process.env.comspec || 'cmd.exe';
// '/d /s /c' is used only for cmd.exe.
if (RegExpPrototypeExec(/^(?:.*\\)?cmd(?:\.exe)?$/i, file) !== null) {
args = ['/d', '/s', '/c', `"${command}"`];
windowsVerbatimArguments = true;
} else {
args = ['-c', command];
....else {
if (typeof options.shell === 'string')
file = options.shell;
else if (process.platform === 'android')
file = '/system/bin/sh';
else
file = '/bin/sh';
args = ['-c', command];
}The docs also points some of this information out in the Shell Requirements and Default Windows shell sections.
Key Points:
- Windows:
process.env.comspec(usuallyC:\Windows\System32\cmd.exe) or fallback tocmd.exe - cmd.exe gets special args:
/d /s /c "<command>" - Linux/macOS:
/bin/sh(usually symlinked to bash/dash) - Custom shells can override via the
shelloption
Next.js Child Process Spawning Patterns
Development Mode (npm run dev)
Process Chain:
cmd.exe/bash
└─> node.exe npm-cli.js run dev
└─> cmd.exe /d /s /c next dev
└─> node <app_name>\node_modules\dist\bin\next dev
└─> node.exe <app_name>\node_modules\next\dist\server\lib\start-server.js
└─> cmd.exe /d /s /c "<command>"
└─> "<binary>"Key Code Path (from next-dev.js):
const startServerPath = require.resolve('../server/lib/start-server');
...
...
...
child = (0, _child_process.fork)(startServerPath, {
stdio: 'inherit',
env: {
...defaultEnv,
...bundler === _bundler.Bundler.Turbopack ? {
TURBOPACK: process.env.TURBOPACK
} : undefined,
NEXT_PRIVATE_WORKER: '1',
NEXT_PRIVATE_TRACE_ID: _shared.traceId,
NODE_EXTRA_CA_CERTS: startServerOptions.selfSignedCertificate ? startServerOptions.selfSignedCertificate.rootCA : defaultEnv.NODE_EXTRA_CA_CERTS,
NODE_OPTIONS: (0, _utils.formatNodeOptions)(nodeOptions),
// There is a node.js bug on MacOS which causes closing file watchers to be really slow.
// This limits the number of watchers to mitigate the issue.
// https://github.com/nodejs/node/issues/29949
WATCHPACK_WATCHER_LIMIT: _os.default.platform() === 'darwin' ? '20' : undefined
}
});Detection Indicators:
If we are tracing child processes spawned by the child_process module in dev mode, we can look for:
- Child processes from
nodeparent where the parent command-line containsstart-server.js. - By default if an
Asynchronousfunction such aschild_process.spawnorchild_process.execis called, commands passed to it will be executed via the default system shell (cmd.exe on Windows, /bin/sh on Linux/macOS). If aSynchronousfunction is used, then the binary passed is executed directly without a shell. (check out the following notes for examples logs on this behavior)
If you have access to environment variables, you can also look for the following variable in child processes:
- Env var:
NODE_ENV=development
Production Mode (npm start)
Process Chain:
cmd.exe/bash
└─> node.exe npm-cli.js start
└─> cmd.exe /d /s /c next start
└─> node <app_name>\node_modules\dist\bin\next dev
└─> cmd.exe /d /s /c "<command>"
└─> "<binary>"Key Code Path (from next-start.js):
const _startserver = require("../server/lib/start-server");
...
...
...
/**
* Start the Next.js server
*
* @param options The options for the start command
* @param directory The directory to start the server in
*/ const nextStart = async (options, directory)=>{
const dir = (0, _getprojectdir.getProjectDir)(directory);
const hostname = options.hostname;
const port = options.port;
const keepAliveTimeout = options.keepAliveTimeout;
if ((0, _getreservedport.isPortIsReserved)(port)) {
(0, _utils.printAndExit)((0, _getreservedport.getReservedPortExplanation)(port), 1);
}
await (0, _startserver.startServer)({
dir,
isDev: false,
hostname,
port,
keepAliveTimeout
});
};Detection Indicators:
- Look for child processes from
nodeparent where the parent command-line contains a variation ofnext start. - By default if an
Asynchronousfunction such aschild_process.spawnorchild_process.execis called, commands passed to it will be executed via the default system shell (cmd.exe on Windows, /bin/sh on Linux/macOS). If aSynchronousfunction is used, then the binary passed is executed directly without a shell.
If you have access to environment variables, you can also look for the following variable in child processes:
- Env var:
NODE_ENV=production
Other Notes
- I've seen cases where the
nextscript is called with the CommandLine:<app>\node_modules\.bin\\..\next\dist\bin\next. - When looking for suspicious command-lines from child-processes, avoid to match on the default shells
cmdorsh(bash/dash) alone as those are as the name suggests "default" and will be used by node itself. Instead try to look for variants that are different. For examplecmd.exewith/d /s /corcmd /d /s /cwith a suspicious looking string or command.
False Positive Scenarios
1. Port Detection via netstat/lsof
When starting the Next.js server, it checks if the desired port is already in use. This is done by spawning system commands like netstat on Windows or lsof on Linux/macOS.
Code from start-server.js:
try {
// Use lsof on Unix-like systems (macOS, Linux)
if (process.platform !== 'win32') {
(0, _child_process.exec)(`lsof -ti:${port} -sTCP:LISTEN`, {
signal: processLookupController.signal
}, (error, stdout)=>{
if (error) {
handleError(error);
return;
}
// `-sTCP` will ensure there's only one port, clean up output
const pid = stdout.trim();
resolve(pid || null);
});
} else {
// Use netstat on Windows
(0, _child_process.exec)(`netstat -ano | findstr /C:":${port} " | findstr LISTENING`, {
signal: processLookupController.signal
}, (error, stdout)=>{
if (error) {
handleError(error);
return;
}
// Clean up output and extract PID
const cleanOutput = stdout.replace(/\s+/g, ' ').trim();
if (cleanOutput) {
const lines = cleanOutput.split('\n');
const firstLine = lines[0].trim();
if (firstLine) {
const parts = firstLine.split(' ');
const pid = parts[parts.length - 1];
resolve(pid || null);
} else {
resolve(null);
}
} else {
resolve(null);
}
});
}
....
....
....This would look like this in a process tree:
- Windows:
cmd.exe /d /s /c "netstat -ano | findstr /C:":3000 " | findstr LISTENING" - Linux:
/bin/sh -c 'lsof -ti:3000 -sTCP:LISTEN'
2. HTTPS Certificate Generation (mkcert)
Code from next-dev.js:
const runDevServer = async (reboot)=>{
try {
if (!!options.experimentalHttps) {
_log.warn('Self-signed certificates are currently an experimental feature, use with caution.');
let certificate;
const key = options.experimentalHttpsKey;
const cert = options.experimentalHttpsCert;
const rootCA = options.experimentalHttpsCa;
if (key && cert) {
certificate = {
key: _path.default.resolve(key),
cert: _path.default.resolve(cert),
rootCA: rootCA ? _path.default.resolve(rootCA) : undefined
};
} else {
certificate = await (0, _mkcert.createSelfSignedCertificate)(host);
}
await startServer({
...devServerOptions,
selfSignedCertificate: certificate
});
} else {
await startServer(devServerOptions);
}
await preflight(reboot);
} catch (err) {
console.error(err);
process.exit(1);
}
};
await runDevServer(false);
};In practice the createSelfSignedCertificate will spawn a binary to generate the certs:
- Spawns
mkcert.exeon Windows ormkcerton Linux - The commands will look something like this on windows
"C:\Users\Administrator\AppData\Local\mkcert\mkcert-v1.4.4-windows-amd64.exe" -CAROOT"C:\Users\Administrator\AppData\Local\mkcert\mkcert-v1.4.4-windows-amd64.exe" -install -key-file "C:\Users\Administrator\react123\certificates\localhost-key.pem" -cert-file "C:\Users\Administrator\react123\certificates\localhost.pem" localhost 127.0.0.1 ::1
This should only occur when the --experimental-https flag and its variants is used.