OS command injection happens when an application builds an operating system command from untrusted input and the shell, or the target program, interprets part of that input as instructions. It remains heavily exploited: in July 2024 CISA and the FBI issued a Secure by Design alert after edge-device command injection flaws in Cisco NX-OS, Palo Alto Networks PAN-OS and Ivanti Connect Secure were used in active campaigns. This guide explains how the flaw works, where it hides beyond web forms (CI/CD workflows, appliances, AI agent tools), how to detect it and how to remove the class from your code. It is for developers, AppSec engineers and detection teams, and contains no payloads. For the wider family of web attacks, see the Web Application Attacks Guide.
Key takeaways
- The fix is structural, not a filter. Call a library function instead of a program, or pass the program and each argument as separate list elements with no shell. Escaping and denylists are the weakest layers, not the main defence.
- Every command injection is also argument injection. Removing the shell stops metacharacters, but a value that starts with a dash can still become an option. Validate arguments against an allowlist and end option parsing where the program supports it.
- It is not only a web form problem. Branch names in CI workflows, filenames, hostnames in network appliance diagnostics and parameters chosen by AI agents all reach shells.
- Impact equals the privileges of the process. Injected commands run as the service account, read its secrets and tokens, and move from there. Least privilege and secret hygiene decide how bad a single flaw becomes.
- Detect at the process level. A web server or application runtime spawning a shell or a download utility is one of the highest-value detections you can build, and it catches flaws you have not found yet.
How the attack works
MITRE classifies the weakness as CWE-78, Improper Neutralization of Special Elements used in an OS Command, a Base weakness whose parent is CWE-77 (Command Injection) and which can also be CWE-88 (Argument Injection). The product builds all or part of a command from externally influenced input and fails to neutralise elements that change the meaning of that command when it reaches the downstream component. CWE-78 has appeared in every CWE Top 25 list from 2019 to 2025. See the CWE Top 25 Guide.
There are two distinct failure modes, and defences differ for each:
- Shell interpretation: the application hands a single string to a shell (
sh -c,cmd.exe /c,powershell.exe). The shell parses separators, substitutions, redirections and quoting rules, so data can become a second command. CAPEC records this as CAPEC-88 OS Command Injection, with CAPEC-15 Command Delimiters as a common variant. - Argument injection: no shell is involved, but the program itself treats a value as an option. A value that begins with a dash, or an option that writes files or loads configuration, can change behaviour with no metacharacters at all. CAPEC records this as CAPEC-6 Argument Injection. The OWASP OS Command Injection Defense Cheat Sheet states that every OS command injection is also an argument injection.
Blind command injection is the same flaw where output is not returned to the user. Success is inferred from timing or out-of-band network calls, so egress controls and DNS logging matter.
Why the language API matters
| Language | Risky pattern | Safer pattern | Source note |
|---|---|---|---|
| Python | subprocess with shell=True, os.system | subprocess.run([...]) with a list and no shell | The subprocess docs state the library never calls a shell implicitly; with shell=True quoting is the application's responsibility. On Windows, .bat and .cmd files may still be run by the system shell. |
| Node.js | child_process.exec(), execSync(), any call with shell: true | execFile() or spawn() with an argument array | The docs warn never to pass unsanitised input to exec(). Passing args with shell: true is deprecated (DEP0190) from v22.15.0 and v23.11.0. |
| Java | Runtime.exec(String), or ProcessBuilder wrapping sh -c | ProcessBuilder or Runtime.exec(String[]) with separate elements | OWASP notes Runtime.exec(String) is deprecated since Java 18 and splits on whitespace. |
| .NET | Building ProcessStartInfo.Arguments from input, UseShellExecute = true | Fully qualified path, UseShellExecute = false, ArgumentList | OWASP: never pass untrusted input to cmd.exe, powershell.exe or sh -c. |
| PHP | system(), exec(), shell_exec(), backticks with concatenated input | Avoid; if unavoidable, hardcode the command and wrap each value with escapeshellarg() | OWASP prefers escapeshellarg(); escapeshellcmd() still allows extra parameters. |
Where it shows up
- Network and edge appliances: diagnostic pages (ping, traceroute, DNS lookup), certificate and log utilities and management APIs often wrap shell scripts. The CISA and FBI alert of July 2024 named CVE-2024-20399 (Cisco NX-OS), CVE-2024-3400 (PAN-OS) and CVE-2024-21887 (Ivanti Connect Secure), all in CISA's Known Exploited Vulnerabilities catalog. In the local KEV snapshot used for this guide (catalog 2026.10.21), 112 entries are tagged CWE-78 and a further 33 CWE-77.
- Web applications: file conversion (images, PDFs, office documents), archive handling, backup and export jobs, and wrappers around tools such as
git,curlorffmpeg. LaTeX rendering is a special case: OWASP ASVS 5.0 requirement V1.2.8 asks that LaTeX processors run without--shell-escape. - CI/CD workflows: GitHub documents that values such as
github.event.pull_request.title,body,head_refandrefare attacker-controllable, and that${{ }}expressions are substituted into an inlinerunscript before the shell executes it. That is command injection in a pipeline, running with the workflow's token and secrets. See the GitHub and GitHub Actions Security Guide and the CI/CD Security Guide. - Email and protocol handlers: SMTP and IMAP command injection is a related class where CRLF sequences in user input inject protocol commands rather than OS commands. Use a library that encodes fields rather than concatenating strings.
- AI agents and tools: an agent with a shell tool, or an MCP server that wraps a CLI, turns model output into command arguments. Model output is untrusted input, and prompt injection can steer it. See the AI Agent Sandboxing and Code Execution Guide and the MCP Security Guide.
Impact
Injected commands run with the identity, file permissions and network reach of the vulnerable process. In practice that means:
- Secret theft: environment variables, configuration files, cloud instance metadata credentials, CI tokens and API keys readable by the service account (ATT&CK T1552.001 Credentials In Files).
- Persistence: web shells (T1505.003) and tools pulled in from outside (T1105 Ingress Tool Transfer).
- Lateral movement: appliance-held service accounts and directory bind credentials give a path into the internal network. In the Ivanti Connect Secure exploitation of 2024, an authentication bypass chained with the CVE-2024-21887 command injection exposed user passwords, service accounts, stored API keys and certificates, and CISA ordered agencies to reset them.
- Supply chain: in a pipeline, code execution can poison artefacts or publish releases.
Where identity fits: the blast radius is set by the non-human identity the process runs as. The same flaw is a breach under root with a long-lived cloud key, and a contained incident under a low-privilege identity with short-lived scoped credentials. See the Service Account Security Guide.
Detection
In code and pipelines
- SAST rules on sinks: flag
shell=True,os.system,exec(),Runtime.exec(String),system()and backticks, and any call that includessh -c,cmd /corpowershell -Command. CWE notes automated static analysis can miss custom wrappers, so pair it with manual review. - Workflow scanning: GitHub recommends CodeQL, which can detect vulnerable Actions workflows. Add a rule that blocks
${{ github.event.* }}insiderunsteps. - DAST and fuzzing: time-based and out-of-band tests find blind cases that response-diffing scanners miss. See the Web Application Security Testing Guide.
At runtime
| Signal | Where to collect it | Why it matters |
|---|---|---|
Web server or app runtime (w3wp.exe, java, node, python, php-fpm, httpd) spawning sh, bash, cmd.exe or powershell.exe | Sysmon Event ID 1 or Windows Security event 4688 with command line; Linux auditd execve or EDR process telemetry | Core signal for T1059 (T1059.004 Unix Shell, T1059.003 Windows Command Shell) launched from a public-facing application (T1190) |
| Service process starting download or recon tools (curl, wget, certutil, whoami, id, uname) | EDR and process creation logs | Common post-injection activity |
| New executable or script files in web roots | File integrity monitoring, EDR file events | Web shell drop |
| Unexpected outbound DNS or HTTP from application servers | DNS resolver logs, egress proxy, VPC flow logs | Blind injection confirmation and data exfiltration |
| CI job logs with unusual commands or network calls; releases without matching commits | CI audit logs, package provenance | Pipeline injection |
MITRE D3FEND names the countermeasure D3-PSA Process Spawn Analysis. Baseline the child processes each service legitimately starts and alert on anything else. See the Sysmon Guide and the Detection Engineering Guide.
Prevention and hardening
Defence layers in order of strength
| Layer | What it does | Limits |
|---|---|---|
| 1. Do not call the OS | Use a library (image, archive, DNS, mail, git libraries) instead of shelling out | Not always available; removes the class where it is |
| 2. No shell, argument array | Program path and each argument passed separately; the shell never parses input | Stops metacharacters, not argument injection |
| 3. Allowlist validation | Command from a fixed list; each value matched in full (for example Python fullmatch) against a strict pattern or mapped from an ID | Needs per-parameter rules; denylists are not a substitute |
| 4. Option hygiene | Place -- before operands where supported; reject values starting with a dash; for curl, one validated URL and --globoff | OWASP notes -- is not a complete fix on its own |
| 5. Escaping | escapeshellarg(), shlex.quote() where a shell is unavoidable | Platform-specific and fragile; last resort |
| 6. Contain the process | Dedicated low-privilege account, read-only file system, seccomp or AppArmor profiles, no outbound internet, short-lived scoped credentials | Limits impact, does not remove the flaw |
OWASP ASVS 5.0 requirement V1.2.5 captures layers 2 and 5: the application protects against OS command injection and operating system calls use parameterised OS queries or contextual command line output encoding. D3FEND countermeasures for layer 6 include D3-SCF System Call Filtering and D3-EAL Executable Allowlisting. See the Application Allowlisting Guide.
CI/CD specifics
- Intermediate environment variables: GitHub calls this the preferred approach. Map the untrusted context to an
env:variable and reference it, double-quoted, in the script, so the expression is never pasted into script text. - Prefer an action over inline script, so the value arrives as an argument.
- Restrict GITHUB_TOKEN to read-only by default and raise permissions per job.
- Avoid
pull_request_targetunless necessary, and never check out untrusted pull request code in privileged workflows.
Rollout plan
- Inventory sinks: run SAST and a grep across all repositories for process execution APIs and shell invocations, including CI workflow files and infrastructure scripts. Record owner and data source for each.
- Triage by exposure: fix first where input reaches the sink from unauthenticated or external sources, then authenticated users, then internal jobs.
- Refactor to layers 1 to 3, service by service, with unit tests asserting hostile values (metacharacters, leading dashes, long strings) are rejected or treated as literals.
- Turn on runtime detection in alert-only mode for shell spawns from each service, tune the baseline for two to four weeks, then raise severity. Seccomp or AppArmor profiles go to complain mode first, then enforce; rolling back is a profile change, not a code change.
- Add a CI gate that fails builds on new shell sinks without an approved exception, and on
${{ github.event.* }}inrunsteps. - Verify with testing: scope command injection into penetration tests. See the Penetration Testing Guide.
Decision guidance
- Small teams: layers 1 and 2, the CI rules, one SAST rule set and EDR process spawn alerts cover most risk cheaply.
- Large or product organisations: follow the CISA approach of treating CWE-78 as a class to eliminate: review past defects, fund safe wrapper libraries that only accept argument arrays, ban raw shell APIs by lint rule.
- Buying appliances: ask vendors how they have removed shell calls from management interfaces and whether they have signed the Secure by Design pledge; patch KEV-listed command injection flaws on edge devices first.
Measuring progress
- Number of shell-invoking sinks in code, trending to zero, with each remaining one documented.
- Percentage of services with a process spawn baseline and alerting enabled.
- Mean time to remediate KEV-listed CWE-78 and CWE-77 flaws on internet-facing devices.
- Percentage of CI workflows with read-only default token permissions and no untrusted expressions in
runsteps.
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| NIST SP 800-53 Rev. 5 | SI-10 Information Input Validation; SA-11 Developer Testing and Evaluation; AC-6 Least Privilege; CM-7 Least Functionality; SI-4 System Monitoring | Validation, testing, containment, detection |
| NIST CSF 2.0 | PR.PS-06 secure software development; DE.CM-09 runtime monitoring | Programme level |
| OWASP ASVS 5.0 | V1.2.5, V1.2.8 | Verification requirements |
| ISO/IEC 27001:2022 | Annex A 8.28 Secure coding | Policy anchor |
| MITRE ATT&CK | T1190, T1059, T1505.003, T1105 | Detection engineering |
| MITRE D3FEND | D3-PSA, D3-SCF, D3-EAL | Countermeasures |
Lessons from incidents
- Pipeline values are user input. In the Ultralytics PyPI compromise of December 2024, a branch name was inserted directly into a shell command in a
pull_request_targetworkflow, giving code execution with CI privileges; malicious releases followed. A near-identical injection had been reported and fixed in August 2024, then reintroduced about ten days later, which is why a CI gate beats a one-off fix. A leftover long-lived PyPI token made a second round of releases possible. - Edge devices hold the keys. The Ivanti case shows that after command injection on an appliance, every secret on it must be treated as exposed. Keep an inventory of appliance-held service accounts and keys so rotation takes hours, not weeks.
Practitioner checklist
- Inventory every process execution call and shell invocation across code, scripts and CI workflows, with an owner for each.
- Replace shell calls with libraries where possible; elsewhere pass an argument array with no shell.
- Validate each argument against a full-match allowlist or map it from a fixed ID; reject values starting with a dash.
- Ban
exec(),shell=True,os.systemandRuntime.exec(String)by lint rule, with documented exceptions only. - Move untrusted CI context values into environment variables, set GITHUB_TOKEN to read-only by default and review every
pull_request_targetworkflow. - Run services as dedicated low-privilege identities with short-lived scoped credentials and no outbound internet unless required.
- Alert on web and application runtimes spawning shells or download tools, using a per-service baseline.
- Patch KEV-listed command injection flaws on edge devices first and rotate appliance-held secrets after any compromise.
- Add command injection, including blind and argument injection cases, to test plans for every wrapper and appliance.
Standards and references
- MITRE CWE: CWE-78 Improper Neutralization of Special Elements used in an OS Command
- OWASP: OS Command Injection Defense Cheat Sheet
- CISA and FBI: Secure by Design Alert, Eliminating OS Command Injection Vulnerabilities
- OWASP: Application Security Verification Standard 5.0
- GitHub Docs: Secure use reference for GitHub Actions
- Python Software Foundation: subprocess, Security Considerations
- Node.js: Child process documentation
- MITRE ATT&CK: Command and Scripting Interpreter (T1059)
Related NHI Mgmt Group resources: SQL Injection Prevention Guide · Path Traversal Guide · Server-Side Template Injection (SSTI) Guide · Secure Coding Guide