Home / Blog / Writing Exploits For Exotic Bug Classes: PHP Type Juggling

Writing Exploits For Exotic Bug Classes: PHP Type Juggling

Welcome to the second part of the series on exploiting exotic bug classes. I’d like to re-iterate why this series exists. I was tired of reading constant articles on new xss, sqli, lfi, and etc. so I started a series on more ‘exotic’
bug classes. This is a series on exploiting bugs that aren’t talked about as often as they should be, but yet have serious repercussions. This is part 2 of a 4 part series. Hope you enjoy!

Part 2.) PHP Type Juggling

This article is on exploiting another magical feature of PHP, type juggling. We will go over what it is, examples of comparison issues, examples of other mathematical operation issues, the problem with str(n)?cmp/str(n)?casecmp,
and finally abuse a real world application. The real world application is Simple Machine Forums (SMF) for a bug that was publicly disclosed the beginning of this year (2013). This article will be released with an easier to consume ODP presentation and
python code for a first public exploit release for this vulnerability. Look at the bottom for links to these extra materials. If you don’t understand something in the code, re-read this article.

What is Type Juggling?

Type juggling in PHP is caused by an issue of loose operations versus strict operations. Strict comparisons will compare both the data values and the types associated to them. A loose comparison will use context
to understand what type the data is. According to PHP documentation for comparison operations at http://php.net/manual/en/language.operators.comparison.php: “If
you compare a number with a string or the comparison involves numerical strings, then each string is converted to a number and the comparison performed numerically. These rules also apply to the switch statement. The type conversion does not take place
when the comparison is === or !== as this involves comparing the type as well as the value.” All of this will be covered later with examples, but the main takeaway is if the strings are numerical then they are converted to integers or floats. This can
change the way the operation works completely. Of course, there are certain qualifications that must be taken in order for a string to be deemed a “numerical string”.

Conversion Qualifications

According to php.net on string conversion rules at http://www.php.net/manual/en/language.types.string.php#language.types.string.conversion:
“If the string does not contain any of the characters ‘.’, ‘e’, or ‘E’ and the numeric value fits into integer type limits (as defined by PHP_INT_MAX), the string will be evaluated as an integer. In all other cases it will be evaluated as a float. The
value is given by the initial portion of the string. If the string starts with valid numeric data, this will be the value used. Otherwise, the value will be 0 (zero). Valid numeric data is an optional sign, followed by one or more digits (optionally
containing a decimal point), followed by an optional exponent. The exponent is an ‘e’ or ‘E’ followed by one or more digits.” So if a string is all numbers, fits inside of PHP_INT_MAX (signed int max 2147483647), and does not contain a period or exponent
characters, then it is treated as an integer. Otherwise it is treated as a float value. The float value is the interesting part of the situation. You can learn a lot more about the rules of the float conversion by understanding it uses strtod to convert
an ascii string to float values. If we man strtod, the rules are: “The expected form of the (initial portion of the) string is optional leading white space as recognized by isspace(3), an optional plus (‘+’) or minus sign (‘-‘) and then either (i) a
decimal number, or (ii) a hexadecimal number, or (iii) an infinity, or (iv) a NAN (not-a-number). A decimal number consists of a nonempty sequence of decimal digits possibly containing a radix character (decimal point, locale-dependent, usually ‘.’),
optionally followed by a decimal exponent. A decimal exponent consists of an ‘E’ or ‘e’, followed by an optional plus or minus sign, followed by a nonempty sequence of decimal digits, and indicates multiplication by a power of 10. A hexadecimal number
consists of a “0x” or “0X” followed by a nonempty sequence of hexadecimal digits possibly containing a radix character, optionally followed by a binary exponent. A binary exponent consists of a ‘P’ or ‘p’, followed by an optional plus or minus sign,
followed by a nonempty sequence of decimal digits, and indicates multiplication by a power of 2. At least one of radix character and binary exponent must be present.” A very important note here is how the exponent works. The exponent values are actually
treated as multiplication by a power of 10. So if the value was 00e13242 then the actual value is 0 as anything multiplied by 0 is 0. However, if the value is 1e2 then the value is 100 or 1*10*10. With this understanding, let’s look at issues that are
formed around this feature.

