Detection Engineering

Command Injection Logs: What to Watch

Command injection detection from logs — the telemetry that exposes OS command injection, Sigma and Suricata rules, a CVE-2024-3400 case study, and hardening.

A dark Linux terminal showing a shell command log with one injected command line flagged in red

Command injection detection comes down to one unmistakable signal: a web or application service process spawning a shell. Web servers serve pages; they do not run bash, sh, cmd.exe, or powershell. When nginx, java, or php-fpm launches a shell interpreter, that is command injection in progress. The web-tier signatures catch the noisy attempts; the process-creation signal catches the one that worked. This guide ships both.

OS command injection turns a web request into code execution with the privileges of the service. It maps to A03:2021 — Injection and remains a favorite of both nation-state and opportunistic attackers. The 2024 PAN-OS flaw CVE-2024-3400 — a CVSS 10.0 command injection in GlobalProtect, exploited in the wild as Operation MidnightEclipse — is the worked example here.

What is OS command injection?

OS command injection is an attack where untrusted input is passed into a shell the application invokes, so the attacker’s text becomes additional operating-system commands. The app meant to run ping <host>; the attacker supplies 8.8.8.8; cat /etc/passwd and the shell happily runs both. Unlike SQL injection, which stays in the database, command injection lands the attacker a shell on the host.

The payoff is code execution as the service account, which is why it maps to T1190 — Exploit Public-Facing Application for entry and T1059 — Command and Scripting Interpreter for the shell it spawns.

What are the types of command injection?

Three variants matter for detection, and each surfaces in a different place.

VariantWhat the attacker seesTelemetry fingerprintBest detection layer
In-band (results returned)Command output in the HTTP responseShell metacharacters in params + odd responseWeb logs
BlindNo output; infers successTime delays, or out-of-band DNS/HTTP callbacksEgress + process telemetry
Out-of-bandForces a callback to attacker hostDNS/HTTP to a freshly seen domain from the web hostDNS + egress logs

The lesson: web parameters catch in-band attempts, but blind and out-of-band injection only show up in process-creation and egress telemetry. That is why the shell-spawn signal is the backbone of command injection detection.

How to detect command injection

Layer it from the web request to the shell it spawns.

The backbone: a service process spawning a shell

This is the highest-fidelity rule you can deploy, and it catches every variant regardless of how the payload arrived — the same process-creation logic behind SQL injection detection’s database-shell rule.

Sigma Web Service Process Spawning a Shell Interpreter
title: Web Service Process Spawning a Shell Interpreter
id: 3a7c2e91-darkpwn-illustrative
status: experimental
logsource:
  category: process_creation
detection:
  parent:
    ParentImage|endswith: ['\nginx.exe','\httpd','\apache2','\java.exe','\php-fpm','\w3wp.exe']
  child:
    Image|endswith: ['\bash','\sh','\cmd.exe','\powershell.exe','\python','\perl']
  condition: parent and child
falsepositives:
  - CGI apps that legitimately shell out (allowlist the specific parent/child pair)
level: high

The same backbone rule on Splunk and Sentinel

SPL Web Service Spawning a Shell, Chained to Child Behaviour
index=* (sourcetype=sysmon EventCode=1) OR (sourcetype=linux_audit type=EXECVE)
| eval parent=lower(ParentImage), child=lower(Image)
| where match(parent, "(nginx|httpd|apache2|java|php-fpm|w3wp|node|puma|gunicorn)")
    AND match(child, "(\\\\|/)(bash|sh|dash|zsh|cmd\.exe|powershell\.exe|python[0-9.]*|perl)$")
| eval risk=case(
    match(CommandLine, "(?i)(curl|wget|nc |ncat|base64\s+-d|/dev/tcp)"), "critical",
    true(), "high")
| stats values(CommandLine) as cmds, min(_time) as first, count by Computer, parent, child, risk
| where count > 0
KQL Service Account Shell Spawn With Follow-On Egress
let webParents = dynamic(["nginx","httpd","apache2","java","php-fpm","w3wp","node"]);
let shells    = dynamic(["bash","sh","dash","cmd.exe","powershell.exe","python","perl"]);
DeviceProcessEvents
| where InitiatingProcessFileName has_any (webParents)
| where FileName has_any (shells)
| extend Risk = iff(ProcessCommandLine has_any ("curl","wget","base64 -d","/dev/tcp","ncat"),
                    "Critical", "High")
| project Timestamp, DeviceName, InitiatingProcessFileName, FileName,
          ProcessCommandLine, AccountName, Risk
| order by Timestamp desc

Both encode the same escalation logic: the shell spawn is the alert, and what the shell reaches for immediately afterwards decides the severity.

Web tier: shell metacharacters in parameters

A triage signal for the in-band variant — pair it with rate thresholds so a single scanner request does not page anyone.

Sigma Suspicious Command Tokens in Web Request Parameters
title: Suspicious Command Tokens in Web Request Parameters
id: 5b9d1f64-darkpwn-illustrative
status: experimental
logsource:
  category: webserver
detection:
  selection:
    cs-uri-query|contains: [';cat ',';id',';whoami','|nc ','$(', '`id`','&&curl','%3Bcat']
  condition: selection
falsepositives:
  - Scanners and DAST runs; allowlist their source IPs
level: medium

For perimeter IDS, a Suricata rule flags shell metacharacters in request bodies (reserve SID 1000004+):

Suricata Shell Metacharacters in HTTP Request
alert http any any -> $HTTP_SERVERS any (
    msg:"DARKPWN Shell metacharacters in HTTP request (possible command injection)";
    flow:established,to_server; http.uri;
    pcre:"/(\x3b|\x7c|%3b|%7c|\x60|\x24\x28)\s*(id|whoami|cat|curl|wget|nc)\b/i";
    classtype:web-application-attack; sid:1000004; rev:1;)

How do you detect command injection in containers?

Most command-injection guidance implicitly assumes a long-lived server with an endpoint agent on it. Modern web tiers frequently are not that, and the detection has to move accordingly — but containers also make this detection easier, not harder, if you set them up for it.

Where the telemetry comes from. There is no Sysmon on Linux, so process-creation events come from one of three places: auditd with an execve rule, an eBPF-based sensor (Falco, Tetragon, or your EDR’s Linux agent), or the container runtime’s own audit trail. eBPF is the usual answer at scale because auditd under high process churn is expensive and drops events under load — which is exactly when you need them.

Why containers improve the signal. The parent/child model gets much cleaner. A container image has a known entrypoint and a small, enumerable set of processes it should ever run. In a conventional server the baseline is muddy — cron, admin sessions, agents, maintenance scripts all spawn shells legitimately. In a container running one application, any shell execution is anomalous by construction.

Practical container detection rules, in order of value:

  1. Shell execution inside a container that has no interactive workload. Highest fidelity. Allowlist your healthcheck and init commands explicitly by full argument vector.
  2. kubectl exec into a production pod. Legitimate but should be rare, ticketed, and attributable. Alert on every one and reconcile against change records.
  3. Any process whose executable path is outside the image layers — a binary written at runtime is a dropped payload, and in an immutable-filesystem container it should be impossible.
  4. Outbound connections from a pod to a destination outside its service mesh policy. This is the blind and out-of-band variant surfacing, and it works even when process telemetry is missing.

Run the container filesystem read-only where you can. It converts step 3 from a detection into a prevention, and the failed write attempt is itself a loggable event.

What telemetry do you need to detect command injection?

PlatformProcess creation sourceEgress sourceGotcha
Windows serverSysmon EID 1 or Security 4688Sysmon EID 3, firewall4688 lacks parent command line
Linux VMauditd execve, or eBPF sensorConntrack, firewall, DNS logsauditd drops events under load
Container / KuberneteseBPF (Falco/Tetragon), runtime auditCNI flow logs, service meshNode agent must see all namespaces
Managed PaaSPlatform application logs onlyPlatform egress logsOften no process telemetry at all
Appliance (VPN, firewall)Vendor logs onlyVendor logs, upstream flowFrequently unparsed and unforwarded

The bottom two rows are where most organizations are genuinely blind. On managed PaaS you often cannot get process telemetry at all, which makes the web-tier and egress signals your only coverage — plan the detection around that constraint rather than pretending the backbone rule applies. For appliances, the fix is usually just forwarding logs nobody ever ingested, which is cheap work with an outsized payoff given CVE-2024-3400.

Which false positives will you actually see?

False positiveWhich rule it hitsWhy it happensResolution
CGI and legacy appsShell spawnThey legitimately shell out per requestAllowlist the exact parent/child pair, not the parent
Healthcheck scriptsShell spawn (container)Probe runs a shell commandAllowlist the full argument vector
Deployment and init containersShell spawn (container)Setup genuinely runs shellsScope the rule to the app container only
DAST and vulnerability scannersWeb metacharactersThey send injection payloads deliberatelyAllowlist scanner source IPs, keep the rule enabled
Log-processing pipelinesWeb metacharactersSomeone’s log line contains ;catMatch on request parameters, not arbitrary body text
Legitimate kubectl execContainer execEngineers debug productionAlert anyway; reconcile against change tickets
Backup and cron jobsShell spawnScheduled maintenance spawns shellsTime-plus-account scoped filter, reviewed quarterly
Monitoring agentsShell spawnSome agents shell out to collect metricsAllowlist by signed binary path

Allowlisting the parent alone is the mistake that hollows this rule out. Excluding java because one legacy application shells out disables the backbone detection for every Java service you run. Allowlist the specific parent and child and, where you can, the command shape — so the exception covers the known-good behaviour rather than the whole process class.

How do you triage a command injection alert?

  1. Capture the full command line and the process tree before anything else. Containers get rescheduled and short-lived processes vanish; if you isolate first you may lose the evidence entirely. This is the one detection class where a brief capture step precedes containment.
  2. Identify the entry request. Correlate the process timestamp against web access logs for that host or pod within a few seconds. This gives you the vulnerable endpoint and parameter.
  3. Read what the shell did next. Child processes and outbound connections separate a blocked probe from an active compromise. curl, wget, base64 -d, or /dev/tcp mean stage two.
  4. Check for persistence. Look for new files in web-accessible directories (web shells), new cron entries or systemd units, and modified startup scripts.
  5. Establish the service account’s blast radius. What can that identity reach — databases, cloud roles, internal APIs? On a cloud host, check whether the instance metadata service was queried, which indicates credential theft. This is the same pivot covered in SSRF detection.
  6. Contain. Isolate the host or cordon and drain the node. For a container, capture the filesystem diff first, then kill the pod — the replacement is clean if the image is.
  7. Rotate every credential the process could read. Environment variables, mounted secrets, and any cloud role attached to the workload.
  8. Fix the endpoint. Until the underlying code stops building shell commands from input, the host is going to be re-exploited within hours of coming back.

Step 7 is the one most often under-scoped. Injected code runs as the service, which means it could read every environment variable and mounted secret in that workload — not just the ones the vulnerable endpoint used. Rotate all of them.

How to test your command injection detection

Validate in a lab you own, never against a third party:

  1. Stand up a deliberately vulnerable app (DVWA’s command-injection module, or a throwaway endpoint that shells out).
  2. Trigger the in-band variant and confirm the web-token and process-spawn rules fire.
  3. Trigger a blind/out-of-band variant (a callback to a host you control) and confirm the egress/DNS signal fires.
  4. Replay benign traffic and your DAST scan to confirm the rules stay quiet, then record the false-positive sources in each rule.

How to prevent command injection

  • Run services at least privilege so a shell that does spawn is boxed in (NIST 800-53 AC-6).
  • Patch internet-facing appliances fast — CVE-2024-3400 was added to CISA’s KEV catalog and exploited within days of disclosure.
  • Default-deny egress from web hosts so blind/out-of-band callbacks become blocked, logged events.

The controls ranked by what they actually buy you

ControlStops the bug class?EffortNotes
Argument-array APIs (no shell)YesLow per call siteThe only true fix. execFile, subprocess.run([...])
Remove the shell from the imageYes, in practiceMediumDistroless/scratch; also makes detection near-perfect
Read-only root filesystemNo, but blocks stage twoLowPayload cannot be written to disk
Default-deny egressNo, but blocks stage twoMediumTurns blind injection into a logged, failed callback
Least-privilege service accountNo, limits blast radiusMediumDecides how bad the incident is
Input allowlistingPartiallyMediumUseful defence in depth; brittle alone
WAF signaturesNoLowCatches noise; encoded and second-order variants pass

Read that table top-down when deciding where to spend. The first two rows remove the vulnerability class; everything below them limits consequences. A team that has deployed only the bottom row has bought detection latency, not safety — and CVE-2024-3400 is the demonstration, because a WAF watching query strings had nothing to match against a payload hidden in a filename.

On the argument-array fix specifically: the distinction is whether a shell is involved at all. subprocess.run("ping " + host, shell=True) hands the string to /bin/sh, which is where metacharacters gain meaning. subprocess.run(["ping", host]) executes the binary directly with host as one opaque argument — a semicolon in it is just a character in a hostname, and the attack has nowhere to land. The same distinction exists in every language: Node’s execFile versus exec, Java’s ProcessBuilder with an argument list versus a concatenated string, Go’s exec.Command with separate arguments. This is a mechanical code-review check that scales better than any signature.

Common command injection detection mistakes

  • WAF-only coverage. Parameter signatures miss filename-smuggled and blind variants — exactly the CVE-2024-3400 pattern.
  • No process-creation telemetry. Without it, the backbone signal is invisible.
  • Ignoring egress. Blind and out-of-band injection live in DNS and outbound logs, not request logs.
  • Untuned metacharacter rules that page on every scan and get muted.
  • Allowlisting the parent process alone. Excluding java because one legacy app shells out disables the backbone rule for every Java service you run.
  • Assuming the server model. On managed PaaS there is often no process telemetry at all, so a detection plan built around the shell-spawn rule silently covers nothing.
  • Isolating before capturing. Containers get rescheduled and short-lived processes vanish; capture the process tree first or lose the evidence.
  • Rotating only the credentials the endpoint used. Injected code reads every environment variable and mounted secret in the workload.

