CVE-2020-35674
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
:
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:
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):
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:
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:
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:
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:
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:
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.
© 2023 Ingredous Labs ― Powered by Jekyll and Textlog theme