Software Name: Online Invoicing System
Software Version: < 2.9
Software Developer: https://bigprof.com/
Link to Software: https://github.com/bigprof-software/online-invoicing-system
Vulnerability: SQL Injection
CVE ID: CVE-2020-35674

Introduction

Online Invoicing System suffers from a SQL Injection found in /membership_passwordReset.php which is the endpoint that is responsible for issuing self password resets. An unauthenticated attacker is able to send a request containing a specially crafted payload which can result in sensitive information being extracted from the database eventually leading into an application takeover. This vulnerability is introduced as a result of the developer trying to roll their own sanitization implementation in order to allow the application to be used in legacy environments.

Exploitation Details

PHP Version used throughout this writeup: PHP 7.4.8

Exploring the source found in: app/membership_passwordReset.php:

Screenshot

On Line 5, $username is initialized with the value returned from passing the user-input into the makeSafe() function. The user-input is passed in through the username parameter found in the body of the following POST request:

POST /membership_passwordReset.php HTTP/1.1
Host: localhost
Content-Length: 38
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://localhost
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://localhost/membership_passwordReset.php
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Cookie: online_inovicing_system=5oknklsfinhab277rupidv112o
Connection: close

username=test&email=&reset=Ok

After $username is intialized, the application eventually constructs and executes a SQL query which contains the user-input as shown on Line 21 which would be the sink in this case:

$where = '';
		if($username){
			$where = "lcase(memberID)='{$username}'";
		}elseif($email){
			$where = "email='{$email}'";
		}
$res = sql("select * from membership_users where {$where} limit 1", $eo);

However as $username is first passed into the makeSafe() function before being concantenated into the query, it appears to be sanitized.

Let’s explore the makeSafe() function found in app/admin/incFunctions.php to learn more:

Screenshot

From a high level overview, it can be inferred that this function is indeed responsible for sanitizing user-input by escaping any potentially dangerous characters. However to understand how this function works in greater detail, lets break it down line by line.

On Line 2, the application performs a ternary operation in order to re-initalize the value of $string (which is the value of the user-input passed into this function).

if($is_gpc) $string = (get_magic_quotes_gpc() ? stripslashes($string) : $string);

As shown by the function definition, $is_gpc is set to True by default meaning the above line will always be executed. The ternary operation is then performed based on the result returned from the get_magic_quotes_gpc() function.

Some quick context regarding the get_magic_quotes_gpc() function from the PHP Docs:

Returns 0 if magic_quotes_gpc is off, 1 otherwise. Or always returns false as of PHP 5.4.0.

The last line here is key. As of “newer” PHP Versions, this will always result in false meaning that the ternary operation will never call the stripslashes() function on the user-input but re-initalize it the way it is. This isn’t deterimental as there is additional logic performed in the makeSafe() function.

After $string is re-initalized, the function continues. On Line 6, the application initalizes $na which is an array composed of potentially dangerous characters that can result in an injection such as null-bytes, carriage returns, line feeds, etc. These are the same characters in which PHP’s mysqli_real_escape_string() function escapes. Finally two booleans $escaped and $nosc are initialized with the value being true:

// prevent double escaping
		$na = explode(',', "\x00,\n,\r,',\",\x1a");
		$escaped = true;
        $nosc = true; // no special chars exist

Moving forward, the application then enters a for loop which iterates over the bad characters stored in the $na array. This loop is interesting in how it works because it initializes two new variables:

$dan $esdan

foreach($na as $ns){
			$dan = substr_count($string, $ns);
			$esdan = substr_count($string, "\\{$ns}");
			if($dan != $esdan) $escaped = false;
			if($dan) $nosc = false;
        }

The application takes the value of the user-input which is stored in $string, the bad character in the current iteration and calls the substr_count() function against both which returns the number of occurences of the bad character found in $string. This value is then stored in $dan:

$dan = substr_count($string, $ns);

The application then nearly performs the same thing however this time it passes an escaped variant of the bad character to see if any matches are found in the string. The returned value is then stored in $esdan:

$esdan = substr_count($string, "\\{$ns}");

Finally the application then performs a comparison to see if the value of $dan and $esdan are different. If these values are different, this means that a character from the blacklist was found un-escaped in the user-input and if so $escaped is set to false.

if($dan != $esdan) $escaped = false;

Lastly the application checks if $dan is true meaning if there was one or more occurence of a blacklist character found in the user input. If so, $nosc is set to false and the function moves on:

if($dan) $nosc = false;

At this point, we have reached the final logic in the application which is only triggered if $nosc results in true. The only way this can occur is if there are no blacklisted characters in the user input.

if($nosc){
			// find unescaped \
			$dan = substr_count($string, '\\');
			$esdan = substr_count($string, '\\\\');
			if($dan != $esdan * 2) $escaped = false;
        }

