SecLab

Path traversal

A01CWE-22
01

What it is

Path traversal is when a user-supplied filename is joined into a filesystem path without validation, so ../ walks the read or write outside the intended directory. The classic outcome is reading /etc/passwd, web.config, .env or an SSH key — and in its write form it is arbitrary file overwrite, which is code execution.

02

Why you should care

Relevance: CoreExpected: L2

Path traversal is the bug where the intuitive fix is almost always wrong, and that is more worth learning here than the bug itself.

Everyone's first reflex is "block ../". It fails because the journey from the user's string to the syscall passes through several decoding layers, and each can recreate ../ after your check:

  • the web server decodes %2e%2e%2f../
  • some frameworks decode twice: %252e%252e%252f%2e%2e%2f../
  • ..\ on Windows; ....// collapses into ../ precisely when you strip the string ../
  • a symlink inside the target directory pointing outside it

The only winning approach is to check the result rather than the string: resolve the path to its absolute canonical form, then ask "is it still under the root?". That question does not care how the ../ was spelled.

Second thing worth noting: this bug usually is not in code you wrote. It is in archive extraction — a .zip with an entry named ../../app/appsettings.json (Zip Slip) — and in misconfigured static file middleware.

03

How the attack works

The mechanism is the gap between the string you validate and the path the kernel actually opens.

Diagram source
flowchart TD    U["file=%252e%252e%252fetc%252fpasswd"] --> W[Web server<br/>first decode]    W --> D1["file=%2e%2e%2fetc%2fpasswd"]    D1 --> F[Framework<br/>second decode]    F --> D2["file=../etc/passwd"]    D2 --> C{Where do you check?}    C -->|"Check the STRING here<br/>(wrong — checked before decode 2)"| B["Path.Combine(root, file)<br/>→ /srv/files/../etc/passwd"]    C -->|"Check the RESULT<br/>(right)"| G["GetFullPath() → /etc/passwd<br/>StartsWith(root)? → NO → reject"]    B --> K["open('/etc/passwd')<br/>kernel collapses the ../"]

The key point on the left branch: Path.Combine does not collapse ../, but the kernel does. So the string you inspect (/srv/files/../etc/passwd) looks like it lives under /srv/files, while the opened file does not.

The spelling list — this table is why blocklists lose:

SpellingBecomesWhat it bypasses
../../nothing — the base case
%2e%2e%2f../checks performed before URL decoding
%252e%252e%252f../double decoding
....//../fixes of the form replace("../", "")
..\../checks that look only for / (Windows)
..%c0%af../UTF-8 overlong encoding (older servers)
a symlinkanythingevery string check, including badly done canonicalisation

Zip Slip is the same bug where nobody looks: entry.FullName inside an archive is a fully attacker-controlled string joined onto the destination directory. In its write form the outcome is RCE, not data disclosure.

Diagram description: A flow diagram showing the string file=%252e%252e%252fetc%252fpasswd passing through two decoding layers — the web server decodes once, the framework decodes again — before reaching the decision point. The wrong branch validates the string before the second decode, so Path.Combine produces /srv/files/../etc/passwd and the kernel collapses the ../ into /etc/passwd. The right branch calls GetFullPath to obtain the canonical path /etc/passwd, checks whether it starts with the root directory, and rejects it.

04

Concrete example

An attachment download endpoint keyed by name. Three requests, three spellings of one destination.

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
# After adding the replace("../","") fix — still through, because ....// collapses to ../GET /api/files/download?name=....//....//....//app/appsettings.json HTTP/1.1 HTTP/1.1 200 OK{"ConnectionStrings":{"Core":"Host=db;Password=…"}}
HTTP
# After the correct fix (canonicalise, then compare against the root)GET /api/files/download?name=....//....//app/appsettings.json HTTP/1.1 HTTP/1.1 404 Not Found{"errorCode":"ER_FILE_NOT_FOUND"}

404 rather than 400: a 400 "invalid path" confirms to the attacker that a filter exists and they are on the right track.

C#Line 9 is the read form. Line 20 is Zip Slip — same bug, write form, and the outcome is 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 has its own surprise: an absolute second argument makes it DISCARD the root.
@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

What happened in the wild

CVE-2021-41773 / CVE-2021-42013 — Apache httpd 2.4.49 and 2.4.50. 2.4.49 had a path traversal via %2e; 2.4.50 shipped to fix it, and that fix was insufficient%%32%65 still walked through, becoming CVE-2021-42013 and reaching RCE where CGI was enabled. This is the textbook case for block 2's argument: two rounds of fixing by blocking spellings, two losses. Both were mass-exploited within days.

Zip Slip (2018) — thousands of libraries across ecosystems. Snyk disclosed the same flaw in archive-extraction libraries across Java, .NET, Go, Ruby and JS. One shared cause: entry.FullName joined onto the destination without checking the result. Memorable because it shows the bug survives longest where nobody thinks they are "handling user input".

06

How to defend

Layer 1

Do not accept a filename. Accept an ID

mandatory