Command injection detection checklist

  1. Forward process-creation, web access, and DNS/egress logs to the SIEM.
  2. Deploy the service-process-spawns-a-shell rule — the highest-fidelity signal.
  3. Add the web-parameter metacharacter rule with a per-IP rate threshold.
  4. Add the Suricata metacharacter rule where you have TLS visibility.
  5. Chain shell-spawn alerts to child processes (curl/wget/base64) and new egress.
  6. Default-deny egress from web hosts; alert on new outbound destinations.
  7. Replace shelled-out commands with argument-array APIs in code review.
  8. Run services at least privilege; patch internet-facing appliances on a KEV cadence.
  9. Fire every rule in a lab against true-positive and benign traffic.
  10. Allowlist by parent and child pair — never by parent process alone.
  11. On containers, alert on any execve that is not the known entrypoint; allowlist healthchecks by full argument vector.
  12. Ship distroless or shell-free runtime images wherever the workload allows.
  13. Set container root filesystems read-only so a dropped payload cannot be written.
  14. Alert on every kubectl exec into production and reconcile against change records.
  15. Confirm appliance and PaaS logs are actually forwarded and parsed — this is the most common blind spot and the cheapest to fix.

Item 15 deserves the last word. The CVE-2024-3400 intrusions were visible in appliance logs that most organizations had never forwarded to a SIEM. There was no detection engineering problem to solve — the evidence existed, unread, on the device. Ingesting logs you already generate is the highest return-on-effort work in this entire guide.

What does normal look like on this host?

Every signal above is only meaningful against a baseline, and the baseline is the part teams skip. A web server that never spawns a shell is a strong detection; a build server that spawns shells constantly makes the same rule useless noise.

Collect two weeks of process-creation telemetry per host role before tuning, and write down what the normal parent-child tree actually looks like. You will usually find that the interesting signal is not “a shell spawned” but “a shell spawned from a process that has never spawned one before” — and that distinction is only available if you know the prior.

Segment the baseline by role, not by fleet. Web tier, build tier, and admin jump hosts have wildly different legitimate behaviour, and a single fleet-wide threshold ends up tuned to the noisiest role, which is exactly the role you were least worried about.

The takeaway

Command injection detection is a behavior problem, not a string-matching one. Watch for a service process spawning a shell, chain it to what the shell does next, and remove the bug class by never building shell commands from untrusted input. Command execution also arrives straight from the keyboard — see USB Rubber Ducky detection patterns. Continue across the web-application attack surface with SQL injection detection and SSRF detection, or follow the data back out of the network with DNS tunneling detection. Or browse the full Detection Engineering pillar.

Training & tools referenced

Disclosure: Some links below are affiliate links. If you buy through them, darkpwn may earn a commission at no extra cost to you. We only recommend training and tools we actually use in our own lab, and affiliate links never influence editorial coverage.

  • TryHackMeAuthorized labs to practice command-injection detection against real telemetrySecurity Training
    Start training

Frequently asked questions

How do you detect command injection in logs?

The highest-fidelity signal is a web or application service process (nginx, apache, java, php-fpm) spawning a shell interpreter such as bash, sh, cmd.exe, or powershell. At the web tier, watch request parameters for shell metacharacters and command tokens, and watch for unexpected outbound connections from the web host.

What is the difference between command injection and code injection?

Command injection runs operating-system commands through a shell the application invokes (for example, passing input to system() or a shell call). Code injection runs code in the application's own language or runtime. Command injection maps to MITRE ATT&CK T1059; the detection signal is a service process spawning a shell.

Which MITRE ATT&CK technique covers command injection?

Command injection against an internet-facing app maps to T1190 (Exploit Public-Facing Application) for the entry and T1059 (Command and Scripting Interpreter) for the shell execution it produces.

How do you detect command injection in containers?

Containers make this easier, not harder. A container image has a known entrypoint and a small set of processes it should ever run, so any shell execution inside an application container is anomalous by construction. Collect process events with an eBPF sensor such as Falco or Tetragon rather than auditd, which drops events under high process churn, and allowlist healthcheck and init commands by their full argument vector.

Does using distroless images stop command injection?

Largely, in practice. If the runtime image contains no shell, an injected command has no interpreter to invoke and the common exploitation path stops working. It also sharpens detection: the alert condition becomes any process execution that is not the known entrypoint, which has almost no false positives. It does not fix the underlying code defect, so pair it with argument-array APIs.

Can a WAF stop command injection?

A WAF blocks obvious shell-metacharacter payloads and is worth running, but encoding, blind, and second-order variants bypass tuned-down rules. The durable control is avoiding shell calls with untrusted input; the durable detection is process-creation telemetry showing a service spawning a shell.