GitS 2015: aart.php (race condition)

Welcome to my second writeup for Ghost in the Shellcode 2015! This writeup is for the one and only Web level, "aart" (download it). I wanted to do a writeup for this one specifically because, even though the level isn't super exciting, the solution was actually a pretty obscure vulnerability type that you don't generally see in CTFs: a race condition!

But we'll get to that after, first I want to talk about a wrong path that I spent a lot of time on. :)

The wrong path

If you aren't interested in the trial-and-error process, you can skip this section—don't worry, you won't miss anything useful.

I like to think of myself as being pretty good at Web stuff. I mean, it's a large part of my job and career. So when I couldn't immediately find the vulnerability on a small PHP app, I felt like a bit of an idiot.

I immediately noticed a complete lack of cross-site scripting and cross-site request forgery protections, but those don't lead to code execution so I needed something more. I also immediately noticed an auth bypass vulnerability, where the server would tell you the password for a chosen user if you simply try to log in and type the password incorrectly. I also quickly noticed that you could create multiple accounts with the same name! But none of that was ultimately helpful (except the multiple accounts, actually).

Eventually, while scanning code over and over, I noticed this interesting construct in vote.php:

<?php
if($type === "up"){
        $sql = "UPDATE art SET karma=karma+1 where id='$id';";
} elseif($type === "down"){
        $sql = "UPDATE art SET karma=karma-1 where id='$id';";
}

mysqli_query($conn, $sql);
?>

mysqli_query($conn, $sql);

Before that block, $sql wasn't initialized. The block doesn't necessarily initialize it before it's used. That led me to an obvious conclusion: register_globals (aka, "remote administration for Joomla")!

I tried a few things to test it, but because the result of mysqli_query isn't actually used and errors aren't displayed, it was difficult to tell what was happening. I ended up setting up a local version of the challenge on a Debian VM just so I could play around (I find that having a good debug environment is a key to CTF success!)

After getting it going and turning on register_globals, and messing around a bunch, I found a good query I could use:

http://192.168.42.120/vote.php?sql=UPDATE+art+SET+karma=1000000+where+id='1'

That worked on my test app, so I confidently strode to the real app, ran it, and... nothing happened. Rats. Back to the drawing board.

The real vulnerability

So, the goal of the application was to obtain a user account that isn't restricted. When you create an account, it's immediately set to "restricted" by this code in register.php:

<?php
if(isset($_POST['username'])){
        $username = mysqli_real_escape_string($conn, $_POST['username']);
        $password = mysqli_real_escape_string($conn, $_POST['password']);

        $sql = "INSERT into users (username, password) values ('$username', '$password');";
        mysqli_query($conn, $sql);

        $sql = "INSERT into privs (userid, isRestricted) values ((select users.id from users where username='$username'), TRUE);";
        mysqli_query($conn, $sql);
        ?>
        <h2>SUCCESS!</h2>
        <?php
} else {
[...]
}
?>

Then on the login page, it's checked using this code:

<?php
if(isset($_POST['username'])){
        $username = mysqli_real_escape_string($conn, $_POST['username']);

        $sql = "SELECT * from users where username='$username';";
        $result = mysqli_query($conn, $sql);

        $row = $result->fetch_assoc();
        var_dump($_POST);
        var_dump($row);

        if($_POST['username'] === $row['username'] and $_POST['password'] === $row['password']){
                ?>
                <h1>Logged in as <?php echo($username);?></h1>
                <?php

                $uid = $row['id'];
                $sql = "SELECT isRestricted from privs where userid='$uid' and isRestricted=TRUE;";
                $result = mysqli_query($conn, $sql);
                $row = $result->fetch_assoc();
                if($row['isRestricted']){
                        ?>
                        <h2>This is a restricted account</h2>

                        <?php
                }else{
                        ?>
                        <h2><?php include('../key');?></h2>
                        <?php

                }


        ?>
        <h2>SUCCESS!</h2>
        <?php
        }
} else {
[...]
}

My gut reaction for far too long was that it's impossible to bypass that check, because it only selects rows where isRestricted=true!