Comparison Issues and Examples

If a string is converted to a float then we can match much more than possibly expected by the application. Anything that follows the regex of 0+[eE]\d+ will result in 0. People would think that 1[eE]\d+
would also work as 1 to the power of anything is 1, but this is not how the strtod() function actually works. Now, let’s take a look at some comparison examples to visually see this working. $input = $_GET[‘input’]; if ($input == “0e94323″) { print(“$input
== 0e94323 “); } if ($input == “00e19384″) { print(“$input == 00e19384 “); } localhost/test.php?input=0 0 == 0e94323 0 == 00e19384 Remember, this is caused by use of the loose comparison operation with ==.

For examples of the different contextual changes that PHP will do with loose operations, I suggest you look at the spreadsheet provided by Gynvael Coldwind available at https://docs.google.com/spreadsheet/pub?key=0Apy5AGVPzpIOdHREMVpyU0JBak5GcURZZGpQbGRqb0E&output=html. PHP also provides a much more simplistic table at http://www.php.net/manual/en/types.comparisons.php.

Math is Crazy

After the comparison operation fun some readers might be wondering about other mathematical operations. Well don’t worry, there’s a degree of craziness involved in other operations as well. Take this example PHP code: if
($input < “30”) { print(“$input < 30\t”); print((int)$input); } It looks like a pretty sane example. In fact, it even acts that way most of the time: localhost/test.php?input=1 1 < 30 1 localhost/test.php?input=111 Nothing is output as 111
is not less than 30. One might ask what exactly the issue is then if it’s working normally? Well, if an ascii character is present in this operation then the string comparison will use natural sorting on the string. This will cause only the first digit
to be evaluated in the operation. With this in mind, one can do: localhost/test.php?111a 111a < 30 111 As you can see, the operation decided that 111 was less than 30 and the operation succeeded. Math is indeed crazy. str(n)?cmp/str(n)?casecmp

Problem

