URL encoding, decoded

A percent sign and two hex digits. Conceptually the simplest encoding there is, and responsible for a remarkable share of filter bypasses.

Percent-encoding writes a byte as % followed by its two hex digits. It exists because a URL has structure — ? starts the query, & separates parameters, # starts the fragment — and any value carried inside that structure must not be able to change it. So the value's own & becomes %26, and the structure survives.

Paste an encoded URL into the decoder →

What has to be encoded, and where

SetCharactersBehaviour
Unreserved A–Z a–z 0–9 - _ . ~ Never need encoding, and should not be encoded — %41 and A are the same character, which is itself a normalisation problem.
Reserved : / ? # [ ] @ ! $ & ' ( ) * + , ; = Structural. Encode them when they are data, leave them when they are structure.
Everything else spaces, non-ASCII, control bytes Always encoded. Non-ASCII is encoded as its UTF-8 bytes: é is %C3%A9, two bytes, not one.

The + problem

In a query string or a form-encoded body, + means a space. In a path segment it means a literal plus. One string, two rules, depending on which part of the URL it sits in — which is why decoding a whole URL in a single pass is wrong, and why the decoder splits a URL into its components and decodes each one on its own terms.

Double encoding

Encode the percent sign itself and you get a second layer: . is %2e, and %2e is %252e. Now consider a request passing through two components that each decode once — a proxy and an application, or a framework and a file handler:

StageValueFilter looking for ../
As sent%252e%252e%252fno match
After the first decode%2e%2e%2fno match — and this is where the check usually runs
After the second decode../too late

The bug is not the encoding. It is having more than one decode step with the validation sitting in the middle. Decode fully, exactly once, then validate — and reject input that still contains a % after the decode you expected to be the last one. The decoder here flags a %25 for exactly this reason.

Where it turns up in findings

Encoding it correctly

Open the decoder →