Skip to content

# Command Injection Reproduction Notes for app.js - #50

Open
DongZifan wants to merge 1 commit into
im3x:v2-devfrom
DongZifan:v2-dev
Open

DongZifan wants to merge 1 commit into
im3x:v2-devfrom
DongZifan:v2-dev

Conversation

@DongZifan

Copy link
Copy Markdown

Summary

Hello,
I am writing to report a potential OS Command Injection vulnerability in the following file:
app.js

The /sync upload endpoint derives a file path from the uploaded file name and concatenates it into a command that is executed with child_process.execSync(...):

const FILE_NAME = _file['originalname'] + '.js'
const WIDGET_FILE = path.join(SCRIPTS_DIR, FILE_NAME)
...
let cmd = `code "${WIDGET_FILE}"`
if (os.platform() === "win32") {
  cmd = `cmd.exe /c ${cmd}`
} else if (os.platform() === "linux") {
  let shell = process.env["SHELL"]
  cmd = `${shell} -c ${cmd}`
} else {
  cmd = `"/Applications/Visual Studio Code.app/Contents/MacOS/Electron" "${WIDGET_FILE}"`
}
child_process.execSync(cmd)

The upload file name is attacker-controlled (the endpoint has no authentication and app.listen(HTTP_PORT) binds all interfaces). Although the path is wrapped in double quotes, POSIX shells still perform command substitution inside double quotes, so a file name such as x$(calc &) is executed as a separate command on the linux and darwin branches.

The same unvalidated name is also used to build a filesystem path, which allows path traversal in both /sync handlers (arbitrary file write on upload, arbitrary file read on download).

Root Cause

The issue is caused by unsafe shell command construction in the following handler:

app.post("/sync", (req, res) => {
  if (req.files.length !== 1) return res.send("no")
  const _file = req.files[0]
  const FILE_NAME = _file['originalname'] + '.js'
  const WIDGET_FILE = path.join(SCRIPTS_DIR, FILE_NAME)
  fs.renameSync(_file['path'], WIDGET_FILE)
  ...
  let cmd = `code "${WIDGET_FILE}"`
  if (os.platform() === "win32") {
    cmd = `cmd.exe /c ${cmd}`
  } else if (os.platform() === "linux") {
    let shell = process.env["SHELL"]
    cmd = `${shell} -c ${cmd}`
  } else {
    cmd = `"/Applications/Visual Studio Code.app/Contents/MacOS/Electron" "${WIDGET_FILE}"`
  }
  child_process.execSync(cmd)
})

_file['originalname'] comes straight from the multipart upload. The resulting path is interpolated into a shell command string, so shell metacharacters in the file name are interpreted as command syntax.

Note that double quotes do not protect against command substitution in POSIX shells: `...` and $(...) are still evaluated inside them. On Windows the same command line is safe, because cmd.exe performs no command substitution and " is not a legal character in a Windows file name.

The task ID is also used for a filesystem path without confinement:

const { name } = req.query
const WIDGET_FILE = path.join(SCRIPTS_DIR, name + '.js')
if (!fs.existsSync(WIDGET_FILE)) return res.send("nofile").end()

Reproduction Material

A minimal reproduction script is provided in: poc_app.js
poc_app.js

This Proof of Concept (PoC) is intended to demonstrate that external input can reach dangerous command execution logic through the vulnerable code path. For observability, the PoC hooks child_process.execSync() to log the intercepted command before it is executed.

What the PoC Does

The PoC performs a minimal end-to-end trigger of the vulnerable code path:

  1. It stubs express, multer and body-parser to capture the /sync route handler without starting a server.
  2. It hooks child_process.execSync to log the command produced by the application.
  3. It loads the vulnerable file app.js.
  4. It invokes the captured handler with an uploaded file whose name is:
    • x$(calc &)
  5. WIDGET_FILE becomes <scripts>/x$(calc &).js and the linux branch builds sh -c code "<scripts>/x$(calc &).js".
  6. The shell performs the command substitution and runs calc before executing the rest of the command.

Because the injection only expands on the POSIX branches while the PoC is executed on Windows, the PoC selects the linux branch and hands the exact command string produced by the application to a real POSIX shell (Git Bash). The command execution itself is not simulated.

In the provided example, the injected payload is crafted so that successful command execution launches the Calculator application. This serves as a visible indicator that external input can reach the OS command execution sink without proper sanitization.

Example Payload

Uploaded file name:

x$(calc &)

which produces the shell command:

sh -c code "<scripts>/x$(calc &).js"

On a Windows or POSIX host the same handler also allows path traversal, for example the uploaded name ..\..\evil (Windows) or ../../evil.

How to Run

Run from the folder containing the files:

node poc_app.js

On Windows, Git Bash must be installed (the PoC uses it as the POSIX shell).

Expected Output

When the PoC runs, you should see output similar to:

[POC] POST /sync  originalname = x$(calc &)
[+] Scriptalbe App 已连接
[*] 小组件源码(x$(calc &))已同步,请打开编辑
[POC] execSync: sh -c code "D:\shixi\task3\309\Scripts\x$(calc &).js"
[POC] >>> injected command reached the shell
sh: code: not found
[POC] done

In the vulnerable version, when the crafted request is processed, the shell interprets $(...) and executes the injected calc command, launching the Calculator application as a benign demonstration effect.
This shows that an attacker-controlled upload file name can influence command execution behavior through the vulnerable /sync handler.

Patch Explanation

This branch also includes a patched version of app.js (new_app.js) intended to mitigate the command injection risk described above.

What the patch changes

  1. The request-controlled value is reduced to a bare file name before it is used:
    • path.basename(_file['originalname']) in POST /sync;
    • path.basename(String(name || '')) in GET /sync.
  2. The POSIX branches wrap the path in single quotes with proper escaping:
function shellQuote(value) {
  return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
...
} else if (os.platform() === "linux") {
  let shell = process.env["SHELL"]
  cmd = `${shell} -c code ${shellQuote(WIDGET_FILE)}`
} else {
  cmd = `"/Applications/Visual Studio Code.app/Contents/MacOS/Electron" ${shellQuote(WIDGET_FILE)}`
}

What this means for the rest of the flow

  • path.basename is the identity for ordinary widget names, so normal uploads and downloads behave exactly as before.
  • The linux and darwin branches still run code <path> through the same shell with the same argument; only quoting changes, so the editor still opens the same file.
  • The win32 branch is left untouched: " is not a valid character in a Windows file name and cmd.exe has no command substitution, so the doubled-quoted path cannot be broken out of.

Validation introduced by the patch

Rather than filtering out "dangerous" characters (a block-list, which is inherently incomplete), the patch makes the shell treat the path as data:

  • single quotes in POSIX shells suppress $(), backticks, ;, &, | and every other metacharacter;
  • embedded single quotes are escaped with '\'', so any legal file name round-trips correctly;
  • path.basename guarantees the value cannot address a directory outside Scripts/.

With the patch, the file name x$(calc &) produces sh -c code '<scripts>/x$(calc &).js', which the shell treats as a literal path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant