One tainted variable. One shell on the host. Here's the full story of a real
CWE-78 (OS Command
Injection) vulnerability, from the moment a rule engine flags it to a
disclosure-ready report — all running locally, with no cloud AI anywhere in
the pipeline.
The vulnerable code
The target is a deliberately vulnerable local training app: a tiny "network
diagnostics" tool that pings a host you give it.
app.post('/ping', (req, res) => {
const host = req.body.host || '127.0.0.1';
const cmd = `ping -c 1 ${host}`; // BUG: unsanitized string concatenation
exec(cmd, { timeout: 5000 }, (err, stdout, stderr) => {
res.json({ cmd, stdout, stderr, error: err ? err.message : null });
});
});
No validation. No escaping. No argument array — just user input flowing
straight into a shell. And because the JSON response echoes back the exact
cmd string the server built, the vulnerable string is visible in every
response, not just the source — one of the cleanest bugs you'll ever capture
airtight evidence from.
Step 1 — A deterministic rule engine catches it, not an LLM
Before any AI touches this finding, a taint-tracking static analysis rule
walks the exact shape this bug takes:
const host = req.body.host … → source: untrusted request value
const cmd = `ping -c 1 ${host}` → assignment: taint propagates through host → cmd
exec(cmd, …) → sink: child_process.exec
One static scan raises a Critical, CWE-78 finding automatically —
zero manual finding-creation, zero guesswork. The finding already carries:
- Evidence — the matched source line, with the exact column of the injection point pinned inside the tainted expression
- Risk Level — Critical
- Security Reference — CWE-78 / OWASP A03:2021 (Injection)
The rule engine discovers; the LLM only ever explains an already-verified
finding afterward. It never gets to invent a vulnerability on its own.
Step 2 — Proving it live, with zero extra tooling
The form field is prefilled 127.0.0.1; id. Submitting it returns:
{
"cmd": "ping -c 1 127.0.0.1; id",
"stdout": "PING 127.0.0.1 ...\nuid=501(demo) gid=20(staff) groups=...\n",
"stderr": "",
"error": null
}
cmd proves the server built exactly the string you'd expect from unescaped
concatenation. stdout proves it further — after the ping output, a
uid=…gid=… line: the output of id, a command nobody asked the app to
run. One request/response pair is already enough evidence on its own — no
server access needed to confirm the bug, because the app's own response is
externally observable proof.
Step 3 — Varying the payload (and why encoding matters)
A quick gotcha worth calling out for anyone testing this class of bug by
hand: the request's Content-Type is application/x-www-form-urlencoded,
so a literal host=127.0.0.1 && whoami gets split by the form parser on the
raw & before the payload ever reaches host — the metacharacter never
survives. Percent-encode it instead:
| Payload | Encoded body |
|---|---|
127.0.0.1 && whoami |
host=127.0.0.1%20%26%26%20whoami |
| `127.0.0.1 \ | uname -a` |
`id` (substitution) |
host=%60id%60 |
Four independent shell metacharacters (;, &&, |, `), four
independent proofs that this reaches a real shell — not one lucky payload.
Step 4 — A fully offline explanation
With the static evidence and the live proof both attached to the same
finding, a local LLM generates a plain-language narrative from the
structured evidence — something like "Untrusted input reaches
child_process.exec via string concatenation — full remote code
execution." No network call is made. The model reasons over an
already-verified finding; it doesn't discover anything new.
Step 5 — The fix
const { execFile } = require('node:child_process');
app.post('/ping', (req, res) => {
const host = req.body.host || '127.0.0.1';
execFile('ping', ['-c', '1', host], { timeout: 5000 }, (err, stdout, stderr) => {
res.json({ stdout, stderr, error: err ? err.message : null });
});
});
execFile with an argument array means there's no shell to interpret in the
first place — host is passed as a literal argument, never concatenated
into a command string. Add strict input validation on host (e.g. an
IP/hostname allow-list pattern) as defense in depth.
What's honestly not covered
Two active-exploitation probes for this bug class exist in the platform's
engine, but neither is currently wired to a GUI checkbox or CLI flag — so
dynamic confirmation via automated active testing isn't possible today for
this specific bug. That's exactly why this walkthrough proves the exploit
by hand instead of relying on tooling that isn't there yet. Calling out
tooling gaps honestly matters more to me than pretending coverage is
complete.
Try it yourself
The target app and the full step-by-step playbook (every click, every
payload, every panel) are linked below if you want to reproduce this
end-to-end against your own local copy.
This is an intentionally vulnerable local training app. Never run these
techniques against a system you don't own or don't have explicit written
authorization to test.
Try it yourself → https://github.com/sendwavehub/scan-target-demo-apps
Windows Store https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US
Web Site https://Sendwavehub.tech/en/apps/ai-security-studio-4
Top comments (0)