Loạt bài phòng thủ: hiểu lỗ hổng để viết code an toàn, demo trong lab cô lập.

Ta đã thấy SQL injection (phần 1) và XSS (phần 4) — hai biến thể của cùng một lỗi gốc: input người dùng biến thành code. Bài này gộp hai lỗ hổng nữa cùng họ, cùng một gốc rễ, cùng một kiểu phòng thủ.

Command injection xảy ra khi ứng dụng dựng một lệnh hệ điều hành bằng cách ghép input người dùng vào, rồi chạy qua shell. Shell có các ký tự đặc biệt (;, |, &&, `) để nối/điều khiển lệnh — nên một input chứa ; rm -rf ... biến một lệnh thành hai. Path traversal xảy ra khi ứng dụng mở một file dựa trên tên do người dùng cung cấp, mà không kiểm — input ../../../etc/passwd dùng .. để leo ra khỏi thư mục cho phép, đọc file bất kỳ trên hệ thống. Bài này (phần 7 loạt Bảo mật web) tái hiện cả hai và cách chặn.

Cơ chế: input thành code, hoặc vượt ranh giới

Ảnh chụp đoạn mã nền tối minh hoạ command injection và path traversal, phần A command injection khối sai dựng lệnh qua shell bằng nối chuỗi exec.Command sh -c ping cộng userInput userInput bằng localhost chấm phẩy echo CHIEM_QUYEN shell thấy dấu chấm phẩy chạy lệnh thứ hai command injection, khối đúng tách tham số không qua shell exec.Command ping userInput mỗi đối số tách rời không có shell diễn giải input là một đối số text không là lệnh, phần B path traversal khối sai nối đường dẫn thô từ tên file người dùng p bằng filepath.Join baseDir userFile userFile bằng ba chấm gạch etc passwd thoát khỏi baseDir đọc file ngoài, khối đúng làm sạch cộng kiểm thuộc baseDir clean bằng filepath.Clean filepath.Join baseDir userFile if không strings.HasPrefix clean baseDir gạch return từ chối chặn thoát ra

Hình 1: Command injection — dựng lệnh qua shell bằng nối chuỗi (sh -c "ping "+input), input chứa ; làm shell chạy lệnh thứ hai; chặn bằng exec.Command("ping", input) tách tham số không qua shell. Path traversal — nối đường dẫn thô (filepath.Join(baseDir, userFile)) với ../ thoát ra ngoài; chặn bằng filepath.Clean + kiểm HasPrefix(clean, baseDir).

Tái hiện thật trong go-lab (lab cô lập)

Mình chạy trong go-lab (golang 1.23) với input độc giả định, trong một thư mục lab cô lập.

Ảnh chụp bảng kết quả tái hiện thật trong go-lab output thật golang 1.23 lab cô lập input độc giả định, phần A command injection input localhost chấm phẩy echo CHIEM_QUYEN một shell nối chuỗi sh -c output ping localhost rồi CHIEM_QUYEN_THANH_CONG lệnh sau dấu chấm phẩy đã chạy bằng injection hai tách tham số exec.Command echo ping input output ping localhost chấm phẩy echo CHIEM_QUYEN_THANH_CONG là một text không chạy, phần B path traversal tên file hai chấm gạch SECRET.txt ngoài baseDir một nối thô filepath.Join baseDir userFile public.txt doc noi dung cong khai hai chấm gạch SECRET.txt doc DAY_LA_FILE_BI_MAT_NGOAI_BASEDIR thoát ra đọc được hai Clean cộng kiểm thuộc baseDir public.txt doc OK noi dung cong khai hai chấm gạch SECRET.txt từ chối đường dẫn thoát ra ngoài baseDir, cùng gốc với SQL injection XSS input biến thành code lệnh shell hoặc vượt ranh giới cho phép chặn bằng tách tham số không shell và làm sạch cộng kiểm đường dẫn thuộc baseDir đừng tin input

Hình 2: Kết quả thật — A. command injection: shell nối chuỗi chạy cả lệnh thứ hai (CHIEM_QUYEN_THANH_CONG), còn tách tham số coi input là một text (ping localhost; echo ... không chạy); B. path traversal: nối thô đọc được file bí mật ngoài baseDir, còn Clean + kiểm thuộc baseDir thì TỪ CHỐI (vẫn cho đọc public.txt).

Kết quả rõ ràng:

  • Command injection: shell biến một lệnh thành hai. Khi dựng lệnh bằng sh -c "ping " + input với input localhost; echo CHIEM_QUYEN..., output cho thấy cả hai lệnh chạy: ping localhost rồi CHIEM_QUYEN_THANH_CONG. Dấu ; được shell diễn giải là "kết thúc lệnh, bắt đầu lệnh mới". Kẻ tấn công chạy lệnh tuỳ ý trên server (; rm, ; cat file nhạy cảm, ; curl gửi dữ liệu ra ngoài). Ngược lại, exec.Command("echo", "ping", input) tách tham số — không có shell diễn giải, nên input localhost; echo CHIEM_QUYEN... chỉ là một đối số text, in ra nguyên văn, không có lệnh nào chạy thêm.
  • Path traversal: .. leo ra ngoài thư mục. Khi mở file bằng filepath.Join(baseDir, userFile) thô, input ../SECRET.txt thoát khỏi baseDir và đọc được SECRET.txt nằm ngoài — nội dung file bí mật lộ ra. Với phòng thủ (filepath.Clean để chuẩn hoá .., rồi kiểm đường dẫn kết quả vẫn thuộc baseDir), request ../SECRET.txt bị TỪ CHỐI, trong khi file hợp lệ public.txt vẫn đọc được bình thường.

Cả hai lỗ hổng, như SQL injection và XSS, đều cùng một bệnh: trộn lẫn dữ liệu người dùng với code/ranh giới hệ thống. Và cùng một thuốc: tách bạch chúng.

Đánh đổi cần cân nhắc

Đừng dựng lệnh shell từ input — dùng API tách tham số. Quy tắc số một cho command injection: tránh shell hoàn toàn khi có thể. Dùng API thực thi tách tham số (exec.Command(prog, arg1, arg2) trong Go, subprocess.run([...]) không shell=True trong Python) — chúng truyền đối số trực tiếp cho chương trình, không qua shell diễn giải, nên input không bao giờ thành lệnh. Nếu bắt buộc phải dùng shell (hiếm — ví dụ cần pipe), thì input phải qua allowlist nghiêm ngặt (chỉ cho ký tự an toàn đã biết), không bao giờ nối thẳng. Và tốt hơn nữa: nếu có thư viện làm việc đó (ví dụ gọi API thay vì chạy lệnh curl), dùng thư viện thay vì gọi lệnh ngoài.

Path: luôn Clean + kiểm thuộc baseDir, và cẩn thận các biến thể. filepath.Clean chuẩn hoá .. và ., nhưng chỉ Clean là chưa đủ — phải kiểm đường dẫn kết quả có nằm trong baseDir không (như demo). Cẩn thận các biến thể vượt qua kiểm tra ngây thơ: .. được URL-encode (%2e%2e), đường dẫn tuyệt đối (/etc/passwd), symlink trỏ ra ngoài, hay (trên Windows) dấu \. Go 1.24+ có os.Root cho phép mở file bị giới hạn trong một gốc (an toàn hơn kiểm tay); nếu không có, có thể dùng chroot/container. Nguyên tắc: đừng tin tên file từ người dùng — kiểm nó thuộc vùng cho phép trước khi mở.

Phòng thủ theo lớp: least privilege và cô lập. Dù code đúng, hãy giảm thiểu thiệt hại nếu có lỗ hổng lọt. Chạy ứng dụng với quyền tối thiểu (không root), trong container/sandbox giới hạn file và network truy cập được, với filesystem chỉ-đọc ở chỗ không cần ghi. Nếu một command injection lọt qua, một tiến trình quyền thấp trong container cô lập gây hại ít hơn nhiều so với một tiến trình root trên host. Code đúng là tuyến đầu; cô lập là lưới an toàn khi tuyến đầu thủng.

Ba ý mang về

  1. Command injection: input thành lệnh khi dựng lệnh qua shell: tái hiện thật, sh -c "ping "+input với input chứa ; chạy cả lệnh thứ hai (CHIEM_QUYEN_THANH_CONG); exec.Command("ping", input) tách tham số coi input là một đối số text, không chèn được lệnh — tránh shell là chìa khoá.
  2. Path traversal: .. leo ra khỏi thư mục cho phép: tái hiện thật, nối đường dẫn thô với ../SECRET.txt đọc được file bí mật ngoài baseDir; filepath.Clean + kiểm HasPrefix(clean, baseDir) từ chối đường dẫn thoát ra, vẫn cho đọc file hợp lệ.
  3. Cùng gốc, cùng thuốc — và phòng thủ theo lớp: cả hai (như SQL injection/XSS) là input biến thành code/vượt ranh giới; chặn bằng tách tham số (không shell, allowlist nếu buộc) và làm sạch + kiểm đường dẫn (cẩn thận encode/symlink, dùng os.Root); cộng least privilege + container cô lập làm lưới an toàn.

Nguồn

Phần sau ta bàn về chống dò mật khẩu: rate limiting và account lockout cho đăng nhập — đo thật cách giới hạn số lần thử làm brute-force bất khả thi, và các đánh đổi (DoS, khoá oan người dùng thật).