October 19, 2026
Command Injection: When the Shell Runs Your Input
From ;id to JVM diagnostics, every way an OS command sneaks in.

By Raksharoot
9 min read
This is Part 9 of the Web Pentesting series by Raksharoot. Missed the earlier parts? Catch up before this one. The full list is here: Web Pentesting: A Complete Guide.
Disclaimer: Educational content only. Only test systems you own or have written permission to test. Use legal labs and disposable VMs. Never test command injection on production systems without explicit authorization.
Command injection is the bug that ends the engagement.
Not because it's clever. Because it's total. The moment your input hits a shell, you're not a pentester anymore, you're the server. Read files, write files, pivot to the internal network, install a rootkit, exfiltrate the database. Game over.
And yet, it keeps showing up. In 2026. In modern apps. In Synology Photos, in PaperCut, in NetScaler, in Cisco Smart Software Manager.
So let's talk about how it actually works, and why "we escape the input" is not the defense people think it is.
The core idea
A command injection happens when user input ends up in a string that gets passed to a shell โ sh , bash , cmd.exe , PowerShell, whatever.
The shell parses that string the same way it parses anything else. Semicolons separate commands. Pipes chain output. Backticks and $() run subshells. Ampersands fork. Newlines start a new line.
So if your input is 127.0.0.1 and the app runs ping -c 1 127.0.0.1, you type 127.0.0.1; id and the shell runs both.
That's it. That's the whole bug.
The complexity comes from all the ways apps try to defend against this and fail.
Breaking out of context
Before you inject a command, you need to get out of whatever quote or context your input is in.
If the app does:
_exec("ping -c 1 " . $GET['ip']);
You're unquoted. ; id works.
If it does:
_exec("ping -c 1 '" . $GET['ip'] . "'");
You're single-quoted. You need to close the quote first: ' ; id ; ' .
If it does:
_exec('ping -c 1 "' . $GET['ip'] . '"');
You need " ; id ; " .
Always look at the surrounding syntax. If you see ' or " in the error message or response, you know the context you need to break.
The separator cheat sheet
These work on both Unix and Windows:
Unix only:
Two more that don't execute but matter:
The newline ( %0A ) is my default. It works on both platforms, survives most naive filters, and doesn't require closing any specific quote. If a filter blocks ; and | and & , they usually forget %0A .
Parameters worth testing
If you see any of these in a URL, request body, or JSON, you're probably looking at a command execution sink:
Also look at anything that takes an IP, a hostname, a filename, a password, a domain, a URL. Those are the inputs that get passed to shell commands.
The PHP runkit rule engine
Some apps implement admin "rule engines" that execute PHP. If runkit is enabled, you can redefine functions at runtime.
If rules are stored and re-evaluated later, this becomes persistent RCE. Look for runkit or runkit7 in phpinfo() or check extension_loaded('runkit') .
Numeric-prefix validation is not a defense
Here's a subtle one from Infoblox NetMRI.
The saml_id parameter only had to start with an integer. The rest of the string was passed to Ruby's IO.popen without further validation.
Payload:
The 1 satisfies the numeric check. The %26 (encoded & ) and $(whoami) execute.
The lesson: validating the first character of a field is not validating the field. If a numeric-looking input reaches a shell, it's a shell injection, not a numeric field.
Bash arithmetic evaluation in RewriteMap scripts
This one is genuinely clever.
RewriteMap helpers written in bash sometimes push query params into globals and compare them in arithmetic contexts: [[ $a -gt $b ]] , $((โฆ)) , let .
Arithmetic expansion re-tokenizes the content. Variable names and array references get expanded twice.
The Ivanti EPMM pattern: st maps to gStartTime, h maps to theValue . Later:
Send st=theValue so gStartTime becomes the literal string theValue. Then h=gPath['sleep 5'] so theValue contains an array index.
During the arithmetic check, the shell evaluates sleep 5.
Probe (~5 second delay, then a 404):
If you see the delay, you've got code execution, no shell metacharacters required. Just variable names in an arithmetic context.
Node.js: exec vs execFile
If you're auditing a JavaScript or TypeScript backend, you will see child_process. The vulnerable pattern is always the same:
exec() spawns a shell. Backticks, ; , && , | , $() all get parsed as shell syntax.
The fix is boring:
execFile() doesn't spawn a shell. Arguments go straight to execve as an array. No metacharacters get parsed.
A real-world example: Synology Photos โค 1.7.0โ0794 was RCE through an unauthenticated WebSocket event that put attacker data into id_user, which was later interpolated into exec(). Pwn2Own Ireland 2024.
Second-order: log parsing to shell
Not all command injection is direct.
Sometimes the attacker's request is just a log-poisoning step. The exploit fires later, when a privileged job parses that log and puts the extracted text into a shell command.
The NetScaler example:
- Unauthenticated attacker writes a line to a log:
- A Perl monitoring script runs grep | tail | sed | awk over the log and builds
- That value gets interpolated into a separate backtick find :
- The ; ends find . The injected id>/tmp/proof runs. The trailing malformed command fails harmlessly.
The pipeline treated the metacharacters as data. The shell sink gave them meaning.
When testing, trace the full path from the unauthenticated log writer to the scheduled reader to the shell sink. An immediate HTTP 200 is not proof. Wait for the scheduled job. Check for the output file.
This is a class of bug that scanners miss entirely, because the exploit is not in the request. It's in the infrastructure.
PEM explanatory text in certificate upload
RFC 7468 allows explanatory text before a PEM BEGIN line. Some parsers accept it; some don't.
Cisco Smart Software Manager had a chain where Ruby's OpenSSL::X509::Certificate.new accepted a $(โฆ) preamble in an intermediate certificate, and a Node.js configurator later ran:
Double quotes don't prevent command substitution. The $(โฆ) executes before OpenSSL sees the bundle.
The constraint: only the intermediate cert retained the raw preamble. The leaf was reserialized with to_pem, which stripped it. So the payload had to go in the intermediate.
The lesson: parsing boundaries are where bugs live. The X.509 parser accepted a PEM object. The later shell command used the original raw text. Two parsers, two interpretations, one execution.
Argument injection: when there's no shell
Not every injection requires shell metacharacters.
If an app passes untrusted strings as arguments to a system utility, even via execve or execFile , with no shell, programs still parse any argument starting with - or - - as an option.
Common places:
- Embedded web UIs and CGI handlers that build ping , tcpdump -i , curl .
- Centralized CGI routers where multiple handlers reuse the same weak validator
- Admin wrappers that pass filenames or options into privileged utilities
If the program supports - - (end-of-options), naive mitigations that prepend - - in the wrong place can be bypassed. Test - - placement carefully.
JVM diagnostic callbacks: guaranteed execution
This is my favorite trick for turning an IPC bug into RCE.
Any primitive that lets you inject JVM command-line arguments โ _JAVA_OPTIONS , launcher config files, AdditionalJavaArguments fields in desktop agents, can become reliable code execution without touching application bytecode.
Force a deterministic crash by shrinking metaspace:
-XX:MaxMetaspaceSize=16m
Attach an error hook:
The JVM parses these itself. No shell metacharacters required. The command runs with the same integrity level as the launcher.
Any desktop IPC bug that forwards user-supplied JVM flags, like a local WebSocket handler that doesn't validate Origin, becomes OS command execution.
Relative path traversal in executable selection
Sometimes the injection is in the program name, not the arguments.
PaperCut's Scan to Fax job builds an executable path from providerConnector : getAbsoluteBinaryPath(providerConnector)
It accepts the path if Files.isExecutable succeeds, retries with .exe on Windows, then passes the path and a separate argument array to ProcessUtils.execAndGetOutput.
Neither the executable check nor the .exe fallback verifies the path stays inside the connector directory.
Payload:
That resolves to cmd.exe outside the connector directory. Combine with system.scan.fax.send.command-args set to /c whoami and the service runs cmd.exe /c whoami.
This is executable-selection injection, not shell injection. The arguments go through an array. Metacharacters don't matter. The attacker-controlled program is the payload.
PaperCut's other RCE: SetupCompleted auth bypass
A separate PaperCut chain: /app?service=page/SetupCompleted returns a valid JSESSIONID without credentials. From there:
- Enable printer scripting: print-and-device.script.enabled=Y
- Disable the sandbox: print.script.sandboxed=N
- In the printer Scripting tab, add a script that executes immediately:
- Click Apply. The code runs.
No print job needed. The payload executes during form submission because it's placed outside the printJobHook function.
Horizon3's CVE-2023โ27350.py automates the whole chain.
Exfiltration: getting the data out
You have command execution. Now you need the output.
1. Time-based
Character by character, using shell conditionals:
Slow, but it works when there's no other channel. Automate it.
2. DNS-based
If outbound DNS is allowed, use a service like dnsbin.zhack.ca or pingb.in.
Every subdomain query is a piece of data. Watch the DNS logs.
3. HTTP-based
If outbound HTTP is allowed:
Fastest when it works, which is often.
Filtering bypasses
Some filters are dumb. Some are less dumb. All of them can usually be defeated.
Windows wildcards:
? matches one character. * matches any. ** searches recursively. Combining them lets you spell notepad without ever typing "notepad".
Linux:
There's a whole world of bypasses. Environment variables, IFS , wildcards, base64 encoding, rev , xxd , ${LS_COLORS:10:1} to get a space. The dedicated Linux restriction-bypass guide is worth reading.
The golden rule:
If a filter blocks ; , | , & , and $() , try %0A (newline). If it blocks %0A , try tabs. If it blocks tabs, try IFS. If it blocks everything, try argument injection.
The checklist
Not exhaustive, but this is the order I go in:
- Identify every parameter that reaches a shell.
- Look for IPs, hostnames, filenames, URLs, passwords.
- Test ; id in every input.
- Test %0A id if ; is blocked.
- Test quote-break payloads: '; id; ' and "; id; " .
- Test $() and backticks.
- Test IFS for space bypass.
- Test numeric-prefix fields with 1%26$(whoami) .
- Test arithmetic contexts with variable-name injection.
- Test exec() vs execFile() in Node backends.
- Test argument injection with - prefixed inputs.
- Test JVM flag injection where desktop agents are involved.
- Test executable-selection via path traversal in service config.
- Check logs for second-order sinks.
- Check certificate uploads for PEM preamble.
- Use time-based or DNS-based exfil if output is blind.
The thing to remember
Command injection isn't about shell metacharacters. It's about trust boundaries that were never enforced.
The bug isn't ; . The bug is that user input reached a shell, and the shell did what shells do.
The defenses that actually work:
- Don't use a shell. Use execFile , Process.exec , arrays, anything that passes arguments directly to execve.
- Validate inputs against grammar, not substring blocklists.
- If you must use a shell, quote and escape properly, and then hire someone to audit the escaping.
- Constrain executable selection to a fixed allowlist.
- Never pass user data to echo in a shell. Use printf or a file write.
Everything else is theater.
And when you're hunting, remember: not every command injection is a ; in a URL. It could be a JVM flag, a path in a config file, a log line, a PEM preamble, or the name of the executable itself.
The shell is wherever the OS reads a string and interprets it as an instruction.
Test in legal labs. Never on production. And if you find one, report it fast, because command injection is RCE, and RCE is the end of the world.
That's Part-9. Read the whole series in order: Web Pentesting: A Complete Guide