← All writeups

September 2026 · Web application security · 6 min

The search box that gave up the whole database

A single unescaped parameter in a travel booking app's search page turned into five separate findings: full database exposure, remote code execution, stored credential theft, and a working exploit chain between all three. Here's what actually changes once each one gets fixed.

The vulnerability started in the most ordinary place possible: a search box. results.php took whatever you typed and dropped it straight into a SQL query. The confirmation step is almost boring, you type a single quote, the database throws an error, and you know you’re in. What’s not boring is what happens next, because a SQL injection is rarely one finding. It’s an entry point, and how far it goes depends entirely on how much patience you have.

Seven steps, standard UNION-based methodology, got from that single quote to a full customer table: confirm the error, count the columns with ORDER BY, work out which columns get reflected back on the page, then walk the schema, list the tables, list the columns, dump the data. Five accounts came back. All five had plaintext passwords.

That’s usually where a rushed test would stop. It’s also where the actually interesting question starts: what else does this one weakness touch?

The answer, in this case, was three more things. The same reflected column that leaked query results also rendered unescaped HTML, so the injection doubled as a stored path to cross-site scripting. Those plaintext passwords, combined with a wordlist built from scraping a public list of names, turned into a working credential-stuffing attack against the login form, confirmed by watching one response come back a different length than the rest. And a completely separate bug, an upload handler that never checked what it was actually being given, turned into remote code execution once I had valid login credentials to reach it.

The payload that started it

-1' UNION SELECT 1,2,3,CONCAT(customer_name,':',customer_email,':',customer_pass),5,6,7,8 FROM customers -- -

Four vulnerability classes, one entry point, none of them independent of the others once you trace the path an actual attacker would take.

Fixing each one individually is the easy version of the job. The harder version is proving the fix actually closes the specific path that worked before, not just the general category of bug. A prepared statement stops the injection because the database treats the input as data rather than syntax, no amount of quote-escaping changes that structurally. An extension whitelist plus a real MIME-type check (not the browser-supplied one, which lies for free) stops the upload bypass. Output encoding at the point of render stops the XSS regardless of what the query returns. None of these are clever. They’re just applied at the exact point the original bug lived, and then tested against the exact payload that worked before.

The interesting part was never the injection. It was how many other doors it happened to be standing next to.

What changed

01

Parameterised queries replaced string concatenation

The query structure now compiles independently of user input, so the database can't be talked into treating data as instructions.

02

Upload validation checks the file, not its name

Extension whitelist plus a server-side MIME read via finfo, with the upload directory itself barred from executing PHP as a second, independent layer.

03

Output encoding and account lockout closed the last two paths

htmlspecialchars() at every render point kills the XSS regardless of what the database returns; a five-attempt lockout plus bcrypt hashing means a dumped database no longer hands over usable logins.

9.8

CVSS at the worst point (Critical)

4

Vulnerability classes chained from one entry point

0

Original payloads still worked after the fixes were retested