Saturday, May 4, 2013

BitcoinCTF - write-up of the first 3 levels

The BitcoinCTF started yesterday and just a couple of minutes ago somebody won, taking both of the BTCs  as a prize. I had fun with the first three levels - and was extremely frustrated with the 4th one - so I decided to make this write-up. I'm only an amateur enthusiast doing this as a hobby, so if you spot something that could have been done easier/better please feel free to comment.
Okay, on with the solutions.

Level 1

The first level presents us with a simple login form, which looks like this:


Naturally the first thing that comes to mind is SQL injection. Let's put a single quote in the username field and hope for an error. Nope, no luck. Maybe the errors are just turned off and something like ' or 1=1%23 could work. But it doesn't.

We should stop for a minute and try to guess the query. A good guess would be:

SELECT user, pass FROM users WHERE user = '$user' AND pass = '$pass'
But apparently the user and pass values must be filtered, since the single quote doesn't break the query. Is it magic_quotes that filters them, or a simple preg_replace that changes ' to \'

Let's try entering a backslash to the end of the user field. Yeah! It gives us an error. This is what happens to the query:
SELECT user, pass FROM users WHERE user = 'xxx\' AND PASS = 'zzz'
So the backslash at the end of the user value will escape the ending quote and the string that MySQL compares will last until the start of the pass value. The pass value will then be treated as code, so we found a way to inject SQL. The solution would be:

Level 2

Following the link to level 2 gives us this page. Note the url parameters.




It is quite clear that we should be able to inject into the orderby parameter. Unless of course this is just a decoy, but it turned out that it isn't. First let's do some basic tinkering. The ORDER BY clause accepts column indexes as a parameter:
?orderby=1&limit=10 -- this works 
?orderby=2&limit=10 -- this works 
?orderby=3&limit=10 -- this works too, WTF? 
?orderby=4&limit=10 -- doesn't work
Apparently there are 3 columns in the table that stores these bookmarks. That's strange, but the third column could simply be an identifier. Now let's see if we can inject arbitrary code to the ORDER BY clause. The simplest way to do this is the SLEEP() function.
?orderby=sleep(3)&limit=10
This took roughly 3 seconds, we should be good! This is the point where I realized that it can only be done blindly. And we really don't want to do a time-based blind injection because that takes ages, so we have to come up with a query that either gives an error or displays the single bookmark based on its parameters. Then I tried something:
?orderby=(select 1)&limit=10 -- this works
?orderby=(select 1 union all select 1)&limit=10 -- fails
This is nothing surprising - ORDER BY accepts one value, you can't pass more rows. You also can't do (SELECT 1,2) but that won't really help us here. So we need to form a query that returns 1 row if it fails and it returns 2 rows if it doesn't. Returning 0 rows wouldn't result in an error.

What do we want to read from the database? We have no idea at this point. We have to read the schema names, table names and column names to find intersting tables/columns. Fortunately the information_schema contains all of this information in the "tables" and "columns" tables.

We need to form queries to do this blindly and write a script to inject automatically, since we don't want to do it by hand, that would take forever. This was my thought process (omitting the ?orderby= and limit paramters here for readability):
(select table_name from information_schema.tables)
This fails because it retuns many table names
(select table_name from information_schema.tables limit 1)
This works, and returns the first table, but how do we add a WHERE clause that enables us to check a table with an index of our choosing? We need to put this in a subquery and do something like this:
(select 1 from (select table_name from information_schema.tables limit 1) a)
The 'a' is there because we need to name the table that the subquery creates at runtime. And now we can add a WHERE clause and blindly check each letter of the first table.
(select 1 from (select table_name from information_schema.tables limit 1) a where substring(a.table_name, 1, 1) = 'A')
Even though we can be 99% positive that this begins with 'C' (Collations is usually the first table of inofrmation_schema), no matter what letter we check, the query never fails. Of course, I wrote earlier that the ORDER BY clause can take a subquery that returns 0 or 1 rows. And we always return 0 or 1 rows. Let's fix this by returning 1 or 2 rows. That's easy, just union a new row and we are done.
(select 1 from (select table_name from information_schema.tables limit 1) a where substring(a.table_name, 1, 1) = 'A' union all select 1)
After trying a few letters it is clear that it only fails on 'C', thus we were right and we have a way to dump the database now. Let's write a script that does this for us, because it's a tedious amount of work by hand. Before we write a script, let's think about how this could be done with even less queries. Figuring out a letter (provided that we know the table name only contains letters, and this should never be expected, especially when reading other data) would take 26 queries. Why don't we do a kind of "binary search"? That would take 8 queries and we could read any character. To do this, we use the bitwise AND operator to check each bit of a character.
(select 1 from (select table_name from information_schema.tables limit 1) a where char(substring(a.table_name, 1, 1)) & 1 = 1 union all select 1)
Then we can check x&2=2, x&4=4, etc.

