SecLab
Server-sideĐầy đủ

Path traversal

A01CWE-22
01

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.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

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.

03

Cơ chế hoạt động

Cơ chế là sự chênh lệch giữa chuỗi bạn kiểmđường dẫn kernel thực sự mở.

Nguồn sơ đồ
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ếtThànhVò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ũ)
symlinkbấ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.

04

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.

HTTP
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
HTTP
# 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=…"}}
HTTP
# 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.

C#Dòng 9 là traversal dạng đọc. Dòng 20 là Zip Slip — cùng lỗi, dạng ghi, hậu quả là RCE.
[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);    }}
Pythonos.path.join có một hành vi bất ngờ riêng: đường dẫn tuyệt đối làm nó VỨT BỎ phần gốc.
@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)
05

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

06

Cách phòng chống

Lớp 1

Đừng nhận tên file. Nhận một ID

bắt buộc

Biệ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ể.

C# · Layer 1Canonical hoá → so với gốc CÓ dấu phân tách → giải symlink. Ba bước, thiếu bước nào cũng hở.
/// <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 đè    }}
Python · Layer 1pathlib.resolve() canonical hoá VÀ đi theo symlink trong một bước — is_relative_to là phép so.
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)
Lớp 1b

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ự:

  1. Path.Combine(root, userInput) rồi Path.GetFullPath(...) — bây giờ mọi ../, ..\, ....// đã co lại.
  2. So sánh kết quả với root đã canonical hoá, dùng StartsWith có dấu phân tách ở cuối root (nếu không thì /srv/files-secret khớp tiền tố với /srv/files).
  3. 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ù.

Lớp 1c

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.

Lớp 2

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

Lớp 3

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

07

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:

Shell
# 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):

Shell
# 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á:

Shell
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 0
C#Bảng cách viết ở khối 3 chính là dữ liệu của test — cách viết thứ tám chỉ cần thêm một dòng.
public 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);}
08

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 symlinkGetFullPath 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 / WAFApache 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ểmFile đã 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.

09

Nguồn tham khảo

Bậc 1CVE-2021-42013 — Apache HTTP Server path traversal and RCE · NIST · National Vulnerability Database · CVSS 3.1
Bậc 1Path.GetFullPath Method · Microsoft · .NET API reference · .NET 8
Bậc 1A01:2021 – Broken Access Control · OWASP · Top 10 · 2021
Bậc 2Path traversal · PortSwigger · Web Security Academy
Bậc 2File Upload Cheat Sheet · OWASP · Cheat Sheet Series
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để 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.

Đăng kýĐăng nhập

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…