September 7, 2026
The Character Probe: Building a Filter Fingerprint in Ten Requests
Hello, I am Nitin.

By Nitin yadav
7 min read
Day seven, and the last post of week one.
The six days before this were about where your input lands. Today is about what survives the journey. Together they give you the two facts you need before writing a single real payload: the context, and the character budget.
Here is the failure mode this post fixes. Someone finds a reflection, tries about fifteen payloads from a cheat sheet, gets nothing, and concludes the parameter is safe. What they actually did was sample fifteen arbitrary points in a space they never mapped. They do not know whether the angle bracket was blocked or the tag name was. They do not know whether the app sanitised or a WAF rejected. They cannot say which of the fifteen failures were the same failure.
A filter you have fingerprinted is a filter you can defeat deliberately. A filter you have only guessed at is a coin flip you keep losing.
This takes about ten requests.
The baseline probe
Start with one request containing your marker and the full character set:
nitn4041x'"<>`(){}[];:/\=+-&#%$|,.*!?~^nitn4041x'"<>`(){}[];:/\=+-&#%$|,.*!?~^URL-encode it correctly for transport. Then read the raw response at every reflection point and sort each character into one of four buckets.
Raw. Came back exactly as sent. Usable.
HTML-encoded. Came back as an entity. The app is doing HTML output encoding. Note which encoder โ a full one handles the ampersand, both quotes, and both angle brackets; a partial one misses some, and the ones it misses are your way in.
Backslash-escaped. You are in a JavaScript string context and the app is escaping for it. Immediately go test whether the backslash itself is escaped.
Stripped. Gone entirely. This is the most interesting bucket, because stripping is usually a blocklist, and blocklists have gaps.
Write the result down as a table. One row per character, one column per reflection point. This is your character budget for the rest of the engagement on that endpoint.
The block-versus-sanitise question
Before anything else, settle this one, because it changes your entire approach.
Did you get a 403, a challenge page, a connection reset, or a generic error? Something in front of the application rejected your request. That is a WAF or a CDN rule. The application may be perfectly vulnerable behind it. Your problem is transport, and day 19 is your post.
Did you get a normal 200 with your characters missing or encoded? The application received your input and processed it. Your problem is the application's own filter or encoder.
Did you get a 400 or a validation error message? Input validation, usually a stricter allow-list, sometimes with a helpfully specific error telling you exactly what it wants.
These three need completely different follow-up, and conflating them wastes hours. Check the status code and the response headers, not just the body. A WAF block page and an app error page can look similar in a browser and look nothing alike in Burp.
One practical tell: send a payload that is obviously malicious but syntactically irrelevant to the endpoint โ something a WAF signature would hate but the app would just store as text. If that gets a 403 while your marker gets a 200, you have confirmed a WAF sits in front.
Probe two: single characters in isolation
The baseline sends everything at once, which is efficient but ambiguous. If the whole request got blocked, you learned nothing about individual characters.
So the second pass sends them one at a time, or in small groups. In Burp Intruder or with a short script:
for c in "'" '"' "<" ">" "(" ")" ";" "{" "}" "\$" "\`"; do
printf '%s -> ' "$c"
curl -s -o /dev/null -w "%{http_code}\n" \
"<https://target.com/search?q=nitn4041x$>(printf '%s' "$c" | jq -sRr @uri)"
donefor c in "'" '"' "<" ">" "(" ")" ";" "{" "}" "\$" "\`"; do
printf '%s -> ' "$c"
curl -s -o /dev/null -w "%{http_code}\n" \
"<https://target.com/search?q=nitn4041x$>(printf '%s' "$c" | jq -sRr @uri)"
doneNow you know which specific character trips the block, rather than knowing the whole string did.
This matters more than it sounds. A rule that blocks the angle bracket is a very different situation from a rule that blocks the angle bracket only when followed by a letter, which is different again from a rule that blocks specific tag names. The next probe tells them apart.
Probe three: pairs and sequences
Single characters passing does not mean combinations pass. Test escalating structures:
<
<x
<img
<img src
<img src=x
<img src=x onerror=1
onerror
onerror=
alert
alert(1)
javascript:<
<x
<img
<img src
<img src=x
<img src=x onerror=1
onerror
onerror=
alert
alert(1)
javascript:Send each as a separate request and record the status and whether it reflects.
The pattern of failures tells you the filter's shape:
- The bare angle bracket passes but a tag name fails โ tag-name blocklist. Try uncommon or invented tag names, since custom elements still fire event handlers.
- Tag names pass but event attributes fail โ attribute blocklist. Try the rarer events from day three.
- The function name alone fails, anywhere in the input โ keyword blocklist, probably regex on the raw string. All the day-three obfuscations apply.
- Everything containing an angle bracket fails but attribute-context payloads pass โ you are fighting a tag filter and should be doing attribute breakouts anyway.
- Nothing containing a colon after certain letters passes โ scheme filter. Day five.
Ten to fifteen requests and you have the filter's actual shape rather than a vague sense that "it is filtered."
Probe four: how many decode passes
This one finds bugs that nothing else finds.
Send the same character in escalating encodings and see which arrives:
< raw
%3C once encoded
%253C twice encoded
%25253C three times
< html entity
< numeric entity
< hex entity
< padded numeric< raw
%3C once encoded
%253C twice encoded
%25253C three times
< html entity
< numeric entity
< hex entity
< padded numericWhat you are looking for is a mismatch between the number of decodes the filter performs and the number the application performs. If the filter checks the raw string, and the application decodes twice before rendering, then the doubly-encoded form sails past the filter and arrives as a live character.
This is the single most common cause of "the WAF blocks the obvious payload but the bug is definitely there." It is especially common on:
- Multi-hop redirect flows, where a value is decoded at each hop
- Endpoints behind a gateway or proxy that normalises differently from the app
- Anything where a value passes through a queue, a job, or a second service before rendering
- Frameworks that decode route parameters and then decode again in application code
Probe five: case, whitespace, and null
Quick checks that cost one request each and defeat surprisingly many filters:
- Case variation. Mixed-case tag names, attribute names, and schemes. HTML is case-insensitive; blocklists frequently are not.
- Internal whitespace. Tab, newline, carriage return, and form feed inside tag names, between attributes, and around the equals sign.
- The slash separator. Valid between attributes in place of a space.
- Null byte. Sometimes truncates a server-side filter's view of the string while the browser ignores it.
- Unicode normalisation. Some applications normalise input; if a fullwidth or lookalike character normalises into an ASCII one after the filter runs, you have a bypass.
- Overlong or invalid UTF-8. Occasionally decoded permissively by one layer and not another.
Probe six: length and truncation
Find the ceiling before you build a payload that gets cut in half.
Send markers of increasing length and see where they stop coming back intact. If the field truncates at, say, sixty characters, your entire strategy changes: you need a short payload that pulls a second stage from elsewhere rather than a self-contained one.
Also check whether truncation happens before or after filtering. A filter that runs on the full string and then truncates can sometimes be made to cut a payload in a way that removes the part it objected to.
Reading the fingerprint
Once the table is filled in, the filter usually falls into one of these shapes, and each has a standard line of attack:
Full context-correct output encoding. Everything dangerous comes back as entities, in every context. This is correct behaviour. Move on, and note the endpoint for retesting after their next release โ encoders get removed during refactors more often than you would expect.
Encoding in one context, not another. The classic. The body is encoded and the attribute is not, or the HTML is handled and the inline script is not. This is your bug, and day one told you where to look.
Blocklist on tag names. Beatable with uncommon tags and custom elements.
Blocklist on attribute names. Beatable with the rarer event handlers.
Blocklist on keywords. Beatable with obfuscation, because the blocklist matches strings and the browser evaluates code.
Allow-list sanitizer. Hardest and most interesting. You are now in week three territory: fingerprint the library, check its version, and look at mutation.
WAF in front, app unfiltered. Very common and very winnable. The app has no defence; you only need transport.
Impact ladder
The probe itself is not a finding. What it produces is:
- Nothing exploitable, documented. A completed table showing correct encoding everywhere. Genuinely useful to keep โ it makes your retest after their next deploy take five minutes.
- A known-shape filter with no working payload yet. Keep the table. When you learn a new technique, you have a ready list of candidates to retry rather than starting over.
- A confirmed encoding gap in one context. This is a bug in the making; combine with day one and write it up.
- A decode mismatch. Frequently a clean bypass of an otherwise solid defence, and worth calling out explicitly in the report because the fix is architectural, not another blocklist entry.
Conclusion โ steal this checklist
- Two facts before any real payload: the context (days one to six) and the character budget (today).
- One baseline request with the full character set, then sort every character into raw, HTML-encoded, backslash-escaped, or stripped.
- Settle block-versus-sanitise first. A 403 is transport; a 200 with stripped characters is the app. Different problems, different posts.
- Confirm a WAF by sending something signature-hostile but harmless to the app, and comparing responses.
- Test characters in isolation as well as together, or you cannot tell which one tripped the rule.
- Escalate through pairs and sequences to learn the filter's shape: tag-name, attribute, keyword, or scheme.
- Test decode depth. A mismatch between filter decodes and app decodes is the most common clean bypass there is.
- Cheap one-request checks: case variation, internal whitespace, slash separators, null bytes, unicode normalisation.
- Find the length ceiling before building the payload, and check whether truncation runs before or after filtering.
- Keep the table. It is your retest kit and your candidate list when you learn something new.
- Report the filter's shape, not just the payload. Shape is fixable; a payload invites a blocklist entry.
That closes week one. Tomorrow we move client-side: DOM XSS, sources and sinks, and tracing a payload backwards from the point of execution.
If you Love reading my blogs. Check my Youtube Channel too.