scroll it
synack-exploits-explained-blog-series-image-no-text

Turning Blind Error-Based SQL Injection into Exploitable Boolean One — Part 3: PostgreSQL

15
Jun 2026
Ozgur Alp
0% read

Summary

This post documents a blind SQL injection in a PostgreSQL ORDER BY clause where standard boolean, time-based, and UNION techniques were all unavailable. The solution: use a regex function to trigger a controlled error, isolate it inside a subquery so it only fires on the false branch of a CASE statement, and use the error/success response difference as a boolean oracle for character-by-character data extraction.

 

Welcome to the third installment of my series (check out part 1 and part 2 in case you missed them). After exploring techniques for MSSQL and MySQL, it’s time to tackle another beast: PostgreSQL.

In this scenario, we’re dealing with a fully blind injection point. Bypassing PostgreSQL’s strict query evaluation requires a deep dive into how the database engine processes conditional errors and subqueries. Here’s how I turned a seemingly dead-end blind injection into a reliable boolean oracle.

Recognizing a Blind Injection Point Through Fuzzing

The first clue was a classic indicator: a single quote () produced an error, while a double quote () returned success. It was clear an injection point existed, but the HTTP responses never contained any database data, making it fully blind.

I tried all the usual boolean chains (‘ AND ‘1’=’1, AND”OR’, etc.), but every one of them threw an error. Classic boolean chaining simply wasn’t possible here.

To understand the context, I fired up Burp Intruder and fuzzed between the quotes with values ranging from %00 to %FF. This fuzzing session revealed that mathematical and bitwise operators like <, >, and ~ executed perfectly.

For instance, this query executed successfully:

‘<(SELECT ‘1‘);–

From this behavior, I concluded that the injection was occurring inside an ORDER BY clause. This completely explained why appending standard boolean logic wasn’t working.

Identifying PostgreSQL as the Backend Database

Before crafting a payload, I needed to identify the database engine. I probed the backend using environment-specific functions:

  • SLEEP() and IF() returned errors, ruling out MySQL. 
  • WAITFOR DELAY and IIF() returned errors, ruling out MSSQL. 
  • sqlite_version() threw an error, ruling out SQLite.
  • However, version(), current_schema(), and current_setting(‘server_version’) all executed successfully (without showing data), successfully fingerprinting the backend as PostgreSQL.

Common SQL Injection Methods That Don’t Work in Blind ORDER BY Injection

Several obvious exploitation routes were immediately closed off:

  • Standard Boolean: Useless, as true and false responses looked identical.
  • OR Tricks & UNION: Not usable inside an ORDER BY clause, especially since the query was bound as <(SELECT …).
  • Time-Based: PostgreSQL’s sleep functions didn’t produce any measurable delay in this specific environment.
  • Out-of-Band (OOB): Outbound attempts did not fire from the available functions in this context.

There was only one viable path left: building an error-based blind primitive, similar to the strategies used in Part 1 and Part 2.

Creating a True/False Signal in PostgreSQL Blind SQL Injection

I started with a minimal CASE statement as a sanity check:

‘<(SELECT CASE WHEN 2<1 THEN ‘Yes‘ ELSE ‘No‘ END)–+

This returned a success, meaning the syntax was valid. However, changing the conditions (e.g., 1=1 vs 1=2) did not change the HTTP response; both behaved exactly the same. I needed a controlled error.

I attempted to force errors inside the CASE statement using division by zero (1/0) and invalid casting (CAST(‘x’ AS int)). Surprisingly, PostgreSQL returned a 400 error for both the true and false branches. The database evaluates and rejects these illegal operations before the condition can even short-circuit.

The Breakthrough: Regex Functions Work as Error Gadgets in PostgreSQL Injection

I needed an error that PostgreSQL wouldn’t catch during initial query planning. Regex functions looked promising.

