Safeguard
Application Security

Path traversal, decoded: canonicalization patterns across languages

CVE-2021-41773 turned a broken path-normalization routine in Apache 2.4.49 into remote code execution. Here's how canonicalization stops the whole bug class.

Safeguard Research Team
Research
6 min read

CWE-22, "Improper Limitation of a Pathname to a Restricted Directory," is one of the oldest bug classes in web software, and it is still landing in production. The clearest recent proof is CVE-2021-41773: Apache HTTP Server 2.4.49 shipped a rewritten ap_normalize_path() that failed to reject URL-encoded ../ sequences, letting attackers read arbitrary files outside the document root and, on servers with mod_cgi enabled, execute commands. The fix in 2.4.50 was itself incomplete — a double-URL-encoded payload (%%32%65%%32%65%2f-style variants) still slipped past the check, tracked separately as CVE-2021-42013. Both were exploited in the wild within days of disclosure, and Apache didn't close the gap fully until 2.4.51, three releases in about three weeks. The root cause in both cases was the same ordering bug: the server validated the path string before fully decoding it, so an encoded ../ looked harmless at check time and dangerous at file-open time. That single ordering mistake — validate-then-decode instead of decode-then-validate — recurs across languages, frameworks, and file formats, from Node.js Express handlers to Java archive extraction to Go CGI wrappers. This post walks through how the pattern shows up differently per platform, and the canonicalization techniques that close it regardless of language.

What makes path traversal different from a typical injection bug?

Path traversal is different because the "injection" isn't a query language — it's the filesystem's own path-resolution semantics. A SQL injection payload only does damage if the database parses it as code; a path-traversal payload does damage because ../ is a legitimate, universally-supported instruction to walk up a directory, understood identically by the OS whether or not the developer intended to allow it. That means denylisting the string ../ is never sufficient: an attacker can supply it URL-encoded (%2e%2e%2f), double-encoded (%252e%252e%252f), with Windows-style backslash separators (.. plus a trailing backslash), with overlong UTF-8 sequences, or padded with extra . and / characters that some normalizers collapse and others don't. CWE-22's own definition (per MITRE's CWE database) frames this precisely: the weakness is failing to neutralize special elements that resolve to a path outside the restricted directory, not failing to match a specific substring. Any defense built around pattern-matching the raw input string is fighting an open-ended encoding problem instead of a fixed one.

How did CVE-2021-41773 actually happen, and why is it a good teaching case?

It's a good teaching case because the vulnerable code was trying to do the right thing — normalize the path before checking it — and still failed. Apache 2.4.49's ap_normalize_path() was supposed to collapse ../ segments and reject any result that climbed above the base directory, but a logic error in the rewritten function let a URL-encoded traversal sequence pass through the check unmodified, since the decode step and the traversal check happened in the wrong order relative to each other. The request GET /cgi-bin/.%2e/%2e%2e/%2e%2e/etc/passwd reached the filesystem still partially encoded, decoded correctly by the OS file-open call, and resolved outside the web root. A public proof-of-concept for remote code execution appeared within a day of Apache's advisory, and active exploitation in the wild followed almost immediately. The lesson for defenders: canonicalization has to be a single, complete, idempotent operation — fully decode, fully resolve ./.., then compare — never a sequence of partial passes where each step assumes the previous one already normalized the input.

How does the pattern differ between raw string traversal and symlink-based traversal?

String-based traversal and symlink-based traversal require different defenses because one is a parsing problem and the other is a filesystem-state problem. A ../-style attack manipulates the path string an application builds before ever touching disk; canonicalizing that string (resolving . and .. segments arithmetically) is enough to catch it. Symlink traversal is different: an attacker who can plant or influence a symlink inside an otherwise-trusted directory can make a perfectly clean, traversal-free path resolve somewhere else entirely at open time — the string never contains ../ at all. Defending against this requires resolving the actual filesystem target, not just the string — calling realpath() in C/POSIX contexts, os.path.realpath() in Python, or Path.toRealPath() in Java, all of which follow symlinks to their final destination before you do the base-directory comparison. A validator that canonicalizes the string but skips symlink resolution will pass a malicious symlink straight through.

What is zip-slip, and why does it belong in the same bug family?

Zip-slip belongs in the same family because it's path traversal delivered through an archive entry name instead of a URL parameter. The pattern, publicized by Snyk's security research team in 2018 across dozens of Java, JavaScript, .NET, Go, and Python libraries, works like this: an archive extraction routine reads each entry's filename from the zip/tar metadata and joins it to the output directory without checking whether the joined result stays inside that directory. An entry named ../../etc/cron.d/malicious extracts exactly where its name says, overwriting or planting files anywhere the extracting process has write access. It's the same CWE-22 root cause — untrusted input concatenated into a path with no canonicalization — just with the untrusted input coming from inside a file format instead of an HTTP request. Any code that calls extractall(), Files.copy() in a loop over entries, or a manual zip-reader loop needs the identical defense as a URL handler: resolve the joined path, then check it's still a descendant of the target directory.

What actually stops this bug class, regardless of language?

Four techniques stop this bug class, and none of them involve guessing at encodings. First, canonicalize before you validate: fully decode the input and fully resolve the path (collapsing ./.. and following symlinks) before any comparison happens — os.path.realpath() in Python, Path.resolve() in Java/Node's path module, or filepath.Clean() paired with an explicit base check in Go. Second, compare the canonical result against the canonical base directory using a strict prefix or "is-descendant" check, never a substring match on the raw request. Third, prefer allow-lists over denylists — look up a requested filename or ID against a known set of permitted files rather than trying to sanitize an arbitrary path — this eliminates the encoding-bypass problem entirely since there's no traversal string to smuggle. Fourth, apply the same canonicalize-then-check discipline to archive extraction, template includes, and any other code path that joins untrusted input to a filesystem location, not just the obvious upload/download endpoints.

How Safeguard helps

Safeguard's SAST engine traces untrusted input from a source — a request parameter, a CLI argument, a file read — through the call graph to dangerous sinks, and a file path built from unvalidated input is exactly one of those tracked sink categories. When a source→sink dataflow trace shows request data reaching a file-open or archive-extraction call without passing through a canonicalization or allow-list check, the finding carries the full hop-by-hop trace and a CWE-22 mapping, so a developer sees precisely which line needs a realpath() check rather than a bare "path traversal risk" warning. Because that trace is reachability-verified rather than pattern-matched, it distinguishes a genuinely exploitable ../-style sink from a hundred harmless calls to the same file-handling library elsewhere in the codebase — the difference between a ticket someone can act on and noise someone learns to ignore.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.