A sanitizer is the piece of code that cleans user input before it reaches a database, an HTML page, or a shell. When it works, an attacker cannot smuggle a payload through. A sanitizer bypass is what happens when that cleaning looks correct in review but still lets something dangerous slip past at run time. This post walks through the real reasons sanitizers fail, with small safe examples, and shows how to close each gap.
Why a sanitizer bypass happens even when the code looks right
Most bypasses are not exotic. They come from a handful of mistakes that read as fine in a pull request. The value gets escaped, so the reviewer nods and moves on. The problem is that escaping is only correct for one destination, and the value often ends up somewhere else. Below are the five causes that show up again and again.
1. Wrong context: escaping for the body, landing in an attribute
Output encoding depends on where the value lands. Encoding built for HTML body text does not protect an attribute, a script block, or a URL. Take a function that escapes <, >, and &. That is right for body text. Now watch it land in an attribute:
value = escape_html_body(user_input)
html = "<img src=x title=" + value + ">"
The attribute has no quotes, so the attacker sends x onerror=alert(1). There is no < or > to escape, so the body encoder passes it straight through, and the browser reads a new attribute. The same value dropped into an unquoted attribute, a javascript: URL, or an inline <script> each needs a different encoding. If you want the deeper mechanics of how this becomes script execution, read what XSS is and how it works.
2. Order: clean the value, then dirty it again
A sanitizer only protects the exact string it returned. If the code changes that string afterward, the guarantee is gone. The common shape is sanitize, then concatenate or decode:
safe = sanitize_path(user_file) # strips ../
full = base_dir + "/" + safe
full = url_decode(full) # brings ../ back
The decode step runs after the cleaning, so %2e%2e%2f turns back into ../ once the guard is no longer looking. The value was clean for one moment and dirty by the time it reached the file system. Sanitize as late as possible, right at the point of use, and never transform the result afterward.
3. Blocklist gaps: filtering known bad instead of allowing known good
A blocklist tries to name every bad string. Attackers only need one name you forgot. Suppose a filter strips the word script to stop injection:
clean = user_input.replace("script", "")
The attacker sends scrscriptipt. The filter removes the inner script, and the two halves join into script again. One pass, one gap, full bypass. An allowlist flips the logic. Instead of listing what is forbidden, you define exactly what is allowed and reject the rest.
if not re.fullmatch(r"[a-z0-9_]{1,32}", username):
reject()
Now there is nothing to forget. Anything outside the allowed set is gone by definition, and a new trick does not open a new hole.
4. Double encoding and partial decoding
Layers of decoding are a classic way to walk a payload past a check. The value is encoded twice. The sanitizer decodes once, sees something harmless, and passes it on. A later layer decodes again and reveals the real payload.
input: %253Cscript%253E
sanitizer: decodes once -> %3Cscript%3E (looks safe, no < yet)
framework: decodes again -> <script> (payload restored)
The check ran on the wrong form of the data. Decode fully and exactly once, to a single known form, before you validate. Then validate that final form and never decode again downstream. Deciding where in the pipeline this belongs is a sources and sinks question, covered in sources and sinks explained.
5. A custom sanitizer a scanner does not recognize
Teams often write their own cleaning function. A static analysis tool models a set of known sanitizers. Yours is not in that set, and two opposite errors follow. The tool reports a false negative when your custom function is actually broken but the tool assumes any function named clean() made the value safe, so it stays silent on a real bypass. It reports a false positive when your function is genuinely correct but the tool has never heard of it, so it flags a safe value as tainted. Both waste time in different directions. We go deeper on the noisy side of this in why SAST has false positives.
A sanitizer does not make a value safe in general. It makes a value safe for one destination, at one moment, in one exact form. Break any of those three and the payload comes back.
A worked example: one input, three sinks
Picture an invented notes app called Acme Notes. A user sets a display name, and that name is shown in three places: the page body, a link attribute, and a search query. The developer writes one sanitize() that escapes HTML angle brackets and reuses it everywhere.
- Body: the name renders as text. The angle bracket escape is correct here, so this sink is safe.
- Attribute: the name lands in
href="/u/NAME". A value of" onmouseover=steal()needs attribute encoding, which this sanitizer never applied, so it breaks out. - Query: the name is concatenated into SQL. Angle brackets mean nothing to a database, so
' OR '1'='1passes untouched into the query.
One function, three destinations, two bypasses. The fix is not a smarter sanitize(). It is choosing the right defense at each sink: attribute encoding for the link, and a parameterized query for the database so the name is bound as data and never parsed as SQL.
How to prevent a sanitizer bypass
The theme across all five causes is the same. Match the defense to the destination, and apply it at the last possible moment. Concrete rules:
- Encode at the sink, in the right context. Pick body, attribute, script, or URL encoding based on where the value actually lands, not where you assume it lands.
- Prefer allowlists. Define what is valid and reject everything else, instead of naming bad strings you have to keep updating.
- Use parameterized queries. For databases, bind values as parameters so input is data, never code. This removes the whole class of SQL bypass.
- Validate on the final decoded form. Decode once to a known form, validate that, and do not transform the result afterward.
- Validate at the right layer. Clean for the destination at the destination, not in a generic pass three functions earlier where the context is unknown.
For more patterns in this space, browse the injection and input category.
A sanitizer bypass is rarely a missing filter. It is usually a filter aimed at the wrong context, run at the wrong time, or trusted by a tool that never modeled it. Finding these means understanding what an app assumes about its own inputs, which is exactly the kind of assumption testing UnboundCompute is built to do. Learn how we think about it on our about page.
Frequently asked questions
What is a sanitizer bypass?
A sanitizer bypass is when input cleaning that looks correct in review still lets a dangerous value through at run time. The usual causes are escaping for the wrong context, cleaning the value and then changing it again, blocklist gaps, double encoding, and custom filters a scanner does not recognize.
Why does escaping for HTML not stop every injection?
Escaping is correct only for the destination it was built for. HTML body encoding protects text between tags, but the same value in an unquoted attribute, a script block, a URL, or a SQL query needs a different defense. Reusing one encoder everywhere leaves the other sinks open.
Is an allowlist better than a blocklist?
Usually yes. A blocklist names bad strings and fails the moment an attacker finds a variant you did not list, such as nesting so a removed word rejoins. An allowlist defines exactly what is valid and rejects everything else, so there is nothing to forget and new tricks do not open new holes.
How do you prevent a sanitizer bypass?
Encode for the exact context at the sink, use allowlists for input validation, use parameterized queries so database input is never parsed as code, decode once to a known form before validating, and never transform a value after it has been cleaned.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