But after fighting with the register_globals non-starter above, I realized that if there were no matching rows in the privs database, it would return zero results and the check would pass, allowing me access! But how to do that?

I went back to the user creation code in register.php and noticed that the creation code creates the user, then restricts it! There's a lesson to programmers: secure by default.

$sql = "INSERT into users (username, password) values ('$username', '$password');";
mysqli_query($conn, $sql);

$sql = "INSERT into privs (userid, isRestricted) values ((select users.id from users where username='$username'), TRUE);";
mysqli_query($conn, $sql);

That means, if you can create a user account and log in immediately after, before the second query runs, then you can successfully get the key! But I didn't notice that till later, like, today. I actually found another path to exploitation! :)

My exploit

This is where things get a little confusing....

I first noticed there's a similar vulnerability in the code that inserts the account restriction into the user table. There's no logic in the application to prevent the creation of multiple user accounts with the same name! And, if you create multiple accounts with the same name, it looked like only the first account would ever get restricted.

That was my reasoning, anyways (I don't think that's actually true, but that turned out not to matter). However, on login, only the first account is actually retrieved from the database! My thought was, if you could get those two SQL statements to run concurrently, so they run intertwined between two processes, it might just put things in the right order for an exploit!

Sorry if that's confusing to you—that logic is flawed in like every way imaginable, I realized afterwards, but I implemented the code anyways. Here's the main part (you can grab the full exploit here):

require 'httparty'

TARGET = "http://aart.2015.ghostintheshellcode.com/"
#TARGET = "http://192.168.42.120/"

name = "ron" + rand(100000).to_s(16)

fork()

t1 = Thread.new do |t|
  response = (HTTParty.post("#{TARGET}/register.php", :body => { :username => name, :password => name }))
end

t2 = Thread.new do |t|
  response = (HTTParty.post("#{TARGET}/register.php", :body => { :username => name, :password => name }))
end

I ran that against my test host and checked the database. Instead of failing miserably, like it by all rights should have, it somehow caused the second query—the INSERT into privs code— to fail entirely! I attempted to log in as the new user, and it gave me the key on my test server.

Honestly, I have no idea why that worked. If I ran it multiple times, it worked somewhere between 1/2 and 1/4 of the time. Not bad, for a race condition! It must have caused a silent SQL error or something, I'm not entirely sure.

Anyway, I then I tried running it against the real service about 100 times, with no luck. I tried running one instance and a bunch in parallel. No deal. Hmm! From my home DSL connection, it was slowwwwww, so I reasoned that maybe there's just too much lag.

To fix that, I copied the exploit to my server, which has high bandwidth (thanks to SkullSpace for letting me keep my gear there :) ) and ran the same exploit, which worked on the first try! That was it, I had the flag.

Conclusion

I'm not entirely sure why my exploit worked, but it worked great (assuming decent latency)!

I realize this challenge (and story) aren't super exciting, but I like that the vulnerability was due to a race condition. Something nice and obscure, that we hear about and occasionally fix, but almost never exploits. Props to the GitS team for creating the challenge!

And also, if anybody can see what I'm missing, please drop me an email ron @ skullsecurity.net) and I'll update this blog. I approve all non-spam comments, eventually, but I don't get notifications for them at the moment.

3 thoughts on “GitS 2015: aart.php (race condition)

  1. Reply

    guly

    another way it could break itself is if mysql max_connections is set too low, the default value is 100 and will hit quite fast.
    this also can be the reason why on your environment, with my.cnf as default i guess, you can win that race easily and you need to stress it more in production, with a higher limits.

    1. Reply

      Ron Bowes Post author

      That's possible! I didn't go back and troubleshoot what was happening, maybe I should...

  2. Reply

    kamonwat

    here is how your exploit works.

    two threads register with the same username.
    let's say t1 add the username to the database
    then t2 add the same username to the database
    but as you said, only the first user added will get restricted set to true.
    if t2 won over t1 and adds restricted = true to the second-added user
    then t2 would not get restricted added.

    when you login, the first user (with no restricted) will get called from the database hence grants you the flag.

Leave a Reply

Your email address will not be published.