Path TraversalEncoding Bypass

Bypassing Path-Traversal Filters: Encoding, Double-Encoding and Null Bytes

A filter that decodes input and then blocks ../ is defeated by the first encoding it didn't anticipate. Here is how percent-encoding, double-encoding, overlong UTF-8, backslashes, nested sequences and null bytes walk straight past a blocklist, and why canonicalize-then-verify is the only fix that holds.

SelfSec Team5 min read
Part 2 of 3from thePath Traversalseries
%252e%252e%252fetc/passwd
On this page

Once a team learns about path traversal, the first instinct is usually to add a filter: scan the incoming filename for ../ and reject it. It feels like a fix, and against the textbook payload it even works. But a blocklist guards against the spelling of an attack, not its meaning — and ../ can be spelled in a dozen ways that a naive filter never sees, because each one only resolves back to dot-dot-slash after the check has already passed.

This post walks through the encodings that defeat blocklist filters one by one, explains why "decode then blocklist" is the wrong order at the root, and shows the canonicalize-then-verify pattern that closes every variant at once.

How it happens

The mistake is structural, not careless. A developer adds a guard that decodes the input, looks for the dangerous substring, and serves the file if it is absent:

$file = urldecode($_GET['file']);

if (strpos($file, '../') !== false) {
    http_response_code(400);
    exit;
}

readfile("/var/www/uploads/" . $file);

This rejects ?file=../../etc/passwd. But the check runs once, against one decoding, and then the path is handed to readfile, which does its own resolution. Anywhere the real filesystem sees a ../ that the filter did not, the guard is bypassed. The filter and the file API disagree about what the string means — and the attacker only has to exploit the gap between them.

A concrete attack: spelling ../ around the filter

Each of these requests reaches /etc/passwd despite the strpos check above, for a different reason:

GET /download?file=%2e%2e%2f%2e%2e%2fetc/passwd HTTP/1.1

urldecode turns %2e%2e%2f back into ../ — but if the platform decodes after the blocklist instead of before, the literal %2e%2e%2f sails past the substring check and the file layer decodes it. Double-encoding defeats the filter even when it does decode first:

%252e%252e%252f   ->  urldecode once ->  %2e%2e%2f   ->  decoded again later ->  ../
..%c0%af          ->  overlong UTF-8 encoding of "/" that strict checks miss
..\..\            ->  backslash separator, treated as ../ on Windows
....//....//      ->  a strip-once "../" -> "" filter collapses this back into ../
%2e%2e%2fboot.ini%00.jpg  ->  null byte truncates the forced ".jpg" suffix

The double-encoded value survives because one decode pass leaves %2e%2e%2f, which contains no literal ../; a second decode downstream produces the real separator. The nested ....// is worse against a filter that "fixes" input by removing ../ — strip the inner ../ from ....// and the surrounding characters close up into a fresh ../. And on a legacy stack, %00 ends the string for the C file API while the appended .jpg extension check still saw a "safe" name.

The filter was reading the filename the attacker handed it. The filesystem read the filename after a decode the filter never performed — so the two never saw the same string, and only one of them mattered.

What an attacker gains

A bypassed traversal filter gives exactly what an unfiltered one would — the blocklist just provided false confidence in the meantime:

  • Credential and config theft.env files, database passwords, API keys and private keys, reachable through whichever encoding slipped past.
  • Source code disclosure — reading the application's own code to mine it for further bugs.
  • System reconnaissance/etc/passwd, /proc/self/environ, or boot.ini and win.ini on Windows.
  • A false sense of safety — the most dangerous outcome, because a passing filter discourages anyone from adding the real check.

The fix: canonicalize first, then verify the resolved path

The root-cause mistake is "decode then blocklist": you can never enumerate every encoding that resolves to ../, so matching strings is a losing game. Reverse the logic — resolve the path to its single true location first, then confirm that location sits inside an allowlisted base directory. There is no encoding to outsmart, because canonicalization collapses every variant down to one real path before you check it:

$base = realpath("/var/www/uploads");
$requested = realpath($base . "/" . rawurldecode($_GET['file']));

if ($requested === false || !str_starts_with($requested, $base . DIRECTORY_SEPARATOR)) {
    http_response_code(404);
    exit;
}

readfile($requested);

realpath() fully decodes nothing on its own, so decode once up front, then let it resolve every ../, ., symlink and separator to the actual file on disk; the str_starts_with check rejects anything that escaped the base. Reinforce it with structural defences that do not depend on guessing encodings:

  • Prefer mapping an opaque ID to a known filename in a lookup table so the user never supplies a path at all.
  • Where a name is unavoidable, validate it against a strict allowlist (^[a-zA-Z0-9._-]+$, no slashes, no %) rather than a blocklist of bad sequences.
  • Use safe file-access APIs that confine reads to a directory handle, and run the service with least privilege so a successful traversal reads as little as possible.

How SelfSec finds it

SelfSec assumes the filter exists and tries to walk through it: for every parameter that feeds a file operation it submits traversal payloads in each evasion form — percent-encoded, double-encoded, overlong-UTF-8, backslash, nested strip-once and null-byte variants, for both Unix and Windows targets. It reports a finding only when the response actually contains the signature of a known system file, such as the root:x:0:0 line from /etc/passwd or the [fonts] section of win.ini, so a bypass is proven rather than guessed. The whole scan runs locally on 127.0.0.1, your scan data never leaves your machine, and every finding ships with the exact encoded request that defeated the filter so you can replay it.

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about Path Traversal

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The Path Traversal series

  1. 01OverviewPath Traversal: How ../ Reads Files You Never Meant to Share3 min
  2. 02Encoding BypassBypassing Path-Traversal Filters: Encoding, Double-Encoding and Null BytesReading
  3. 03LFI / RFILFI vs RFI: From File Disclosure to Remote Code Execution6 min

Related reading