Although this is not necessarily a type juggling issue this will be covered for completeness sake of comparison issues. When faced with these issues, or coming from a C background, people tend to look at other functions like strcasecmp.
You see this in quiet a bit of PHP code for string evaluation. In a normal case, and according to php.net/strcasecmp, the function follows this guideline: “Returns < 0 if str1 is less than str2; > 0 if str1 is greater than str2, and 0 if they
are equal.” This seems sane, check against 0 or do a ! to see if the two provided strings match. Like: if (strcasecmp($_GET[‘pass’],”pass”) == 0) { If (!strcmp($_GET[‘pass’],”pass”) ) { Both of these functions look like they would only evaluate if both
of the strings are true as the function should return 0 only if they are equal. Interestingly, there is another case that will be fully accepted. If an array is passed into the variable then a NULL is given. According to the comparison charts as given
above, NULL is actually 0. So this function can be bypassed completely if an attacker were to provide: https://securesite.com/login.php?user=admin&pass[]=whatever

Real World Attack – Simple Machine Forums

As always, it’s time to have fun and create an exploit for a recent vulnerability in real world software. This type juggling vulnerability effects SMF versions <=2.0.3 and <=1.1.17. These
are two different branches of SMF that are actively maintained. The vulnerability was found by Arseny Reutov and the vendor released a fix early this year on January 02, 2013.

The Vulnerability

In Sources/Reminder.php, line 199 in 2.0.3, the start of the vulnerable function setPassword2() is seen: checkSession();

[1] if (empty($_POST[‘u’]) || !isset($_POST[‘passwrd1′]) || !isset($_POST[‘passwrd2′]))

[2] fatal_lang_error(‘no_access’, false); … // Is the password actually valid? require_once($sourcedir . ‘/Subs-Auth.php’); $passwordError = validatePassword($_POST[‘passwrd1′], $username, array($email));

[3] … if (empty($_POST[‘code’]) || substr($realCode, 0, 10) != substr(md5($_POST[‘code’]), 0, 10))

[4] { // Stop brute force attacks like this. validatePasswordFlood($_POST[‘u’], $flood_value, false);

[5] fatal_error($txt[‘invalid_activation_code’], false); } … setPassword2() is a function that is used to validate a ‘forgot password’ token. A user will click ‘forgot password’, be emailed a token, and then made to validate that token
in a web form. If we are able to complete the validation procedure, we can reset any user’s password with whatever we chose without actually needing the email. But we will need to know how to trigger the vulnerability by following and completing the
above path first.

Path to Exploitation

The path to exploitation is much simpler than any of the other articles. At [1] a valid session is required and grabbing and using the PHPSESSID cookie is all that is needed: if ($type == ‘post’) { $check = isset($_POST[$_SESSION[‘session_var’]])
? $_POST[$_SESSION[‘session_var’]] : (empty($modSettings[‘strictSessionCheck’]) && isset($_POST[‘sc’]) ? $_POST[‘sc’] : null); The required POST values are seen at [2]. $_POST[‘u’] is the member_id. If you’re not attacking user id 1, like if
you decide to attack all the admins/mods at the same time for a higher time to success, then you just need to click on the username and look in the URI. The value u=# will be seen and give you the user id you need. The other values are passwrd1 and
passwrd2. They must match the validatePassword() function at [3], which we will not get into details of here. The final post value is seen in the vulnerable line of code at [4] in the above setPassword2() function. At [4] is where the type juggling
vulnerability takes place. We see a loose comparison operation between 10 bytes of a variable called $realCode and 10 bytes of an md5 hash of our posted code value. First, let’s take a look at what $realCode is and how it is generated. By searching
Sources/Reminder.php, this is the first step: $password = generateValidationCode(); … if (empty($row[‘openid_uri’])) // Set the password in the database. updateMemberData($row[‘id_member’], array(‘validation_code’ => substr(md5($password), 0, 10)));
… $request = $smcFunc[‘db_query’](”, ‘ SELECT validation_code, member_name, email_address, passwd_flood … list ($realCode, $username, $email, $flood_value) = $smcFunc[‘db_fetch_row’]($request); Here $password is created by the generateValidationCode()
function, saved into the database as 10 characters of an md5 hash of $password, then later retrieved and saved as $realCode. So the next question ends up being, what does generateValidationCode() look like? The answer is found in Sources/Subs-Members.php:
function generateValidationCode() { global $smcFunc, $modSettings; $request = $smcFunc[‘db_query’](‘get_random_number’, ‘ SELECT RAND()’, array( ) ); list ($dbRand) = $smcFunc[‘db_fetch_row’]($request); $smcFunc[‘db_free_result’]($request); return substr(preg_replace(‘/\W/’,
”, sha1(microtime() . mt_rand() . $dbRand . $modSettings[‘rand_seed’])), 0, 10); } I’m not great at cryptographic attacks, but with the seeds from the database, this isn’t something we could easily compute locally. Instead, we’ll have to hammer away
requests. Now that we know how $realCode is generated, and more importantly that it’s 10 characters of an md5 hash, how do we write an exploit for this vulnerability?

Generating A Type Juggle

Again, if we look at [4] above, notice that the posted code is actually md5 hashed and compared with another md5 hash. The greatest advantage is that only 10 bytes of the hashes are actually used. Therefore if
both sides achieve 0+[eE]\d+ then the comparison will fall through. One of the values in the operation is user controlled, which is substr(md5($_POST[‘code’]),0,10). Whats required next is to make sure that the controlled code will generate an appropriate
0+[eE]\d+. To do this a simple python script was created: import hashlib,re f = open(“wordlist”,”r”).readlines() findit = re.compile(“^0e[0-9]{8}”) #\d supports unicode, 0-9 is faster for entry in f: entry = entry.rstrip(“\n”) # strip new line m = hashlib.md5(str(entry)).hexdigest()
# save md5 hash instead of reference m = m[0:10] # substr(m,0,10) if (findit.search(m) != None): # if match found print(“%s: ” % str(m)), # print hash: print(str(entry)) # print wordlist entry With a wordlist containing 000000-999999, and the too simplistic
regex, 101 results were found. If the regex was ^00e[0-9]{7} then 8 results were found. Finally, if it was ^000e[0-9]{6} then 2 results were found. This gives 111 working values from the example wordlist to use.

A randomly chosen value “190539” was chosen for the exploit which, when md5 and substr’d, is actually “0e25261622″ or 0. Now we just need $realCode to match as well, so we can complete the comparison. In order to do this keep requesting a new token by
submitting forgot password requests and then checking them against our posted code of 190539. If they match then the password is changed, if not keep cycling. Unfortunately, we do hit one snag at 5 in the setPassword2() function above where validatePasswordFlood()
resides. validatePasswordFlood() Before we continue, if the target is of the 1.1.x branch, this function isn’t used and the amount of guesses are not limited. You can spam guesses as quick as you and the webserver can handle. If the
target is of the 2.0.x branch then there’s a guess limitation function. The validatePasswordFlood() function is located in Sources/LogInOut.php: // Destroy any session or cookie data about this member, as they validated wrong. require_once($sourcedir
. ‘/Subs-Auth.php’); setLoginCookie(-3600, 0);

[1] … // Right, have we got a flood value? if ($password_flood_value !== false) @list ($time_stamp, $number_tries) = explode(‘|’, $password_flood_value); // Timestamp invalid or non-existent? if (empty($number_tries) || $time_stamp <
(time() – 10))

[2] { // If it wasn’t *that* long ago, don’t give them another five goes. $number_tries = !empty($number_tries) && $time_stamp < (time() – 20) ? 2 : 0;

[3] $time_stamp = time(); } $number_tries++; // Broken the law? if ($number_tries >5)

[4] fatal_lang_error(‘login_threshold_brute_fail’, ‘critical’); For a high level overview, if we go over 5 attempts in 10 seconds we will not be allowed to try again until the 10 seconds from the first attempted request is finished.

For a more technical overview, at [1] the session cookies are deleted. This means after each token guess attempt we’ll need to grab the PHPSESSID cookie value again. This can be done straight from the reply on the guess attempt. At [2] is the start of
the time validation procedure. If $time_stamp (a value for time() on the first try cycle) is less than the current time-10s or there is no current try attempts, enter the loop. What this really means is if we’re over 10 seconds since we generated the
time stamp, enter into the loop. First, what happens if the loop is not entered? The $number_tries is increased and the check at [4] is hit. This simply checks if the number of tries in this 10 second period is higher than 5. If it is, then fail and
force the user to wait out the rest of the 10 second timer. Humorously, the error message states you are forced to wait 30 seconds, however, this is not true and only need to wait out the 10 seconds since our first attempt. Now, if the 10 second period
is over then the loop at [2] is entered. The check inside the loop at [3] is to see if the $time_stamp is over 20 seconds. Here’s another humorous discrepancy. If it’s over 20 seconds, it actually sets the number of tries to 2 and if it’s under 20 seconds
then it sets it to 0. Therefore, when we enter the loop after the 10 second period and before 20 seconds are up, we will get reset to 0 allowing five more attempts. If it’s entered after a 20 second period, we will get reset to 2. This is why if there’s
a saved time stamp from long ago in the db, the person only gets 3 tries instead of the normal 5 on the first cycle. This is the opposite logic then stated in the comment above the check at [3].

Post Compromise

While this has not been built into the exploit itself, post compromise for arbitrary code execution is easy once you have admin credentials. Simply create your own backdoor package or upload a known vulnerable package
of your choosing. Honestly, making your own backdoor package to download is really easy. You can even host it externally and bring it in via URL making it that much easier to add to the exploit.

Conclusion and Exploit

After reading all of this you should be able to understand the exploit and all the stages required for it. The exploit itself takes quiet some time unless you’re lucky. You could get a type juggle match on the first
try or the ten thousandth try. After trying a variety of tests, the maximum amount of attempts the exploit took was 8.6k. It would be best to figure out times when the victim is not online, like sleeping or out of reach, as my own tests have shown about
1-1.4k attempts in a given hour. Of course, targeting 1.1.x branch will be much quicker. smf_juggle.py PHP Type Juggle Presentation (ODP)

Click to watch our MDR demo

Tyler Borland
About the Author

Related Post

Ready to protect your company with Alert Logic MDR?