A Five-Second Sort: Injection Behind a Blocking WAF
A supplier portal runs Java/Spring on PostgreSQL 15 behind a cloud WAF in blocking mode. The catalog search is /catalog?q=widget&sort=price_asc. A single quote in q returns the WAF's 403 block page, as do UNION and a plain pg_sleep(5). Your test account has a low-privilege database role. In the sort parameter, sort=price_asc);SELECT 1-- returns a 500, and sort=price_asc AND 1=(SELECT 1 FROM pg_sleep(5)) returns a normal 200 page after 5.3 seconds. The same payload with pg_sleep(0) returns in 0.4 seconds, and two repeats of the five-second test return in 5.2 and 5.4 seconds. Your testing is tripping SOC alerts, and the client's contract requires you to notify their named contact within one hour of any SOC-triggering activity. You have not done so. Scope permits WAF-evasion testing.
1. Is the timing evidence sufficient to call this SQL injection? What controls would separate database execution from a WAF delay?
Read the worked answer
What the current data supports. The pg_sleep(0) control at 0.4 seconds rules out a fixed added delay from the WAF, and the repeat results at 5.2 and 5.4 seconds rule out a one-off timeout. But timing is the weakest form of injection evidence, and two alternative explanations survive: the WAF may inspect the literal string pg_sleep and add a delay, or the application may be timing out on a malformed ORDER BY clause in a way that correlates with payload length.
Three controls that settle it.
- Monotonicity. Send
pg_sleep(0),pg_sleep(2)andpg_sleep(5), three times each, and compare medians. A database sleep scales with its argument; a WAF delay does not. - Broken-payload control. Send
sort=pg_sleep(5)xyz- syntactically invalid SQL containing the same token. If it is still slow, the WAF is the source; if it errors or returns fast, the delay comes from execution. - Conditional error. Send a payload that raises a division-by-zero only when a condition holds, for example
sort=price_asc AND 1/(CASE WHEN (SELECT 1)=1 THEN 0 ELSE 1 END). A 500 versus a 200 is a boolean oracle and is far less ambiguous than a stopwatch.
Confirm reach. sort=price_desc changes the result ordering, which shows the parameter lands in the ORDER BY clause. That explains why no quote is needed and why the WAF's quote rules never fire.
Quantify. Record baseline latency first - 20 control requests, median around 0.4 seconds, p95 around 0.6 - so the 5.3-second result is reported as a measured deviation rather than an impression.
2. You tripped SOC alerts without notifying. What do you do in the next hour, and how does this affect the engagement?
Read the worked answer
In the next hour. Stop the phase, call the named contact on the 24/7 number, and follow up in writing with the source IP, timestamps, request counts, payloads used and what you were testing. Ask three questions: should I pause, do you want the source IP allowlisted, and has the SOC already opened an incident? Capture your evidence first - if the SOC blocks your IP mid-test, the session dies and unsaved proof goes with it.
Why the omission matters, beyond the contract. The one-hour clause is not paperwork. An unannounced SQL-injection-shaped payload from an unknown IP can push the client into their incident-response plan: rotating credentials, isolating the application, or starting log management procedures mid-engagement. That costs them money and can destroy the evidence you need. It also makes you indistinguishable from a real attacker to their monitoring, which may cause them to draw wrong conclusions in a report they write about the same window.
Effect on the engagement. I would record the breach of the rules of engagement in the engagement log, tell the client plainly that it was my process failure and not a client-side issue, and change the procedure: pre-notify before any WAF-evasion phase and agree a test source-IP set in advance. This belongs in the engagement record and in a short process note, not buried or omitted - the client needs to know their escalation path worked, which is a genuine positive finding.
One technical consequence. Confirm whether the SOC response changed application behaviour - new block rules, IP bans, tightened rate limits. Any latency measurement taken after that point must be re-baselined, or the report will contain timings from two different systems.
3. The client says the WAF blocks injection, so the risk is accepted. How do you present the finding, and what is the actual fix?
Read the worked answer
How to present it. The vendor's claim is that injection is blocked; the evidence is that injection is reachable through a parameter that never trips the signature set. The ORDER BY context accepts an identifier expression, so no quote, no UNION and no obvious keyword is required, and the pg_sleep rule is evidently not applied to this parameter or is normalised away before matching. Say exactly that, with payloads and timing data. Avoid 'the WAF is useless' - write instead that the WAF is not a compensating control for this finding, because the application layer is exploitable with traffic the WAF does not inspect.
Why risk acceptance is the wrong answer here. A compensating control that demonstrably fails for the payload class in question cannot be accepted as mitigation. Accepting it would leave the client believing the residual risk is low when the tested path is exploitable.
The actual fix. Parameterisation does not solve ORDER BY: bind variables cannot stand in for column names or ASC/DESC. Map user input to a fixed allowlist - price_asc to price ASC, price_desc to price DESC, name_asc to name ASC - defaulting to price_asc and rejecting anything else with a 400. Enumerated selection is the only structurally safe option in that clause.
Secondary work. Fix the 500 on a malformed sort so errors stop leaking stack traces, and review every other parameter that feeds ORDER BY, LIMIT or column selection. Those are the parameters that most often escape both parameterisation and WAF coverage, so they deserve a targeted code review rather than a scanner pass.
Retest. Submit hostile sort values and assert a 400 with no database error, then re-run the sleep-monotonicity control to confirm execution is gone rather than merely masked.