For the injecting script I used python with the requests module, which is one of my favourite python modules. (Never tell that on a first date though, just TMI dude). The script is quite straightforward, you can find the source code here: https://gist.github.com/anonymous/83c86279013462fc7d5e

The code is a bit chaotic and has unused features that I didn't care to delete, but it works.

Running this for table_name tells us a few table names. The first 40 tables are internal and not of much interest right now. The last table is called urls. That's what we need. Then we can form a query for column_names, which you can find in the script that I linked. The column names turned out to be:

  • url
  • addedby
  • deleted
Hmm... that deleted field looks promising. Is there a deleted bookmark that we need to read? Yes, it turns out. After running the same script for the urls table's url field we get the deleted url:
/b00km4rks.php
And we just solved level 2.

Level 3 

I won't go into much detail here. It looks really similar to level 2.




Well there are 3 bookmarks this time. Let's try the same thing we did in the last level. It gives us an error, so there is probably some filtering going on. Is it injectable at all?
?orderby=sleep(3)&limit=10
Hell yes. But when we try this, we get an error:
?orderby=(select 1)&limit=10
This is suspicious. After some more tinkering I found out that spaces are definitely filtered here. Oh, that's not too bad. But then there is one little problem. You simply can't (at least I couldn't) form a LIMIT clause without a space. MySQL seems to be pretty strict on that compared to how liberal it is with other syntax. We need to lose the LIMIT clause. But how can we limit our query to just a single table? Otherwise we couldn't blindly inject.

Well at this point it's easy. We can guess that the table schema is the same, but even if we want to make sure we only need to check the last table. All of the information_schema table names begin with capital letters. Let's filter these out by using
(ord(substring(table_name,1,1))>90)
Here is the whole script by the way (again it is a bit chaotic): https://gist.github.com/anonymous/ab92acc41b0cf86cf52a

Another thing we can check is the number of deleted bookmarks. Using the same method it turns out to be one. Finally, here is the space-less query that blindly reads the url of the deleted bookmark:
(select(addedby)from(urls)where((deleted=2)and(ord( substring(addedby,1,1))&1=1))union(select(1)))
Running this returns the solution for the third level:
/?name=Lewis+Carroll

Level 4 

This is where I (and many others according to Reddit/Twitter) got stuck. The clue was a name parameter. Where should we put this parameter? It turns out that the main page (www.bitcoinctf.com) is the target for this. When you check the source you get this comment at the top:
<!-- I imagine that right now you are feeling a bit like Alice tumbling down the rabbit hole... -->
And it turns out that the "name" parameter controls the value of "Alice" (when it's not empty, that is). The first thing that comes to mind is XSS, but what can you do with a reflected XSS on a site that doesn't even use sessions? Nada. The second thing that came to my mind was something that I considered an "ancient" technique, and never thought that I would need it. SSI. Of course I don't have much experience, but is SSI used by any site in 2013?

We can use the following commands:
<!--#echo var="..."-->
<!--#include file="..."--> 
<!--#printenv-->
These give us some important info about the server, but sadly #exec is somehow disabled. One thing that might be important is the SCRIPT_FILENAME variable. Its value is "/var/www/html/index.shtml". 

I also tried including other files, even index.shtml itself, but it didn't help. Then I started trying the weirdest things until I was completely out of ideas. This is where I got stuck.

I'm really interested in the solution here, looking forward to a proper write-up!

No comments:

Post a Comment