Type Juggling Authentication Bypass Vulnerability in CMS Made Simple
Our researcher, Sven Morgenroth, explains how he found an Authentication Bypass in CMS Made Simple, what PHP Type Juggling is, and why you should never use the unserialize function together with user-supplied input.
Have you ever experienced that sinking feeling when you discover that you've run out of one crucial ingredient for a special meal? It might be a single ingredient, but it ruins the whole dish, doesn't it? In the world of web application security, one apparently small slip-up can compromise the entire security of your web application, even when that mistake consists of leaving out a single character.
This is exactly what happened in CMS Made Simple (CMSMS): one missing character was sufficient to result in an Authentication Bypass, and ultimately Remote Code Execution (RCE).
This article explains the CMSMS vulnerability, which is a perfect illustration of why you should pay very close attention when you code your web application, especially critical parts such as authentication functionality.
What Caused the Authentication Bypass Vulnerability?
CMS Made Simple (CMSMS) is a Content Management System (CMS) written in PHP, and subject to all the beautiful pitfalls that make PHP so unique.
Did you know, for example, that both of these comparisons would return true?
'test' == 0
'test' == TRUE
And that's just the tip of the iceberg. But why would PHP determine that the string 'test' is the same as the integer '0'? Or the same as the boolean TRUE?
The explanation is obvious once you know how it's worked out.
What is Type Juggling?
When using the "==" operator, PHP attempts something called loose comparison (or 'type juggling'). With loose comparison, it's possible for a PHP developer to compare values even if they have a different data type, such as integers and strings. But why would this deliver the weird result in the comparisons example?
Comparing a string to an integer is a little bit like comparing apples and oranges. It is impossible, and the final food metaphor you'll read in this post! PHP somehow has to convert both values to the same data type. When PHP does a comparison, it always attempts to convert the string to an integer first. To do this, it checks the beginning of the string to see if there are numbers it can use for comparison.
The string 'test' doesn't begin with a number. In PHP, this means that it is equal to the integer '0', which explains why the comparison returns true. It's a little different for strings and boolean values. PHP always tries to convert a string to boolean if both are compared. It does this by treating strings with any given content as TRUE. For example 'test' == TRUE, while an empty string is always treated as FALSE.
How to Avoid Type Juggling
The behavior I have just described is obviously undesirable in the many instances in which you want to have an exact comparison of two given values. For this reason, PHP also has the === operator. Since good things come in threes, three equal signs mean that the comparison should take the data type into consideration. If the type differs, PHP will not attempt any conversions; it will return FALSE instead. This is a relatively easy way to deal with PHP's often confusing behaviour when it comes to type comparisons. Simply add a single character.
CMS Made Simple – Authentication Bypass
As I've mentioned, CMS Made Simple is written in PHP, and the loose comparison operator is quite popular in this language. It therefore doesn't come as any surprise that it is used on multiple occasions throughout the code. Of course, there is nothing wrong with that. The real problem arises when it is used on critical comparisons such as password hashes. That is exactly what happened with CMS Made Simple, and I'll explain how I found and exploited this vulnerability.
Thousands of Lines of Source Code
When you are auditing a popular, well-established web application, you are dealing with thousands of lines of code. Most of them won't lead to vulnerabilities, but some of them will, under the right circumstances. A good way to find some vulnerable code in a short amount of time is to concentrate your efforts on critical functions and functionality that is easy to get wrong. It would take weeks to analyse every bit of code starting from index.php, line 1.
One of the areas where critical mistakes often happen is in the authentication functionality of a website. So one of the first files I looked at in CMSMS was /admin/index.php, to find out how it checked whether a user was logged in. Fortunately, the function responsible for this wasn't that difficult to spot.
As you can see in the middle, the function was called check_login. It calls all the other functions that check whether or not the visitor is authenticated. To quickly spot where the function was defined, I used the Find in Folder functionality of my text editor (Sublime Text). It was as easy as searching for 'function check_login('. After a while it returned the following result.
As you see, there are almost two thousand files in CMSMS. So it was important to set some priorities and create a plan for where to start. The search further told me that the function was defined in lib/page.functions.php, which could be opened in Sublime by double-clicking the highlighted line number on the left.
Are We Logged In Yet?
Clicking on the highlighted number displays the actual check_login function. This is what it looks like.
There was a lot going on in this function, so let's try to make some sense out of it. It first tries to retrieve the user id with the get_userid function. If it's bigger than 0, it will assume that the user is logged in. Next, it checks whether or not the Cross-site Request Forgery (CSRF) token is correct. If those checks fail, the function will deauthenticate the user, redirect him or her to /admin/login.php, and abort the remainder of the script execution. So the important values are the CSRF token and the user ID.
Let's first take a look at the user ID. It's returned from the function get_userid. After a quick search in the same file, this was the function.
First, it creates an instance of the LoginOperations class and then calls its get_effective_uid method to return the user ID. To see what it does, we have to go further down the rabbit hole. (Yes, I'm perfectly aware that this sentence is used in every single vulnerability writeup involving source code since the inception of vulnerability writeups involving source code, but at least it's not a food metaphor!)
Finding Authentication Bypasses is a Tedious Task
The search for the get_effective_uid method leads us to a file with the following path: lib/classes/internal/class.LoginOperations.php. Yet again, the actual check doesn't happen in this method.
Instead, it will call the method _get_data which seems to return an array and check the value of an array element against the eff_uid key. If the value does not exist, it will just return the result of calling the method get_loggedin_uid. So to make this check return something other than null, we first have to make sure that _get_data is returning something. Let's take a look at another function.
It seems like we are on the right track. If this check has already taken place, the _data property of this class instance will return its content. However, this is not yet the case. Below this check we see a comment that describes what happens in this function:
"// using session, and-or cookie data see if we are authenticated"
This cryptic comment (not the only weak 'crypto' as we'll discover later) is trying to tell us that CMSMS will try to use $_SESSION (not really user-controllable) to see if there is any data. If that's not the case, it will get its data from $_COOKIE, which holds the data from cookies sent to the server by the client requesting the page. Therefore, it is completely user-controllable. After that, it checks whether any array keys are missing. If everything is fine, it moves on to the _check_passhash method.
Let's take a closer look at this method before we come back to $_COOKIE[$this->_loginkey]. Below you can see _check_passhash:
This looks very promising. It first checks the database to see whether there is a user for the given user id. If there is a user, it will create an MD5 hash consisting of various values. Most of them are user-controllable or static. But the real problem, from an attacker's perspective, is that the password hash that was taken from the database is part of the MD5 hash. To know the hash, you need to know the password. And if you knew the password you wouldn't have to hack anything. You'd simply log in. That's depressing, right? We've come so far, but to authenticate, we still need the correct password. Don't lose hope yet! The checksum from the cookie and the generated checksum containing the database password are compared using loose comparison.
Finally Bypassing CMSMS Authentication Checks
We'd either have to pass the integer 0 or boolean TRUE as $checksum to the function in order to pass the check without knowing the password, providing the hash string doesn't start with a number other than 0. So it's better to use TRUE in this case, as the comparison will always result in TRUE when it's compared to a non-empty string.
However, there is one thing you need to know about PHP input when it comes to $_GET, $_POST and $_COOKIE. There are basically two data types that you can get from these variables: string and array. The latter would work by passing a query such as ?array=first_element&array=second_element. This seems like a big problem. So let's take a look what data is actually being passed by $_COOKIE. The logic for that is in _get_data(), as illustrated.
It receives the data from a cookie. Its name is saved in the _loginkey property of the current class. Its value is assigned during the construction of the class, as illustrated.
I'm not sure if this hash is in place in order to avoid the problem of having the same login key for different installations or versions of CMSMS, but its values can easily be guessed and some of them are even static. For example, the constant __CLASS__ never changes, and the CMS version can be guessed, as well as the content of __FILE__. The only variable in __FILE__ that is unknown to an attacker is the path to the webroot. If display_errors is not set to 'off', it can be as simple as converting a GET parameter to an array with the method described above. All we have to do is add square brackets after the parameter name: mact. It will throw an error in a function that expects a string value, and will display the full path of the file in an error message.
Yet again, we have proof that in certain circumstances, a small information leak can have a huge impact. However, even if we don't achieve a full path disclosure, the value can simply be guessed in most cases. This means that we can easily find the right cookie name, just by trying different webroots and versions. The only problem is that we still need to find a payload that will ensure that we pass the password hashsum check. Since we now know the name of the authentication cookie, we need to look at its content.
Another Reason Not to Use Unserialize
This is yet another example of why you really should read the PHP documentation (as if there weren't enough reasons already). It seems that one of my favorite UNIX quotes applies to PHP as well:
Unix will give you enough rope to shoot yourself in the foot. If you didn't think rope would do that, you should have read the man page.
Let's get back to business.
As we can see, the cookie value is saved in the $private_data variable, as well as in $_SESSION. Before $private_data can be used, it is decrypted using the _decrypt method from the current class. If you were hoping that we were going to crack some advanced encryption algorithms, I'm afraid you're going to be disappointed. You'll see why when you look at the _decrypt and _encrypt methods.
Oh great! It's only encoded with rot13 and base64. In their comment, they actually acknowledged the fact that this function has nothing to do with encryption, but rather with encoding, which is why I'm still puzzled as to why they called it _encrypt. Perhaps they intended to disappoint security researchers and blog readers keen to solve a nice crypto challenge?
I'm kidding! Everything lines up perfectly so far, and having an encrypted cookie would cause a nuisance that might even result in a failed attack. Anyway, str_rot13 and base64 are not the only functions within the two methods. There are another two: unserialize and serialize. To see what's wrong with that, take a quick look at the PHP manual.
It tells us not to pass untrusted user input to unserialize. The reason involves both PHP Object Injection and even Local File Inclusion (LFI) through PHP's autoloading feature.
However, these are not the only vulnerabilities that may arise. Unserialize is infamous for its issues with memory management. Various code execution CVE numbers were assigned due to this function. However, those memory issues are difficult to find, let alone exploit, on different platforms.
Local File Inclusions that are caused by unserialize in combination with an existing autoloading functionality are almost exclusively restricted to files ending in .php. Also, PHP Object Injection requires existing code ('gadgets') that can lead to dangerous actions. For this to work, the affected classes must contain certain magic methods, like __toString that would be called on to deserialize user input, casting the deserialized object to a string, or more commonly __wakeup which will execute a specific action upon deserializing an object. They also need to have controllable properties and call exploitable functions.
CMSMS didn't contain any obviously dangerous gadgets. But at this point, we don't even need them. While I mentioned before that you can only pass strings and arrays using cookies, it's different with unserialize. We could simply serialize an array containing any data types from boolean to integer, and feed it to unserialize. After they are deserialized, the data types are the same as in the original array. Let's look at the code again.
How to Pass the Checksum Comparison with Type Juggling
Finally we can start building a payload that is able to bypass the CMSMS login prompt for the admin panel.
If we send a base64-encoded, serialized array that was passed through str_rot13 in a cookie (whose name is an MD5 generated by the full path to class.LoginOperations.php, the string CMSMS\LoginOperations and the current version number), we can control the $private_data variable. We can make it an array containing any data with any type we want.
Since both the $uid and $checksum variable we see above come from the deserialized array in the cookie, we can give $private_data['uid'] the value of an integer of '1', and $private_data['cksum'] can be set to the boolean TRUE. It's important to note that administrators almost always have the user id '1'. By setting $private_data['cksum'] to TRUE, the $checksum variable above will also be TRUE.
What happens now is that CMSMS will load the userdata of the user with the id '1' and save it in $oneuser. Then it will generate the password hash, by hashing different values including the received password hash from the database, and save it in $tmp. Among those values, we also have the current user agent and the IP address, which aims to prevent session token theft. However, we can ignore them, due to the loose comparison of $checksum and $tmp using the == operator. Since we have set the $checksum variable to 'TRUE', the comparison is basically like this:
TRUE == '9ab50f27d4201db9b28483ba83c48ebafbb2aa17'
If you recall the comparisons mentioned at the beginning of this post, you may remember that comparing boolean TRUE to any string will alway return true. So even if we don't know the value of $tmp, we can pass the check simply by using TRUE. When we look at the very next comment in the _get_data function, we see this beautiful statement:
// if we get here, the user is authenticated.
Now we know that we have succeeded. However, if we created a cookie and tried to log in, we'd still have one tiny problem.
One Final Setback
This is the error message that tells us that we are missing the 'User key'.
We can see that there is a session variable whose name is the value of the CMS_USER_KEY constant in the code above. It is assigned by retrieving the cookie with the name stored in the constant CMS_SECURE_PARAM_NAME. When searching for the code that defines the secure param name, we end up in the file lib/include.php.
The value is just the string '_sk_'. I've seen this name before. It turns out that this is the name of the CSRF token parameter. In order to get rid of the error, we have to pass a cookie named _sk_ with any string we want (for example the string 'pwned'). This will make 'pwned' our CSRF token value. When we return to the admin panel, we will be logged in!
Writing an Exploit to Gain Remote Code Execution
Now that we are logged in, we can perform any administrative action we want. However, often this is not enough. In order to pivot into an internal network or steal sensitive data, we actually need a proper shell. In PHP, the default way to do this is to upload a file with the .php extension, and then execute system commands. This is easier said than done.
Even though we can upload any file using the file manager, we can't upload files with the .php extension. This is because the input is filtered and any file with an extension beginning or ending with php will be rejected. We could circumvent this restriction by uploading an .htaccess file, and force any other extension to be recognized as a PHP file, for example .pwn or (since it's sometimes already registered) use .pht as the extension instead. But we can't depend on this being the case for every server, and .htaccess files are often disabled. However, it's possible to bypass the check in Windows. Let's take a look at the filter.
This will take the text after the last '.' character and save it in $ext. After that, it will check if $ext begins or ends with the string 'php'. One trick for Windows is to save the file as 'exploit.php.' instead of 'exploit.php'. Notice the additional '.' at the end of the first variation. The check will still try to retrieve the string following the final period. This is now empty, since nothing comes after the final period. So it doesn't contain 'php' and the check passes. However, this file would no longer be recognized (at least in Linux) as a PHP file, because the extension is missing. Windows, on the other hand, does us a huge favor. To see why, look at the commands I issued.
As you see, I tried to save the text 'hello' in the file 'exploit.php.'. But when I listed the contents of the directory, it was actually saved as 'exploit.php' without the extra period. By employing '.php.' as the extension, we bypass the filter but still have a valid php extension. The only drawback is that this behaviour doesn't work in Linux. Since it's not useful for writing a reliable exploit for both Windows and Linux, we need to find another way to upload files.
CMSMS has a lot of modules that can extend the functionality of the CMS. We can actually upload our own from within the administrator panel. These files are uploaded via an .xml file that describes all the folders, files and content that are needed for the extension to work. This will be our method of uploading a PHP file containing malicious code. We can have an extension with any name containing our shell.
Since the metasploit framework is perfect for this kind of automated exploit, I have written a module for it. Below you can see it in action.
What You Can Learn From CMSMS Mistakes
Forgetting even a single character can have a huge impact on the security of your application. Unfortunately, writing web applications is not always easy. There is a lot to keep in mind and a lot more to mess up. This is why it's important to know the ins and outs of the programming language of your choice, and to read the documentation regarding dangerous functionality to understand its implications.
I'm sure that the developers of CMSMS knew about loose comparison and its pitfalls, but just weren't paying close attention at the time. But this is exactly what makes this type of vulnerability so dangerous. If you don't pay close attention, you end up introducing a flaw that is so easy to exploit, it's like taking candy from a baby. Whether or not this was a food metaphor is left to the judgement of the reader.
The good news is that fixing it is straightforward. You don't need to be a web security expert to be able to use the '===' operator on critical comparisons. In most cases, it doesn't take a long time to search for the '==' operator in your code and replace it where you need to. So as easy as it is to exploit those vulnerabilities, it is just as easy to fix them.
PHP Type Juggling Vulnerabilities Presentation
In the video below you can see a presentation about PHP Type Juggling on the Security Weekly show. We talked about why developers use weak type comparisons, how to avoid them and highlight a few of the unexpected results of PHP Type Juggling.