I recently checked my 247ctf account to see if any new challenges had been posted and spotted a new web challenge called "Acid Flag Bank". I decided to give the challenge a go, and made a writeup as I played along.

After reading the description, I gathered that the goal of the challenge is essentially to increase your funds so you can buy the flag. From first glance, I guessed it was some sort of logic flaw, although I couldn't be sure without checking the code, so let's dive in.

Don't know what a race condition is? 247ctf has made a brilliant video that can be found here.

Initial code review

We start the challenge off with a PHP file, which I have attached below with comments to help understand what is happening throughout.

<?php
require_once('flag.php'); # Include the flag.php file (this will probably be setting the $flag variable)

class ChallDB 
{
    public function __construct($flag) 
    {
        $this->pdo = new SQLite3('/tmp/users.db'); # Load the database from a file
        $this->flag = $flag; # Assign the instance variable "flag" to be the flag variable we imported earlier
    }
 
    public function updateFunds($id, $funds) # Pass two parameters to the variable, $id and $funds
    {
        $stmt = $this->pdo->prepare('update users set funds = :funds where id = :id'); # Define the initial query
        $stmt->bindValue(':id', $id, SQLITE3_INTEGER); # Insert said variables into the previously stated query.
        $stmt->bindValue(':funds', $funds, SQLITE3_INTEGER);
        return $stmt->execute(); # Run the query.
    }

    public function resetFunds()
    {
        $this->updateFunds(1, 247); # Reset the funds in the database.
        $this->updateFunds(2, 0);
        return "Funds updated!";
    }

    public function getFunds($id)
    {
        $stmt = $this->pdo->prepare('select funds from users where id = :id'); # Select all funds from the users table where the id matches what we pass the function.
        $stmt->bindValue(':id', $id, SQLITE3_INTEGER);
        $result = $stmt->execute();
        return $result->fetchArray(SQLITE3_ASSOC)['funds']; # Return the "funds" column from the database output
    }

    public function validUser($id)
    {
        $stmt = $this->pdo->prepare('select count(*) as valid from users where id = :id'); # Select the number of rows returned when looking for the user where the id's match
        $stmt->bindValue(':id', $id, SQLITE3_INTEGER);
        $result = $stmt->execute();
        $row = $result->fetchArray(SQLITE3_ASSOC); 
        return $row['valid'] == true; # If the row is valid, then return true.
    }

    public function dumpUsers()
    {
        $result = $this->pdo->query("select id, funds from users");
        echo "<pre>";
        echo "ID FUNDS\n";
        while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
            echo "{$row['id']}  {$row['funds']}\n";
        }
        echo "</pre>";
    }

    public function buyFlag($id)
    {
        if ($this->validUser($id) && $this->getFunds($id) > 247) { # If the user is valid and the funds are greater than 247
            return $this->flag;
        } else {
            return "Insufficient funds!";
        }
    }

    public function clean($x)
    {
        return round((int)trim($x));
    }
}

$db = new challDB($flag); # Create instance
if (isset($_GET['dump'])) { # Dump user data
    $db->dumpUsers();
} elseif (isset($_GET['reset'])) { # Reset the table
    echo $db->resetFunds();
} elseif (isset($_GET['flag'], $_GET['from'])) { # If the flag and from get parameters are set then
    $from = $db->clean($_GET['from']); # Run the "clean" function on the value in the from parameter
    echo $db->buyFlag($from); # echo the output from running byflag with the from get param
} elseif (isset($_GET['to'],$_GET['from'],$_GET['amount'])) { # If to, from and amount are set then
    $to = $db->clean($_GET['to']); # Assign the variables
    $from = $db->clean($_GET['from']);
    $amount = $db->clean($_GET['amount']);
    if ($to !== $from && $amount > 0 && $amount <= 247 && $db->validUser($to) && $db->validUser($from) && $db->getFunds($from) >= $amount) { # If to and from id's arent the same, and the amount is greater than 0 but smaller than 247, and both the to and from addresses are valid, and the from address has enough in their bank.
        $db->updateFunds($from, $db->getFunds($from) - $amount); # Update the values in the database
        $db->updateFunds($to, $db->getFunds($to) + $amount);
        echo "Funds transferred!";
    } else {
        echo "Invalid transfer request!";
    }
} else {
    echo highlight_file(__FILE__, true); # Show this source code.
}

Solving the challenge

Right off the back the challenge seems to involve abusing a race condition. I made this assumption based purely on two factors, there don't seem to be any exploitable syntax errors, such as SQL injections, and race condition challenges usually involve transferring/using credits.

So we can see that we have 4 different ways of interacting with the server:

  1. Dump the user ID and the amount of credits said user has.
  2. Reset the table to default state.
  3. Buy flag with the "flag" get parameter and the user ID of the account that is paying.
  4. Send credits from one account to another.

Maybe we can somehow bypass this check with the race condition, to allow us to make a payment somewhere we shouldn't be able to, and then buy the flag with our new amount!

($to !== $from && $amount > 0 && $amount <= 247 && $db->validUser($to) && $db->validUser($from) && $db->getFunds($from) >= $amount)

So we won't be able to do anything with the first check, as they are simply ensuring that the to and from user's aren't the same. Next, it makes sure the amount we want to transfer is over 0, but under 247, so we probably won't be able to exploit this either. The check then ensures the two users passed to the function are actual valid accounts, which seems to be secure. And finally, the check that appears to be vulnerable; it checks how much money the "from" account has, and checks if it is greater than or equal to the amount that the user wants to transfer. This last bit is the bit that I believe to be vulnerable, as it is this check that will determine if we can send money to an account when we don't actually have enough.

So, say we have the following details:

ID FUNDS
1  247
2  0

Theoretically, if we send loads of requests at the almost exact same time, to transfer money from one account to another and vice versa, it will process the checks for both requests at the same time (when the information checks out), and both threads will go through to the actual transaction part simultaneously.

To do this initially, I was using burp intruder, using null payloads, and 25 threads, but failed to do the race condition fast enough, so I turned to a great tool written in go.

I used this tool to send each of the defined requests 200 times at almost the exact same time. I speculated the tool was fast enough for me to send money back and forth a couple of times, and breaking the check in the process, so I defined two requests, one to be sending money from account 1 to 2, and another to send money from 2 to 1.

After that, it was just a matter of cycling between running the tool, checking /?dump, and then heading to /?reset until this happened:

ID FUNDS
1  247
2  1050

Bingo! Race condition succesfully exploited. Finally, head to /?flag&from=2 and claim your flag! This challenge was brilliant, I always love a good race condition, as they are certainly still present nowadays in the wild.