During a recent application penetration test, I came across what proved to be an interesting SQL Injection (SQLi) vulnerability. This case of SQLi was interesting for a couple reasons:
- The challenges that it presented during exploitation
- The Database Management System (DBMS) that was in use.
Below, I'll walk you through the hurdles that I encountered and how I overcame them to lead to full data extraction.
The application was using a backend Java remoting framework called DWR, and this is where the SQLi appeared. The system calls looked like this:
The injection was located in the c0-param1 parameter. On the surface, it looked like it was going to be quite easy, as it gave verbose error messages. When a single quote was added to the end of the parameter, I got the following error stating, "java.sql.SQLException: A syntax error has occurred."
The c0-param1 parameter controlled how many results were retrieved from the database. Based on this, the injection appeared to be in something like the TOP keyword for Microsoft SQL Server or LIMIT for MySQL. I decided the application was more likely running MS SQL Server than MySQL. So I tried adding a column to the results as illustrated here:
When I executed this query, I got an error message stating, "java.sql.SQLException: The column (card_no) must be in the GROUP BY list." With a little Googling, I quickly figured out that the DBMS was IBM Informix. I knew very little to nothing about Informix, so some research was in order. It didn't help that there was nearly nothing by the way of SQLi information out there for Informix, so I had to turn to the IBM documentation looking for a function I could use to leverage the error-based nature of this vulnerability. However, that appeared to be fruitless.
Since I now knew it was Informix, I had a better understanding of where the injection was occurring. It was in the FIRST clause, similar to TOP and LIMIT. The query looked something like this:
SELECT FIRST 10 col1, col2, ... FROM table...
My first thought was that I could basically control the entire query with comments, but…not so fast, my friend. The application did not like the comment string of "--", and it would throw an error whenever it saw this. Without comments or a useful error-based function, I turned to boolean-based blind injection.
Informix supports the CASE statement that is in most SQL languages, so I decided to use that. The injection looked like this:
c0-param1=string:10 (CASE WHEN SUBSTR((SELECT USER FROM SYSTABLES WHERE TABID = 1), 1, 1) = 'a' THEN 1 ELSE (SELECT 1 FROM SYSTABLES) END),
Basically, if the condition were true (i.e., the first letter of the current username is 'a') the query would return results, and if it were false, it would return an error since the subquery (SELECT 1 FROM SYSTABLES) returns more than one result. However, this led me to my second hurdle; I couldn't use the equal sign either. The application would throw an error with that too. By using alternate operators, I arrived at the following injection:
c0-param1=string:10 (CASE WHEN SUBSTR((SELECT USER FROM SYSTABLES WHERE TABID > 0 AND TABID < 2), 1, 1) LIKE 'a' THEN 1 ELSE (SELECT 1 FROM SYSTABLES) END),
Armed with the knowledge of this injection and a working proof-of-concept, I turned to my tool of choice for doing the heavy lifting of dumping the database, sqlmap (for some sqlmap tips from my colleague Christophe De La Fuente, read his post "Sqlmap Tricks for Advanced SQL Injection"). SQLmap is an awesome tool, and the author, Bernardo, is a nice guy whom I met at Defcon this year…but I digress. Alas, sqlmap does not support Informix. So, I had to write my own tool for dumping the database.
I started with dumping the schema. My script walked through the database in this fashion as follows:
Get the length of the table name (incrementing the highlighted portions):
c0-param1=string:10 (CASE WHEN CHAR_LENGTH((SELECT TABNAME FROM SYSTABLES WHERE TABID > 0 AND TABID < 2)) > 1 THEN 1 ELSE (SELECT 1 FROM SYSTABLES) END),
Get the table name:
c0-param1=string:10 (CASE WHEN SUBSTR((SELECT TABNAME FROM SYSTABLES WHERE TABID > 0 AND TABID < 2), 1, 1) LIKE 'a' THEN 1 ELSE (SELECT 1 FROM SYSTABLES) END),
Get the number of columns in the table:
c0-param1=string:10 (CASE WHEN (SELECT NCOLS FROM SYSTABLES WHERE TABID > 0 AND TABID < 2) > 1 THEN 1 ELSE (SELECT 1 FROM SYSTABLES) END),
Get the length of the column name:
c0-param1=string:10 (CASE WHEN CHAR_LENGTH((SELECT COLNAME FROM SYSCOLUMNS WHERE (TABID > 0 AND TABID < 2) AND (COLNO > 0 AND COLNO < 2))) > 1 THEN 1 ELSE (SELECT 1 FROM SYSTABLES) END),
Get the column name:
c0-param1=string:10 (CASE WHEN SUBSTR((SELECT COLNAME FROM SYSCOLUMNS WHERE (TABID > 0 AND TABID < 2) AND (COLNO > 0 AND COLNO < 2)), 1, 1) LIKE 'a' THEN 1 ELSE (SELECT 1 FROM SYSTABLES) END),
Once I had the schema, I could begin dumping the data. This led to the final two hurdles I needed to overcome. The subquery that is passed to the SUBSTR function can only return one result. Therefore, I had to find a way to only return one column at a time.
My first thought was to use the FIRST statement that I talked about in the beginning. Unfortunately, it results in this error, "Cannot use 'FIRST' in this context.", as the FIRST clause cannot be used in a subquery. So, I decided to look for some sort of ID field in the user-defined tables, but there did not appear to be anything useful.
Through a little research I discovered that Informix tables actually have a hidden column named ROWID, which is a unique sequential number for each row. However, these ROWIDs can become fragmented with DELETEs and INSERTs into the table. In order to find the ROWIDs that contained data, I turned to a function called NVL. This function returns different results depending on whether the first argument is NULL. By selecting a column that was most likely to always contain data, I could find the ROWIDs in use like the following:
c0-param1=string:10 (NVL((SELECT username FROM users WHERE ROWID > 0 AND ROWID < 2), (SELECT 1 FROM SYSTABLES))),
With the active ROWIDs, I could begin reaping my rewards like so:
c0-param1=string:10 (CASE WHEN SUBSTR((SELECT username FROM users WHERE ROWID > 0 AND ROWID < 1), 1, 1) LIKE 'a' THEN 1 ELSE (SELECT 1 FROM SYSTABLES) END),
Through this method, I was able to discover plaintext application credentials, plaintext bank SFTP credentials and unencrypted payment card numbers.
Along with this success, I now know a ton more about IBM Informix than I ever thought I would need to know.