If you pass a malformed regex pattern like substring(‘a’ from ‘(‘), it triggers an error.

  • When the condition was false (2=3), it returned success.
  • When the condition was true (2=2), it returned an error.

This was the lever I needed! However, when I replaced the static numbers with a dynamic condition (like checking the user or version()), both outcomes errored out again. When the left side uses a dynamic function, PostgreSQL evaluates both branches of the CASE statement, causing the regex error to fire regardless of the condition.

To fix this, I had to isolate the error. I wrapped the faulty regex call inside a subquery so that it would strictly only run if the specific branch was executed.

‘<(SELECT CASE WHEN substring((select ‘usd’),1,1)=’u’

THEN ‘ok’

ELSE substring(‘a’ from (select ‘(‘ from “user” limit 1)) END)–

The true branch returns ‘ok’, resulting in success.

‘<(SELECT CASE WHEN substring((select ‘usd’),1,1)=’b’

THEN ‘ok’ 

ELSE substring(‘a’ from (select ‘(‘ from “user” limit 1)) END)– 

The false branch runs the subquery, resulting in an error.

This restored a perfectly reliable true/false signal: an error means the condition is false, and a success means it is true.

Dumping Tables, Columns, and Values Through Blind SQL Injection

With a stable primitive, I moved to the extraction phase. Using Burp Intruder, I iterated through characters to reconstruct the data.

  1. Finding Tables:

<(SELECT CASE WHEN substring((select ‘1’ from target_table limit 1),1,1)=‘1’

              THEN ‘ok’ 

              ELSE substring(‘a’ from (select ‘(‘ from “user” limit 1)) END)

  1. Finding Columns:

<(SELECT CASE WHEN substring((select column_name from information_schema.columns  

                             where table_name=‘target_table’ limit 1),1,1)=‘c’

              THEN ‘ok’

              ELSE substring(‘a’ from (select ‘(‘ from “user” limit 1)) END)

  1. Dumping the Value:

<(SELECT CASE WHEN substring((select column_name from target_table limit 1),1,1)=‘A’

              THEN ‘ok’

              ELSE substring(‘a’ from (select ‘(‘ from “user” limit 1)) END)

The responses confirmed the matches, and I successfully pulled the data via the error/no-error oracle.

PostgreSQL Blind SQL Injection: What Every Pentester Should Know

  • Many obvious error gadgets (like 1/0) fail before the condition short-circuits due to PostgreSQL’s query evaluation.
  • Regex functions are great for triggering errors, but they must be isolated within a subquery to ensure they only execute when their branch is actually reached.
  • Even in a restrictive ORDER BY blind injection, you can build a boolean channel by combining a dynamic check with a subquery-wrapped error.
  • The reliable pattern is: Dynamic comparison in CASE WHEN -> Erroring expression strictly in the ELSE branch -> Wrap the error in a subquery 
  • For character extraction, always use the nested SELECT form: substring((select …), pos, 1)

Thanks for reading. Be sure to follow Synack and the Synack Red Team on LinkedIn for upcoming Exploits Explained posts in this series.

Frequently Asked Questions

What is blind SQL injection? Blind SQL injection is a type of injection vulnerability where the database does not return data directly in the HTTP response. Instead, an attacker must infer information indirectly, either by observing changes in the application’s behavior (boolean-based) or by measuring response time (time-based). As this blog demonstrates, when both of those channels are unavailable, a third option exists: error-based inference, where a controlled database error acts as the true/false signal.

Why doesn’t standard boolean SQL injection work inside an ORDER BY clause? ORDER BY clauses in SQL don’t process boolean logic the same way WHERE clauses do. Standard boolean chains like AND ‘1’=’1 or OR 1=1 are syntactically invalid in that context, which is why every attempt thrown at this injection point returned an error rather than a meaningful difference in response. Identifying this constraint early, by fuzzing the full byte range and noting which operators worked, was what made it possible to design a payload that fit the actual execution context.

Why doesn’t division by zero work as an error trigger in PostgreSQL CASE statements? PostgreSQL validates the entire query during its planning phase before evaluation. When a CASE statement contains an illegal operation like 1/0 or CAST(‘x’ AS int) in any branch, the database rejects it immediately, regardless of which branch would actually execute. That means the error fires on both the true and false conditions, destroying any meaningful signal. The fix, as described in this blog, is to wrap the error-triggering expression inside a subquery. PostgreSQL only evaluates subqueries at execution time and only when the branch containing them is actually reached, which restores the true/false distinction.

Could an automated scanner have found this vulnerability? No, and this case is a good illustration of why not. Automated scanners are designed to inject payloads and pattern-match responses. They are not built to recognize that unusual operators like < and ~ succeed while standard boolean chains fail, or to conclude from that pattern that the injection point is inside an ORDER BY clause. They cannot reason that 1/0 fails to signal in PostgreSQL due to query planning, experiment with regex error gadgets, or engineer a subquery wrapper to isolate execution. Finding and exploiting this vulnerability required understanding how PostgreSQL actually processes queries, which is inherently a human skill. Check out AI Can Find More Vulnerabilities. Humans Still Decide What Matters. and Human-in-the-Loop: Why Human Validation Is the Trust Layer AI Still Needs to learn more.

About the Author

Ozgur Alp is an offensive security specialist with over a decade of experience in penetration testing, consulting, and security training, holding both OSCP and OSWE certifications. He currently ranks in the top three all-time on the Synack Red Team, with more than 2,000 accepted vulnerabilities across 500+ unique client engagements, including 250+ critical findings at CVSS 9.0 or above. As a LEGEND Hall of Fame inductee, Ozgur has fulfilled all five of Synack’s exclusive researcher criteria and has earned TITAN status for four consecutive years. A member of the Synack Circle of Trust, he advises on platform direction and researcher experience, has helped 20+ new researchers join through the Envoy program, and organized the first official global SRT hangout. Beyond active research, Ozgur shares expertise through technical writing, university-level instruction, public speaking at events including Black Hat, and on-site consulting with organizations worldwide.