To quickly break down this function’s logic, it compares the number of escaped backslashes and unescaped backslashes found in the user-input. The logic here is that if the number of escaped backslashes (meaning a backslash followed immediately by another backslash) is not double the amount of backslashes found in the user-input, there is an un-escaped backslash (which can result in injection in some cases) and $escaped is set to false. What may seem confusing at first is you would think that in order to reach this function, $escaped would’ve already been set to false earlier, however keep in mind that you can reach this function if your user-input does not contain any blacklisted characters.

Finally we reach the return statement of the function, which is another ternary operation:

return ($escaped ? $string : db_escape($string));

This ternary condition takes the value of $escaped and if true, returns the value of $string directly. However if $escaped results in false, the user-input is then passed into the db_escape() function which is essentially a wrapper function over mysql?_escape_string (essentially depending on your version of PHP):

Screenshot

Taking a step back, we can see that this logic seems to be fairly sound. However there is one small flaw here that can result in a fatal vulnerability.

Before we start exploring this, let’s do a quick recap of the makeSafe() function logic:

1. Count the number of blacklist character occurences in the string and store the value in $dan
2. Count the number of escaped blacklist character occurences in the string and store in $esdan
3. Compare $dan & $esdan; if different then set $escaped to false
4. If there is at least one blacklist character meaing that $dan is more than 0, set $nosc = false
5. If $nosc is true (meaning there are no blacklisted characters), count the number of escaped vs unescaped backslashes and compare.
6. If escaped backslashes is not double the value of unescaped backslashes, set $escaped to false.
7. If $escaped is true, return string directly. Else pass to db_escape() function which sanitizes string using the mysqli_escape_string function

In order for a successful attack, it is imperative that the value of $escaped is true at the end as it would mean that the string would be returned directly instead of being passed into the db_escape() function therefore rendering the injection useless as the user input would be correctly sanitized.

With this in mind, we can try using the following payload which if passed into a query will be able to break out:

test\\'

To recap the payload above, we notice:

- Valid SQLi payload (as we would be able to append arbitrary queries we would like to inject after the ')
- Contains a blacklisted character (') 
- Contains an even amount of backslashes + in front of the blacklisted character therefore 'escaping' it

After adding a few debug statements which will print what the makeSafe() function is doing behind the scenes (to make it easier to understand visually), we can pass this value to the application and see what happens:

Screenshot

As shown in the debug statements, when the iteration loop reaches the ' special character found in the payload, the following values are returned:

Value of special character: '
membership_passwordReset.php:145 Value of dan: 1
membership_passwordReset.php:145 Value of esdan: 1
membership_passwordReset.php:145 Value of escaped: true
membership_passwordReset.php:145 Value of nosc: false

As there is one special character, $dan is set to 1. As we are escaping the special character (as there is a backslash prepended to it), the value of $esdan is 1 as well. As $dan == $esdan, the value of $escaped remains unchanged at true which is crucial. However since $dan is greater than 0 (since the user input contains a black list character), $nosc is set to false meaning that the function skips the second conditional check (where it compares the amount of escaped backslashes vs unescaped).

Since the function skips the second conditional, the return statement is reached with the value of $escaped being set to true meaning that the user-input is directly returned as is aka unsanitized and dangerous.

Coming back to the logic which is in charge of concatenating the finalized query:

$where = '';
		if($username){
			$where = "lcase(memberID)='{$username}'";
		}elseif($email){
			$where = "email='{$email}'";
		}
$res = sql("select * from membership_users where {$where} limit 1", $eo);

In this case the value of $username is set to test\\' meaning the full query will look something similar to:

select * from membership_users where lcase(memberID)='test\\'' limit 1

To confirm whether or not the application is vulnerable, we attempt to issue a password reset request inputting the payload in the username field and being met with:

Screenshot

Great this looks like a promising start. However in the case of black box testing or when the application doesn’t show any errors, we will need to verify that we can succesfully inject queries. As such we can use a payload that will cause the database to sleep for a certain amount of time to confirm that injection was indeed possible such as:

test\\' OR sleep(3)#

When the above payload is executed, it will cause the database to sleep for 3 seconds.

To see the response time, we can prepend time to a curl command and see how long it takes to finish:

Screenshot

As shown above, the response is returned nearly instantly (you can run it a few times to get the average).

Now if we modify the value of the username parameter in the curl command to reflect the updated payload, we see the response time takes longer to execute and the response time is completely different at 6 seconds:

Screenshot

It may be strange that the payload had a value of 3 seconds, however the response time returned was double. This is due to the application executing the query an additional time (such as in cases when it is trying to return the number of queries found using count(*)). We can confirm this is the case by issuing a payload with a sleep time of 1 second meaning the response time should be two seconds:

Screenshot

As shown above, the response time is 2 seconds therefore confirming that it is possible to inject additional queries into the database resulting in a SQL Injection.

Proof of Concept

Basic proof of concept:

test\\' OR sleep(1)#

As no information is returned in the response (apart from the error message), an attacker can utilize a blind boolean sleep based injection in order to exfiltrate information from the database. Using specially crafted SQL statements, an attacker is able to use the response time as an indicator to infer whether or not a specific value exists in the database using a set of conditionals which will either result in true or false causing the application to sleep when a specific condition is met.