Là gì
Path traversal là lỗi khi tên file do người dùng cung cấp được ghép vào đường dẫn hệ thống mà không kiểm, nên ../ đưa được thao tác đọc/ghi ra ngoài thư mục dự kiến. Kết quả điển hình là đọc /etc/passwd, web.config, .env, hoặc khoá SSH — và ở dạng ghi thì nó là ghi đè file, tức là thực thi code.
Vì sao bạn quan tâm
Path traversal là lỗi mà bản vá trực giác gần như luôn sai, và đó là điều đáng học ở đây hơn bản thân lỗi.
Phản xạ đầu tiên của mọi người là "chặn ../". Nó sai vì đường đi từ chuỗi người dùng gửi lên tới lời gọi syscall đi qua nhiều tầng giải mã, và mỗi tầng có thể tạo lại ../ sau khi bạn đã kiểm:
- web server giải mã
%2e%2e%2f→../ - một số framework giải mã hai lần:
%252e%252e%252f→%2e%2e%2f→../ ..\trên Windows;....//co lại thành../khi bạn xoá chuỗi../- symlink trong thư mục đích trỏ ra ngoài
Cách duy nhất thắng được là không kiểm chuỗi mà kiểm kết quả: giải quyết đường dẫn về dạng chuẩn tuyệt đối rồi hỏi "nó có còn nằm trong thư mục gốc không?". Câu hỏi đó không quan tâm người ta viết ../ bằng cách nào.
Điểm thứ hai đáng lưu ý: lỗi này thường không nằm trong code bạn viết. Nó nằm trong giải nén — một file .zip chứa entry tên ../../app/appsettings.json (Zip Slip) — và trong static file middleware cấu hình sai.
Cơ chế hoạt động
Cơ chế là sự chênh lệch giữa chuỗi bạn kiểm và đường dẫn kernel thực sự mở.
flowchart TD U["file=%252e%252e%252fetc%252fpasswd"] --> W[Web server<br/>giải mã lần 1] W --> D1["file=%2e%2e%2fetc%2fpasswd"] D1 --> F[Framework<br/>giải mã lần 2] F --> D2["file=../etc/passwd"] D2 --> C{Kiểm ở đâu?} C -->|"Kiểm CHUỖI ở đây<br/>(sai — đã kiểm trước lần giải mã 2)"| B["Path.Combine(root, file)<br/>→ /srv/files/../etc/passwd"] C -->|"Kiểm KẾT QUẢ<br/>(đúng)"| G["GetFullPath() → /etc/passwd<br/>StartsWith(root)? → KHÔNG → chặn"] B --> K["open('/etc/passwd')<br/>kernel co ../ lại"]Điểm mấu chốt ở nhánh trái: Path.Combine không co ../ lại, nhưng kernel thì có. Nên chuỗi bạn nhìn thấy (/srv/files/../etc/passwd) trông như nằm trong /srv/files, còn file được mở thì không.
Danh sách cách viết — bảng này là lý do blocklist không thắng được:
| Cách viết | Thành | Vòng qua được cái gì |
|---|---|---|
../ | ../ | không vòng gì — dạng cơ bản |
%2e%2e%2f | ../ | kiểm trước khi URL-decode |
%252e%252e%252f | ../ | giải mã hai lần |
....// | ../ | bản vá kiểu replace("../", "") |
..\ | ../ | kiểm chỉ dấu / (Windows) |
..%c0%af | ../ | UTF-8 overlong (server cũ) |
| symlink | bất kỳ | mọi phép kiểm chuỗi, kể cả canonical hoá sai cách |
Zip Slip là cùng lỗi ở nơi ít ai nhìn: entry.FullName trong một archive là chuỗi do kẻ tấn công kiểm soát hoàn toàn, và nó được ghép vào thư mục đích. Ở dạng ghi thì hậu quả là RCE, không phải rò dữ liệu.
Mô tả sơ đồ: Sơ đồ luồng cho thấy chuỗi file=%252e%252e%252fetc%252fpasswd đi qua hai tầng giải mã — web server giải mã lần một, framework giải mã lần hai — rồi tới điểm quyết định. Nhánh sai kiểm chuỗi trước lần giải mã thứ hai nên Path.Combine tạo ra /srv/files/../etc/passwd và kernel co ../ lại thành /etc/passwd. Nhánh đúng gọi GetFullPath để có đường dẫn chuẩn /etc/passwd rồi kiểm nó có bắt đầu bằng thư mục gốc không, và chặn.
Ví dụ cụ thể
Endpoint tải file đính kèm theo tên. Ba request, ba cách viết cùng một đích.
GET /api/files/download?name=../../../../etc/passwd HTTP/1.1 HTTP/1.1 200 OKContent-Type: application/octet-stream root:x:0:0:root:/root:/bin/bashpostgres:x:114:120::/var/lib/postgresql:/bin/bash# Sau khi thêm bản vá replace("../","") — vẫn lọt, vì ....// co lại thành ../GET /api/files/download?name=....//....//....//app/appsettings.json HTTP/1.1 HTTP/1.1 200 OK{"ConnectionStrings":{"Core":"Host=db;Password=…"}}# Sau khi vá đúng (canonical hoá rồi so với gốc)GET /api/files/download?name=....//....//app/appsettings.json HTTP/1.1 HTTP/1.1 404 Not Found{"errorCode":"ER_FILE_NOT_FOUND"}404 chứ không 400: một 400 "invalid path" xác nhận cho kẻ tấn công là bộ lọc tồn tại và họ đang đi đúng hướng.
[HttpGet("/api/files/download")]public IActionResult Download(string name){ var root = "/srv/files"; // ❌ Path.Combine KHÔNG co ../ lại — kernel mới co. Nên chuỗi này trông như // nằm trong /srv/files (/srv/files/../../etc/passwd) trong khi file được // mở thì là /etc/passwd. var path = Path.Combine(root, name); return PhysicalFile(path, "application/octet-stream");} public void Extract(string zipPath, string dest){ using var archive = ZipFile.OpenRead(zipPath); foreach (var entry in archive.Entries) { // ❌ Zip Slip. entry.FullName do người tạo file zip kiểm soát HOÀN TOÀN, // và "../../app/appsettings.json" là một tên entry hợp lệ theo chuẩn zip. // Ở dạng GHI thì hậu quả không phải rò dữ liệu mà là ghi đè file. entry.ExtractToFile(Path.Combine(dest, entry.FullName), overwrite: true); }}@app.get("/api/files/download")def download(): name = request.args["name"] # ❌ Hai lỗi trong một dòng. Ngoài ../ như mọi ngôn ngữ khác, os.path.join còn # có hành vi riêng: nếu name bắt đầu bằng "/", nó VỨT gốc đi hoàn toàn. # os.path.join("/srv/files", "/etc/passwd") == "/etc/passwd" path = os.path.join("/srv/files", name) return send_file(path)Chuyện đã xảy ra
CVE-2021-41773 / CVE-2021-42013 — Apache httpd 2.4.49 và 2.4.50. Bản 2.4.49 có path traversal qua %2e; bản 2.4.50 được phát hành để vá nó, và bản vá đó không đủ — %%32%65 vẫn đi qua, thành CVE-2021-42013, khai thác được thành RCE khi bật CGI. Đây là ví dụ kinh điển cho luận điểm của khối 2: hai vòng vá dựa trên việc chặn cách viết, hai lần thua. Cả hai đều bị khai thác hàng loạt trong vòng vài ngày.
Zip Slip (2018) — hàng nghìn thư viện trên nhiều hệ sinh thái. Snyk công bố một lỗ hổng cùng dạng trong thư viện giải nén của Java, .NET, Go, Ruby, JS. Nguyên nhân chung: entry.FullName được ghép vào thư mục đích mà không kiểm kết quả. Đáng nhớ vì nó cho thấy lỗi này sống lâu nhất ở nơi không ai coi là "xử lý input người dùng".
Cách phòng chống
Đừng nhận tên file. Nhận một ID
bắt buộcBiện pháp tốt nhất là loại bỏ bài toán: client gửi fileId (UUID), server tra DB ra đường dẫn thật do chính server đã sinh khi upload. Không có chuỗi nào của người dùng đi vào đường dẫn, nên không có traversal. Đây là bản vá nên chọn khi có thể — mọi thứ dưới đây chỉ cần thiết khi bạn không thể.
/// <summary>/// Không kiểm CHUỖI, kiểm KẾT QUẢ.////// Lý do là bảng cách viết ở khối 3: ../ viết được bằng ít nhất bảy cách, và danh/// sách đó vẫn đang dài ra. Canonical hoá rồi so sánh thì không quan tâm người ta/// viết bằng cách nào — nó chỉ hỏi "file cuối cùng nằm ở đâu"./// </summary>public static class SafePath{ /// <summary> /// Trả về đường dẫn tuyệt đối an toàn, hoặc null nếu nó ra ngoài gốc. /// /// null chứ không throw, và caller trả 404 chứ không 400: một 400 "invalid /// path" xác nhận cho kẻ tấn công là bộ lọc tồn tại và họ đang đi đúng hướng. /// </summary> public static string? ResolveWithin(string root, string userInput) { if (string.IsNullOrWhiteSpace(userInput)) return null; // Dấu phân tách ở CUỐI gốc. Thiếu nó thì /srv/files-secret/x khớp tiền tố // với /srv/files — một lỗi thầm lặng chỉ lộ ra khi có thư mục anh em. var canonicalRoot = Path.GetFullPath(root) .TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; // GetFullPath co MỌI ../, ..\, ....//, và mọi cách viết đã được giải mã // xong ở tầng trên. Đây là bước thay thế cho toàn bộ ý tưởng blocklist. var candidate = Path.GetFullPath(Path.Combine(canonicalRoot, userInput)); if (!candidate.StartsWith(canonicalRoot, StringComparison.Ordinal)) return null; // Symlink: GetFullPath KHÔNG đi theo link, nên tới đây một link nằm trong // /srv/files trỏ ra /etc/passwd vẫn đi qua mọi kiểm ở trên. Đây là bước bị // bỏ nhiều nhất và là bước duy nhất bắt được nó. var finalTarget = File.ResolveLinkTarget(candidate, returnFinalTarget: true); if (finalTarget is not null && !Path.GetFullPath(finalTarget.FullName).StartsWith(canonicalRoot, StringComparison.Ordinal)) return null; return candidate; }} [HttpGet("/api/files/download")]public IActionResult Download(string name){ var path = SafePath.ResolveWithin("/srv/files", name); // 404 cho cả "ra ngoài gốc" lẫn "không tồn tại": hai trường hợp không được // phân biệt được từ bên ngoài, nếu không thì mã trạng thái trở thành một // kênh dò cấu trúc thư mục. if (path is null || !System.IO.File.Exists(path)) throw new NotFoundException(ContentErrorsList.FILE_NOT_FOUND); return PhysicalFile(path, "application/octet-stream");} /// <summary>/// Zip Slip: cùng phép kiểm, đặt TRƯỚC khi ghi. Kiểm sau khi giải nén là vô nghĩa —/// file đã nằm trên đĩa rồi./// </summary>public void Extract(string zipPath, string dest){ using var archive = ZipFile.OpenRead(zipPath); long totalBytes = 0; const long MaxTotal = 500L * 1024 * 1024; // trần chống zip bomb foreach (var entry in archive.Entries) { if (entry.FullName.EndsWith('/')) continue; // thư mục var target = SafePath.ResolveWithin(dest, entry.FullName); if (target is null) throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, $"Archive entry escapes the destination: {entry.FullName}"); totalBytes += entry.Length; if (totalBytes > MaxTotal) throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Archive too large"); Directory.CreateDirectory(Path.GetDirectoryName(target)!); entry.ExtractToFile(target, overwrite: false); // false: không ghi đè }}from pathlib import Path from flask import abort, send_file ROOT = Path("/srv/files").resolve() def resolve_within(root: Path, user_input: str) -> Path | None: """Trả về đường dẫn an toàn, hoặc None nếu nó ra ngoài gốc. Python tiện hơn .NET ở đúng một điểm và đó là điểm quan trọng nhất: Path.resolve() vừa co ../ vừa ĐI THEO symlink, nên hai bước riêng của bản C# gộp làm một ở đây và không có cách nào quên bước thứ hai. """ if not user_input or user_input.isspace(): return None # strict=False: file chưa tồn tại vẫn resolve được, cần cho luồng ghi. candidate = (root / user_input).resolve(strict=False) # is_relative_to so theo THÀNH PHẦN đường dẫn, không so tiền tố chuỗi — nên # /srv/files-secret không khớp với /srv/files, và ta không cần tự nhớ đặt dấu # phân tách ở cuối như bản C#. if not candidate.is_relative_to(root): return None return candidate @app.get("/api/files/download")def download(): path = resolve_within(ROOT, request.args.get("name", "")) # 404 cho cả hai trường hợp: "ra ngoài gốc" và "không tồn tại" không được # phân biệt được từ bên ngoài. if path is None or not path.is_file(): abort(404) return send_file(path)Nếu buộc phải nhận tên: canonical hoá rồi so, KHÔNG kiểm chuỗi
Ba bước, đúng thứ tự:
Path.Combine(root, userInput)rồiPath.GetFullPath(...)— bây giờ mọi../,..\,....//đã co lại.- So sánh kết quả với
rootđã canonical hoá, dùngStartsWithcó dấu phân tách ở cuối root (nếu không thì/srv/files-secretkhớp tiền tố với/srv/files). - Sau khi mở file, kiểm lại đường dẫn thật để bắt symlink: trong .NET dùng
File.ResolveLinkTarget(path, returnFinalTarget: true).
Bước 3 là bước bị bỏ nhiều nhất, và là bước duy nhất bắt được symlink — thứ mà mọi phép kiểm chuỗi đều mù.
Allowlist tên file khi hình dạng cho phép
Nếu tên file luôn có dạng biết trước (^[a-zA-Z0-9_-]{1,64}\.(pdf|png)$) thì allowlist regex là kiểm thêm rẻ. Nó KHÔNG thay bước canonical hoá — nó chỉ chặn sớm.
Giải nén archive: kiểm từng entry bằng cùng phép kiểm
Đây là chỗ Zip Slip sống. Với mỗi entry: canonical hoá Path.Combine(dest, entry.FullName) và so với dest. Bỏ qua entry là symlink hoặc device. Và đặt trần cho tổng kích thước giải nén (zip bomb).
Chroot / container / quyền filesystem
Chạy service với user không đọc được /etc/shadow, khoá SSH, hay file cấu hình của chính nó. Mount thư mục file là volume riêng, readOnlyRootFilesystem: true trong Kubernetes. Lớp này quyết định traversal lấy được gì khi lớp 1 có lỗ.
Kiểm chứng đã vá
1. Test đơn vị lấy BẢNG CÁCH VIẾT ở khối 3 làm dữ liệu vào. Đây là điểm chính: bảng cách viết là thứ sống lâu, và test là nơi nó nên sống. Khi ai đó tìm ra cách viết thứ tám, họ thêm một dòng [InlineData] — không sửa code sản phẩm. Xem tab csharp / test.
2. Test symlink — phép kiểm mà gần như không ai viết. Tạo một symlink trong thư mục gốc trỏ ra /etc/passwd, rồi yêu cầu file đó theo tên hợp lệ hoàn toàn. Nếu test này pass mà bạn chưa gọi ResolveLinkTarget thì bản vá của bạn có lỗ và test đang nói dối.
3. Test Zip Slip trên đúng hàm giải nén đang dùng:
# Dựng một zip độc hại rồi khẳng định file không xuất hiện ngoài thư mục đíchpython3 - <<'EOF'import zipfilewith zipfile.ZipFile("evil.zip", "w") as z: z.writestr("../../../../tmp/pwned.txt", "traversal")EOFdotnet test --filter ZipSliptest ! -f /tmp/pwned.txt || { echo "Zip Slip vẫn còn"; exit 1; }4. Kiểm quyền filesystem của process (lớp 3):
# Từ trong container, những lệnh này PHẢI thất bại:docker exec app cat /etc/shadow && { echo "đọc được shadow"; exit 1; }docker exec app touch /app/marker && { echo "root fs ghi được"; exit 1; }docker exec app id -u | grep -qv '^0$' || { echo "đang chạy bằng root"; exit 1; }5. Grep chặn merge cho Path.Combine có input người dùng mà không canonical hoá:
grep -rn "Path.Combine" --include='*.cs' src/ \ | grep -vE 'GetFullPath|SafePath\.' \ | grep -iE 'request|input|name|fileName|entry\.' \ && { echo "Path.Combine với input chưa canonical hoá — chặn"; exit 1; }exit 0public class SafePathTests : IDisposable{ private readonly string _root = Directory.CreateTempSubdirectory().FullName; /// <summary> /// Mỗi dòng ở đây là một hàng của bảng cách viết ở khối 3. Đặt chúng thành DỮ /// LIỆU chứ không thành các câu if trong code sản phẩm là toàn bộ điểm khác biệt /// giữa bản vá này và bản vá blocklist: khi ai đó tìm ra cách viết thứ tám, họ /// thêm một dòng ở đây và không sửa gì trong SafePath. /// </summary> [Theory] [InlineData("../../../../etc/passwd")] [InlineData("....//....//....//etc/passwd")] // co lại thành ../ khi bị strip [InlineData("..\\..\\..\\windows\\win.ini")] [InlineData("/etc/passwd")] // tuyệt đối, không cần ../ nào [InlineData("subdir/../../../etc/passwd")] [InlineData("")] [InlineData(" ")] public void Rejects_every_escape_from_root(string input) { Assert.Null(SafePath.ResolveWithin(_root, input)); } [Theory] [InlineData("report.pdf")] [InlineData("2024/q1/report.pdf")] [InlineData("./report.pdf")] [InlineData("sub/../report.pdf")] // ../ hợp lệ: vẫn nằm trong gốc public void Accepts_paths_that_stay_inside(string input) { var resolved = SafePath.ResolveWithin(_root, input); Assert.NotNull(resolved); Assert.StartsWith(_root, resolved); } /// <summary> /// Thư mục anh em có tên là tiền tố của gốc. Test này đỏ nếu ai đó bỏ dấu phân /// tách ở cuối canonicalRoot — một lỗi không có triệu chứng nào khác. /// </summary> [Fact] public void Sibling_directory_sharing_a_prefix_is_rejected() { var sibling = _root + "-secret"; Directory.CreateDirectory(sibling); File.WriteAllText(Path.Combine(sibling, "keys.txt"), "secret"); Assert.Null(SafePath.ResolveWithin(_root, "../" + Path.GetFileName(sibling) + "/keys.txt")); } /// <summary> /// Symlink. Tên file hoàn toàn hợp lệ, không có một ký tự ../ nào, nên MỌI phép /// kiểm chuỗi đều cho nó đi qua. Chỉ ResolveLinkTarget bắt được. /// </summary> [SkippableFact] public void Symlink_pointing_outside_root_is_rejected() { Skip.If(OperatingSystem.IsWindows(), "cần quyền tạo symlink"); var link = Path.Combine(_root, "innocent.txt"); File.CreateSymbolicLink(link, "/etc/passwd"); Assert.Null(SafePath.ResolveWithin(_root, "innocent.txt")); } /// <summary>Zip Slip: khẳng định KHÔNG có file nào rơi ra ngoài thư mục đích.</summary> [Fact] public void ZipSlip_entry_is_rejected_before_writing() { var zip = Path.Combine(_root, "evil.zip"); using (var archive = ZipFile.Open(zip, ZipArchiveMode.Create)) archive.CreateEntry("../../../../tmp/pwned.txt"); var dest = Directory.CreateTempSubdirectory().FullName; Assert.Throws<ApplicationGeneralException>(() => new Extractor().Extract(zip, dest)); Assert.False(File.Exists("/tmp/pwned.txt")); } public void Dispose() => Directory.Delete(_root, recursive: true);}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
input.Replace("../", "") | ....// co lại thành ../ chính vì bạn xoá chuỗi ở giữa. Bản vá tự tạo ra payload |
if (input.Contains("..")) | %2e%2e%2f chưa được giải mã lúc bạn kiểm; và nó chặn cả file tên report..2024.pdf |
Chỉ chặn /, quên \ | Trên Windows ..\ là traversal đầy đủ. Code chạy Linux nhưng CI chạy Windows là chỗ nó lộ ra |
Canonical hoá rồi StartsWith(root) không có dấu phân tách | /srv/files-secret/x khớp tiền tố /srv/files. Phải là root + Path.DirectorySeparatorChar |
| Canonical hoá nhưng bỏ qua symlink | GetFullPath không đi theo symlink. Một link trong thư mục gốc trỏ ra ngoài đi qua mọi phép kiểm chuỗi |
| Kiểm ở tầng web server / WAF | Apache 2.4.50 chính là bản vá kiểu đó, và nó thua trong một vòng |
| Giải nén archive rồi mới kiểm | File đã ghi ra đĩa trước khi bạn kiểm. Phải kiểm từng entry trước khi ghi |
Sai lầm về phạm vi: chỉ nghĩ tới dạng ĐỌC. Path traversal ở dạng GHI (upload, giải nén, ghi log theo tên do người dùng đặt) là ghi đè file tuỳ ý — ghi đè authorized_keys, một .so được nạp, hay một cron file là RCE. Dạng ghi nguy hiểm hơn hẳn dạng đọc nhưng ít được test hơn hẳn.
Sai lầm về vị trí: tìm lỗi trong controller. Nó thường ở trong hàm giải nén, trong static file middleware cấu hình sai, hoặc trong một thư viện template đọc file theo tên.
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…