The best control removes the problem: the client sends a fileId (UUID), the server looks up the real path that the server itself generated at upload time. No user string enters a path, so there is no traversal. Choose this fix whenever you can — everything below is only needed when you cannot.

C# · Layer 1Canonicalise → compare against root WITH a separator → resolve symlinks. All three, or it leaks.
/// <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() canonicalises AND follows symlinks in one step — is_relative_to does the compare.
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)
Layer 1b

If you must accept a name: canonicalise then compare, never inspect the string

Three steps, in order:

  1. Path.Combine(root, userInput) then Path.GetFullPath(...) — every ../, ..\ and ....// is now collapsed.
  2. Compare the result against the canonicalised root, using StartsWith with a trailing separator on the root (otherwise /srv/files-secret prefix-matches /srv/files).
  3. After opening, re-check the real path to catch symlinks: in .NET, File.ResolveLinkTarget(path, returnFinalTarget: true).

Step 3 is the most-skipped and the only one that catches a symlink — which every string check is blind to.

Layer 1c

Allowlist the filename when its shape permits

If names always have a known shape (^[a-zA-Z0-9_-]{1,64}\.(pdf|png)$), a regex allowlist is a cheap extra check. It does NOT replace canonicalisation — it just rejects earlier.

Layer 2

Archive extraction: apply the same check per entry

This is where Zip Slip lives. For each entry: canonicalise Path.Combine(dest, entry.FullName) and compare to dest. Skip entries that are symlinks or devices. And cap total extracted size (zip bombs).

Layer 3

chroot / container / filesystem permissions

Run the service as a user that cannot read /etc/shadow, SSH keys, or its own configuration. Mount the file directory as its own volume, with readOnlyRootFilesystem: true in Kubernetes. This layer decides what a traversal gets when layer 1 has a hole.

07

Verifying the fix

1. A unit test that takes the SPELLING TABLE from block 3 as its input data. That is the point: the spelling table is the long-lived artefact, and the test is where it should live. When someone finds an eighth spelling, they add one [InlineData] line — no production code changes. See the csharp / test tab.

2. A symlink test — the check almost nobody writes. Create a symlink inside the root directory pointing at /etc/passwd, then request that file by a perfectly valid name. If this test passes and you have not called ResolveLinkTarget, your fix has a hole and the test is lying to you.

3. A Zip Slip test against the extraction function you actually use:

Shell
# Build a malicious zip, then assert no file appears outside the destinationpython3 - <<'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 still present"; exit 1; }

4. Check the process filesystem permissions (layer 3):

Shell
# From inside the container, these MUST fail:docker exec app cat /etc/shadow           && { echo "shadow is readable"; exit 1; }docker exec app touch /app/marker         && { echo "root fs is writable"; exit 1; }docker exec app id -u | grep -qv '^0$'    || { echo "running as root"; exit 1; }

5. A merge-blocking grep for Path.Combine with user input and no canonicalisation:

Shell
grep -rn "Path.Combine" --include='*.cs' src/ \  | grep -vE 'GetFullPath|SafePath\.' \  | grep -iE 'request|input|name|fileName|entry\.' \  && { echo "Path.Combine with un-canonicalised input — blocked"; exit 1; }exit 0
C#Block 3's spelling table is the test data — an eighth spelling costs one line.
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

Common mistakes

The "fix"Why it is wrong
input.Replace("../", "")....// collapses into ../ because you removed the middle. The fix manufactures the payload
if (input.Contains(".."))%2e%2e%2f is not decoded yet when you check; and it also blocks a legitimate report..2024.pdf
Block / but forget \On Windows ..\ is full traversal. Code that runs on Linux but builds on Windows is where this surfaces
Canonicalise then StartsWith(root) with no separator/srv/files-secret/x prefix-matches /srv/files. It must be root + Path.DirectorySeparatorChar
Canonicalise but ignore symlinksGetFullPath does not follow symlinks. A link inside the root pointing outside passes every string check
Filter at the web server or WAFApache 2.4.50 was exactly that kind of fix, and it lost in one round
Extract the archive, then validateThe file is already on disk before you look. You must validate each entry before writing

The scoping mistake: thinking only about the READ form. Path traversal in its WRITE form (upload, extraction, writing logs to a user-chosen name) is arbitrary file overwrite — overwriting authorized_keys, a loaded .so, or a cron file is RCE. The write form is far more dangerous and far less tested.

The location mistake: hunting in the controller. It usually lives in an extraction routine, in misconfigured static file middleware, or in a template library that loads files by name.

09

References

Tier 1CVE-2021-42013 — Apache HTTP Server path traversal and RCE · NIST · National Vulnerability Database · CVSS 3.1
Tier 1Path.GetFullPath Method · Microsoft · .NET API reference · .NET 8
Tier 1A01:2021 – Broken Access Control · OWASP · Top 10 · 2021
Tier 2Path traversal · PortSwigger · Web Security Academy
Tier 2File Upload Cheat Sheet · OWASP · Cheat Sheet Series
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…