August 13, 2026
Bypassing Keyword and Character Filters to Achieve Reflected XSS
A Bug Bounty Writeup | HackerOne Report
By Bahmed Boumriga
4 min read
Introduction
During testing on a public HackerOne program, I identified a reflected Cross-Site Scripting (XSS) vulnerability in a search endpoint. What made this finding interesting was not the initial discovery, which was straightforward, but the filtering mechanism the application used to block exploitation. The application blocked the word "alert" and the plus (+) character, both of which are common building blocks of XSS payloads. This writeup walks through the full process, from discovering the reflection to bypassing the filter and confirming execution.
Target: https://redacted.com/query.htm?q=x Vulnerability class: Reflected Cross-Site Scripting (XSS) Injection context: HTML attribute value
Step 1: Discovering the Reflection
The first step was to identify whether user input was reflected in the response, and whether any characters were being encoded or filtered. I injected a simple marker string, "insertTEST, into the q parameter to observe two things: whether the value appeared in the response, and whether the double quote character survived unescaped.
The response showed the value reflected directly inside an input tag's value attribute, and the double quote character was not encoded. This meant I could potentially break out of the attribute context.
Step 2: Attempting Standard Payloads
With confirmation that the double quote broke out of the attribute, I moved on to payloads suited for an attribute-context injection, such as:
"><svg onload=confirm(document.cookie)>
"tabindex="1" onfocus="alert(document.cookie)" id="a1"#a1
" onfocus="alert(document.cookie)" autofocus=""><svg onload=confirm(document.cookie)>
"tabindex="1" onfocus="alert(document.cookie)" id="a1"#a1
" onfocus="alert(document.cookie)" autofocus="A more complete reference for context-specific payloads is available in the PortSwigger XSS cheat sheet: https://portswigger.net/web-security/cross-site-scripting/cheat-sheet
These payloads were reflected in the response, but none of them executed in the browser. This indicated that some form of filtering was in place, rather than a lack of injection.
Step 3: Identifying the Keyword Filter
Reviewing the reflected response more closely revealed that the application was stripping the word alert from the payload before returning it.
To confirm this was a simple case-sensitive keyword filter rather than a more robust sanitization routine, I tried mixed-case variations such as Alert, aLeRt, and similar combinations. These variations were no longer stripped and appeared intact in the response.
However, even though the keyword now passed through unfiltered, nothing executed in the browser. This suggested a second, independent filter was also in play.
Step 4: Identifying the Character Filter
I next tested a payload that avoided the word alert entirely by constructing it dynamically through string concatenation:
"+autofocus+Onfocus="window['al'+'ert'](document.cookie)"+y=""+autofocus+Onfocus="window['al'+'ert'](document.cookie)"+y="This payload also failed to execute. Inspecting the response showed why: the plus (+) character was being stripped out entirely.
Since the plus sign was necessary for the string concatenation trick, and it was being removed server-side, the payload broke and never reached valid JavaScript syntax.
Step 5: Bypassing the Character Filter
To work around the stripped plus character, I URL-encoded it as %2b instead of sending it as a literal + in the request. Because URL-decoding happens before the filtering logic inspects the raw parameter, the encoded character passed through the filter and was decoded back into a literal + in the reflected output.
"+autofocus+Onfocus="window['al'%2b'ert'](document.cookie)"+y=""+autofocus+Onfocus="window['al'%2b'ert'](document.cookie)"+y="The response confirmed that the plus character was now present, and the concatenation expression remained intact.
Step 6: Confirming Execution
With both filters bypassed, the concatenation-based payload triggered a JavaScript popup displaying document.domain when rendered in the browser, confirming successful execution of injected script.
As a secondary, cleaner PoC, I also used String.fromCharCode() to construct the word alert dynamically, avoiding both the keyword and character filters without relying on encoded plus signs:
" Onfocus="self[String.fromCharCode(97,108,101,114,116)](document.domain)" autofocus="" Onfocus="self[String.fromCharCode(97,108,101,114,116)](document.domain)" autofocus="This payload also executed successfully.
Final Payloads
"+autofocus+Onfocus="window['al'%2b'ert'](document.cookie)"+y="
"+Onfocus="self[String.fromCharCode(97,108,101,114,116)](document.domain)"+autofocus=""+autofocus+Onfocus="window['al'%2b'ert'](document.cookie)"+y="
"+Onfocus="self[String.fromCharCode(97,108,101,114,116)](document.domain)"+autofocus="Root Cause
The application attempted to prevent XSS by blacklisting the literal string alert and the literal + character from the q parameter. Both protections were pattern-based and applied to the raw, decoded input rather than accounting for encoding variations or alternate ways of expressing the same JavaScript logic. This allowed:
- Case variation to bypass the keyword filter (
alertvsAlertvsaLeRt) - URL-encoding of the
+character to bypass the character filter String.fromCharCode()as a filter-agnostic method of constructing blocked keywords
Impact
An attacker could craft a malicious link that, once clicked by a victim, executes arbitrary JavaScript in the context of the vulnerable domain. This could be used to steal session cookies, perform actions on behalf of the victim, or redirect them to a phishing page, depending on the application's session handling and cookie attributes.
Key Takeaways
-
Invest time in the free PortSwigger Web Security Academy material and labs. They cover the underlying theory far better than any single writeup: https://portswigger.net/web-security/cross-site-scripting https://portswigger.net/web-security/all-labs#cross-site-scripting
-
Do not spend excessive manual effort repeatedly inserting values and checking reflections. Automate that repetitive step and reinvest the saved time into deeper analysis of how the WAF or application-level filter behaves.
-
Payload selection is not random. It depends entirely on the reflection context (HTML body, HTML attribute, or JavaScript block). A payload that works inside a
<script>block, such as;alert(1);//, will not work inside an HTML attribute, and vice versa. Always identify the context first. -
When a keyword or character is filtered, test URL-encoding, HTML-encoding, and Unicode escapes before assuming the input is fully sanitized. Filters that operate on raw strings rather than decoded, normalized input are frequently bypassable.