How To Find XSS (Cross-Site Scripting) Vulnerabilities in WordPress Plugins and Themes

Yesterday, we announced the WordPress XSSplorer Challenge for the Wordfence Bug Bounty Program. The objective of this promotion is to help beginners get started in WordPress bug bounty hunting by opening up the scope of our Bug Bounty Program. Cross-Site Scripting vulnerabilities reported in plugins and themes with over 1,000 active installations are in-scope for all researchers regardless of their tier through October 7th, 2024.

This will give beginners time to focus on one of the most common vulnerabilities across a large scope allowing them to sharpen their skills and apply that to future research.

To help those of you who are interested in this challenge and learning more about Cross-Site Scripting vulnerabilities in WordPress, we’re publishing this guide on how to find and exploit Cross-Site Scripting vulnerabilities in WordPress plugins and themes.

Cross-Site Scripting (XSS) is one of the most common and dangerous vulnerabilities in web applications, including WordPress plugins and themes. Specifically in WordPress, Cross-Site Scripting can be leveraged to inject malicious web scripts that can be used to steal sensitive information, manipulate site content, inject administrative users, edit files, or redirect users to malicious websites.

It’s important to note that, in the case of WordPress, adding administrative users with attacker-controlled credentials and editing files could lead to a complete site compromise and we’ve seen it actively targeted in the past.

This guide will walk you through the process of identifying and exploiting XSS vulnerabilities, starting from first principles, and will emphasize WordPress-specific examples.

How XSS Works

Web browsers are the locally installed applications we use to run and view the output of web applications. They are designed to interpret HTML and use JavaScript engines (like Google’s V8, used in Chrome; Mozilla’s SpiderMonkey, used in Firefox; Microsoft’s Chakra, used in Edge; and Apple’s JavaScriptCore, used in Safari) to interpret JavaScript code that is used by web applications to provide an interactive experience.

Web applications often need to consume user-supplied input, such as through form submissions. This user input is processed by the application and frequently displayed back to users in some way. This input can be incorporated into the HTML output by the web application and, if it’s JavaScript, can be interpreted (executed) by the browser.

When user input is handled incorrectly and displayed without proper sanitization or escaping, it can introduce an XSS vulnerability. XSS occurs when an attacker is able to inject malicious scripts into these inputs. The fundamental concept of XSS is that user input is not treated as plain text but instead as executable code by the browser, leading to the unintended execution of that code.

Types of XSS

Reflected XSS: This type of XSS occurs when user-supplied input is submitted to the web application and is immediately reflected back in the server’s response to the browser without proper sanitization or escaping. This XSS type generally requires active user-interaction from a targeted user. That means threat actors have to leverage social engineering techniques like phishing to successfully exploit this type of XSS vulnerability.


Figure 1: A generic example of an attacker exploiting a reflected cross-site scripting vulnerability.

Stored XSS: This type of XSS occurs when malicious input submitted to the web application is stored in the database and later displayed to users (e.g., when loading a web application page) without proper sanitization or escaping.

In WordPress, you might find that the ability to submit vulnerable input might be hindered by the requirement for authentication as a subscriber-, contributor-, author-, editor-, or admin-level user. However, there are many situations in which unauthenticated users could also submit malicious input (e.g., sign-up and registration forms, comments, reviews, etc.).


Figure 2: A generic example of an attacker exploiting a stored cross-site scripting vulnerability.

DOM-Based XSS: A bit more rare in the WordPress space, this XSS occurs when the browser processes data from the DOM (Document Object Model) that hasn’t been sanitized, leading to script execution. This means we need a way to get our payload into the DOM and there must be client-side JavaScript that consumes this data and places it elsewhere into the DOM in a way that can be rendered by the browser.


Figure 3: A generic example of an attacker exploiting a DOM-based cross-site scripting vulnerability.

How to Find XSS in WordPress

These days, it’s rare to find an XSS vulnerability in WordPress core code. However, the flexibility and extensibility provided by the WordPress plugin and theme ecosystem often allow for vulnerabilities to be introduced if specific WordPress security guidelines are not followed or misinterpreted by developers.

Static Analysis and Dynamic Analysis

Before we can find XSS vulnerabilities, we must understand the concept of static code analysis. This is the act of looking for vulnerabilities in the source code of an application without actually executing it. In other words, you’re gaining an understanding of the code’s logic by reading it and working through what each file, class, and function is used for.

Sometimes, you may only get a “loose” understanding of the code during static analysis and you may need to perform dynamic analysis – which involves executing the code by loading pages, pressing buttons, submitting test input, and setting breakpoints within a debugger and walking through the code’s functionality step-by-step to get a complete picture.

Finding XSS Vulnerabilities in WordPress Plugins and Themes

Finding WordPress XSS vulnerabilities in plugins and themes requires a fundamental understanding of how web applications consume data, process the data, and output the data. In cyber security, this is generally conveyed as sources, data flows, and sinks, respectively.

Sources: Sources are essentially where user-controllable inputs come from. They are the entry points into the application. Sources can be things like HTTP method parameters, cookies, HTTP headers, database inputs, and third-party integrations like embedded content or RSS feeds.

Sinks: Sinks are “dangerous” functions, or functions that should not be executed using user-controllable input without authorization, validation, sanitization, or escaping. In the case of XSS, these are going to be WordPress-specific functions or PHP functions that print or echo data stored in variables.

Data Flow: Data flow is the path that user-supplied data takes from source to sink. It’s important to map this data flow to identify potential vulnerabilities. As the data supplied to a source makes its way to a sink, it may be modified. Understanding these modifications may allow you to bypass any attempted mitigation.

Example XSS Data Flow

Finding sources and making note of their data flow can help us understand how the application handles input. The following example shows the name parameter in the $_GET superglobal as our source. The value of this parameter is assigned to the $name variable and then it is passed to our sink, the echo() function.

Figure 4: A simple example of a data flow from source to sink

In the example below, our source is the WordPress database. The get_user_data() function retrieves some user information from the database and stores it to the $user_data array. The username value of the $user_data array is then stored to the $username variable. This variable is then passed to the esc_html() WordPress function and then stored in the $escaped_username variable and it is finally passed to our sink, the echo() function.

Figure 5: A more complex example of a data flow from source to sink

Methodology

Successful discovery of XSS vulnerabilities requires a solid and repeatable research methodology. The most efficient methodology is to take a white-box approach and analyze the source code of various plugins and themes while following the steps below:

  1. Search for sources and make note of them
  2. Search for sinks and make note of them
  3. Map the data flow from source to sink OR sink to source
  4. Make note of any modifications or transformations of input data from source to sink or vice versa
    1. Does the input run through a validation, sanitization, escaping, or decoding function?
      1. Is it implemented correctly?
    2. Is the input modified by other functions – what elements are added or removed?
  5. How is the output echoed or printed?
    1. Is the output placed within an HTML tag, a tag attribute, or between other tags?
    2. How is the output concatenated with other strings? Are they quoted correctly?
  6. How does the output look in the HTML of a rendered page when a special character, such as single quote (), or an HTML tag, such as <script> is passed?

Alternatively, you can use a black-box approach of installing various plugins/themes and then analyzing them while interacting with them as a user. Considering you will always have access to the source code for WordPress plugins and themes, we don’t recommend taking this approach from an efficiency standpoint though it is definitely a great way to get started and get familiar with the functionality of a plugin or theme before diving into the code analysis.

Finding Sources and Sinks

The following are a list of WordPress sources and sinks relevant to XSS. You can search for these inputs and outputs in WordPress plugin and theme code.

Common XSS Sources

PHP Superglobals Database Inputs Third-Party Integrations Alternative Input Streams
$_POST Options Table External APIs php://
$_GET User Meta Table Webhooks filter_input()
$_REQUEST Post Meta Table Embedded Content
$_COOKIE Custom Tables RSS Feeds
$_FILES
$_SERVER
$_SESSION
An Important Note on Sinks in PHP

In PHP, you don’t need a function like echo() or sprintf() to render the value of a variable into HTML markup. This characteristic of PHP can make finding XSS sinks more challenging, as dynamic content can be embedded directly within HTML without the use of traditional sink functions.

Here are a few examples to illustrate this point:

1. Embedding PHP Variables Directly in HTML

Sometimes, developers embed PHP variables directly into HTML without using a function. This is often seen in templates or view files where PHP and HTML are mixed to generate dynamic pages.

<?php
$message = $_GET['message']; // user-supplied input.
?>
<html>
<body>
	<h1>Messages</h1>
	<h2><?php echo $message; ?></h2> // Using echo()
	<h2><?= $message; ?></h2> // Short-hand for echo
</body>
</html>

2. PHP with HTML Concatenation

HTML can also be dynamically generated by concatenating PHP variables directly into the HTML string. This method might also avoid using typical functions like echo() or printf().

<?php
$username = $_GET['username']; // User-supplied input
?>
<html>
<body>
	<div>
    	<?php
    	echo "<p>Welcome, " . $username . "!</p>";
    	?>
	</div>
</body>
</html>
Searching for Sources and Sinks in WordPress Plugin or Theme Code

You can use an Integrated Development Environment (IDE), a text editor, or command-line tools like grep to find sources in WordPress plugin or theme code with a simple regular expression such as the following:

\$_(GET|POST|REQUEST|COOKIE|FILES|SERVER|SESSION)\b|\b(get_option|get_user_meta|get_post_meta|get_comment_meta|get_site_option|get_network_option|apply_filters|do_action)\b


Figure 6: Searching for sources using a regular expression in Visual Studio Code

You can also search for sinks using a regular expression like in the following example.

\b(echo|print|printf|sprintf|die|wp_die)\s*\(


Figure 7: Searching for sinks using a regular expression in Visual Studio Code

Real World XSS Discovery Examples

In this section, we’ll outline a couple of real world XSS examples by walking through the sources, sinks, data flow, and exploitation techniques.

💡 How do I download older (vulnerable) versions of WordPress plugins?

  • Navigate to the WordPress plugins repository at https://wordpress.org/plugins/
  • Search for the plugin by name or slug
  • In the Details (default) tab, click the Advanced View link (CTRL + F to search for “Advanced View”)
  • Scroll down to the bottom of the page and you’ll see a drop down list of versions under Previous Versions
  • Select your version and click the Download button
  • Alternatively, you can navigate to https://downloads.wordpress.org/plugin/{plugin-slug}.{version}.zip

Let’s walk through finding and exploiting CVE-2024-2738, a reflected XSS in the Permalink Manager Lite and Permalink Manager Pro plugins versions 2.4.3.1 and prior. This vulnerability was submitted through the Wordfence Bug Bounty program and received a $207.00 bounty. For the purpose of this demonstration, we will be using version 2.4.3.1 of Permalink Manager Lite.

Finding this vulnerability is difficult, because there is quite a bit of code to go through in the results of our search using our source and sink regular expressions. The sink search returns much fewer results than the source search, so we use that as our starting point.


Figure 8: A code search in Visual Studio Code using a regular expression to find XSS sinks in ./wp-content/plugins/permalink-manager

In our search results, we see a sink, wp_die() operating on a variable, $debug_txt in permalink-manager-uri-editor-post.php. According to the WordPress function reference for wp_die(), this function “Kills WordPress execution and displays HTML page with an error message.”

As shown in Figure 9 below, when we open this PHP file in Visual Studio Code, we can trace $debug_txt on line 300 back to its source on line 244 where the user-controlled parameter s is passed in via either a GET or POST request. The s parameter is immediately passed to the esc_sql() function, which is designed to escape certain characters in strings for use in an SQL query. Here are some characters that esc_sql() escapes and what the escaped value looks like after it’s run through this function:

Single quote ('): Escaped as \'
Double quote ("): Escaped as \"

This means that we won’t be able to use single and double quotes in our XSS payload, so we’ll make note of that. This function will return the SQL escaped value of s and store it into the $search_query variable. The $search_query variable is then used in the value assigned to the $sql_parts[‘where’] variable, which is then passed to the $sql_query variable, which is then used in the $debug_txt variable, which is then used by our sink, wp_die(). Note that, in order for wp_die() on line 300 to be triggered, the condition of isset($REQUEST[‘debug_editor_sql’]) must return true.


Figure 9: Following the data flow from sink to source in permalink-manager-uri-editor-post.php

Our notes should now include the following:

  • Source: $REQUEST[‘s’]
  • Sink: wp_die()
  • Payload cannot include single or double quotes as they will be escaped and therefore not interpreted.
  • $REQUEST[‘debug_editor_sql’] needs to be set.

The comment at the top of the permalink-manager-uri-editor-post.php file gives us a clue as to where this code is executed.


Figure 10: The path, filename, and comment for permalink-manager-uri-editor-post.php give us a clue as to where this code is loaded and used in the plugin

Looking at the settings of the plugin within our WordPress instance, we find the URI editor at wp-admin/tools.php?page=permalink-manager&section=uri_editor.


Figure 11: The Permalink Manager plugin URI Editor page

From here, we append our payload.

wp-admin/tools.php?page=permalink-manager&section=uri_editor&debug_editor_sql=1&s=<script>alert(0)</script>

We submit this request with our payload, but the payload does not fire.


Figure 12: The DOM contains the XSS payload, but it does not fire

Evaluating the DOM of the page shows that our payload of <script>alert(0)</script> is inserted into the page within a SQL query that is within a <textarea> element. Because we know we can insert HTML tags, we can try to close the <textarea> element before our <script> payload.

wp-admin/tools.php?page=permalink-manager&section=uri_editor&debug_editor_sql=1&s=</textarea><script>alert(0)</script>


Figure 13: The XSS payload fires

Passing the </textarea> tag before our </script> payload closes the parent tag and allows the <script> payload to be interpreted as part of the DOM, allowing for successful exploitation of this vulnerability.


Figure 14: The DOM shows our inserted payload

What does esc_sql() do when we put quotes in the payload?


Figure 15: Placing quotes () in the payload

As we can see in Figure 16 below, the quotes are escaped using backslashes, effectively neutralizing the payload.


Figure 16: Quotes (“) are escaped and neutralized with backslashes

Example 2: CVE-2024-1852 – WP-Members Membership Plugin <= 3.4.9.2 - Unauthenticated Stored Cross-Site Scripting

In this example, we’ll walk through CVE-2024-1852, a stored Cross-Site Scripting example in the WP-Members Membership plugin versions 3.4.9.2 and prior. This vulnerability was submitted through the Wordfence Bug Bounty program and received a $500.00 bounty. For the purposes of this demonstration, we will be using version 3.4.9.1 of the WP-Members Membership plugin.

We first start by searching for sinks using a modified regular expression because, while browsing through the plugin code, we notice the use of echo without parentheses.

\b(echo|print|printf|sprintf|die|wp_die)\b(\s*)?(\(|[^\s(]+)

This regular expression will search for sinks within our plugin code, but include cases where there is not an open parenthesis (() after the sink function name.


Figure 17: Searching for XSS sinks in ./wp-content/plugins/wp-members

This leads us to the _show_ip() function of the WP_Members_User_Profile class. The comments above this function clue us into its purpose and the code shows that a table row (<tr>) is printed with a value derived from the wpmem_reg_ip user meta. We also see this row is labeled with IP @ registration. Presumably, this field shows a user’s IP address recorded at the time of registration.


Figure 18: The _show_ip() function in class-wp-members-user-profile.php

Doing a search for wpmem_reg_ip shows that this user meta is set by the update_user_meta() function in the register_finalize() method of the WP_Members_user class in the class-wp-members-user.php file. The value that this user meta is set to is derived from the wpmem_get_user_ip() function.


Figure 19: The update_user_meta() function used in the register_finalize() method in class-wp-members-user.php

The wp_mem_get_user_ip() function in api-users.php applies the wpmem_get_ip filter, which calls the rktgk_get_user_ip() function.


Figure 20: The wpmem_get_user_ip() function api-users.php

The rktgk_get_user_ip() function first looks at the value of $_SERVER[‘HTTP_CLIENT_IP’] for the users IP. However, HTTP_CLIENT_IP in the $_SERVER superglobal must be configured on the server and is rarely set. If this value isn’t set, then the IP is derived from $_SERVER[‘HTTP_X_FORWARDED_FOR’], which is user-controlled and can be set by submitting an X-Forwarded-For HTTP header in a request.


Figure 21: The rktgk_get_user_ip() function in utilities.php

Looking back to our update_user_meta(), we made a note that this function is called via the register_finalize() method. Searching our code for this function shows that it is hooked to the user_register action.


Figure 22: The user_register action hooked to the register_finalize() method

Reading a bit of the documentation for the plugin shows that the plugin makes a registration page available to users on all posts by default. After all, the intent of this plugin is to allow users to register as a member to gain access to content on the site.

To exploit this vulnerability, we navigate to a post as an unauthenticated user and submit a registration request.


Figure 23: The plugin’s user registration form

We intercept the request and add an X-Forwarded-For HTTP header containing our XSS payload.


Figure 24: Intercepting and adding an X-Forwarded-For header with XSS payload in a user registration request

The fact that the value of X-Forwarded-For is stored in the database and retrieved later (when the user’s profile is viewed) makes it a stored XSS. As the admin user, we’ll navigate to the Users -> All Users page in our WordPress site’s admin dashboard and edit our newly registered user.


Figure 25: Editing the newly added user’s profile as an administrator

Upon loading the profile of malicioususer, our payload fires.


Figure 26: The XSS payload fires when the user’s profile is loaded

Viewing the page source, we can see where our payload has been inserted.


Figure 27: Viewing the inserted XSS payload in the DOM

Example 3: CVE-2024-2830 – WordPress Tag and Category Manager – AI Autotagger <= 3.13.0 - Authenticated (Contributor+) Stored Cross-Site Scripting via Shortcode

In this final example, we’ll walk through CVE-2024-2830, a stored Cross-Site Scripting vulnerability in the “WordPress Tag and Category Manager – AI Autotagger” (a.k.a. “TaxoPress”) plugin versions 3.13.0 and prior. This vulnerability was submitted through the Wordfence Bug Bounty program and received a $94.00 bounty. For the purposes of this demonstration, we will be using version 3.13.0 of this plugin.

Shortcode XSS vulnerabilities in WordPress plugins and themes are ubiquitous. A shortcode is a special tag enclosed in square brackets ([ ]) that enables contributor-level users and above to quickly and easily add dynamic content to posts, pages, and widgets without needing to write code.

Contributor-level and above users can add shortcodes to posts. Shortcodes can take user supplied attributes ([shortcode_tag attribute=”hello!”]) or content ([shortcode_tag] content [/shortcode_tag]). Sources from Shortcode XSS vulnerabilities generally come from these user-supplied attributes or the content placed within shortcode tags. Sinks can be in a variety of formats. The best way to find a Shortcode XSS vulnerability is to search for the WordPress add_shortcode() function within the plugin and theme code. This is where developers will define shortcodes and we can trace inputs and outputs from here.

Searching for add_shortcode() in the ./wp-content/plugins/simple-tags directory yields a few results. We’ll be taking a look at the third result which shows add_shortcode( 'st_tag_cloud', array( __CLASS__, 'shortcode' ) ); in class.client.tagcloud.php.


Figure 28: Searching for add_shortcode() in ./wp-content/plugins/simple-tags

Within this file, we see that the constructor of the SimpleTags_Client_TagCloud class adds the shortcode st_tag_cloud with the shortcode() callback function. We also notice that the st-tag-cloud shortcode has the same callback function.

Moving on to the shortcode() function, we see that it takes the $atts function parameter. Note that all shortcode callback functions take the $atts, $content, and $shortcode_tag parameters by default. In this case, there is no content argument passed to our callback function and the shortcode tag value is irrelevant, so we only need to be concerned with attributes ($atts).

Once we enter the callback function, the shortcode_atts() function is called to merge user-defined attributes with default ones which are passed as an array (line 24). In this case, the params default attribute is set to an empty string if it’s not provided. So we know this shortcode only takes a params attribute.

From here, this attribute is run through the extract() function, which creates PHP variables based on the names of the attributes contained within the passed argument. This gives us the $param variable (which contains any user supplied parameters and their values). This is then passed through html_entity_decode() to convert any HTML entity encoded characters to their respective characters (e.g. &amp; would be converted to &) and then finally through trim() to clean up leading and trailing whitespace.

If $param is empty, it’s set to title=, then it is passed to extendedTagCloud() method as the first argument. This gives us a clue that we can pass title= in the param attribute of the shortcode. So that might look like this:

[st_tag_cloud param=”title=value”]


Figure 29: The st_tag_cloud shortcode is hooked to the shortcode() function

The extendedTagCloud() method consumes the passed $param as $args. If we follow $args, we can see some modifications and then $args gets passed to wp_parse_args() which merges our user-supplied arguments ($args) with the $defaults array of arguments defined earlier in this function and stores it back into $args.


Figure 30: The extendedTagCloud() function processes user-supplied shortcode attributes and generates HTML.

As we continue down the function $args is again passed to extract(). This generates the $title variable, which stores our user-supplied shortcode attributes and their values.


Figure 31: extract() is used to convert parameters into individual PHP variables such as $title

Continuing further down the function, $title is passed as an argument to the SimpleTags_client::output_content method. If $terms (which is generated by a call to getTags()) is empty then this method is called at line 160, otherwise it’s called at line 254.


Figure 32: If $terms (i.e. tags) are empty, then output_content() gets called with our $title argument at line 165


Figure 33: If tags exist output_content() gets called with our $title argument which at line 254

Searching for the output_content() method, we find it in class.client.php. Following $title shows it getting passed to checks as well as trim() to cleanup leading and trailing whitespace and the concatenation of a newline and tab character at the end of the $title value. It is finally returned within other content that generates the shortcode’s final HTML output.

During this entire process, there is only minimal validation with no sanitization or escaping conducted.


Figure 34: SimpleTags_Client::output_content() generates HTML that will be displayed on the page with the [st_tag_cloud] or [st-tag-cloud] shortcode.

While the data flow for this shortcode is complex, focusing on the key items in our methodology shows us that this path is likely exploitable due to improper handling of user-supplied shortcode attributes. Finding this vulnerability and understanding the payload is more easily done through both static analysis with manual testing and debugging with breakpoints set at each step in the process as the shortcode attributes are processed.

Finally, we add the st_tag_cloud shortcode as a logged in contributor-level user.


Figure 35: Adding the [st_tag_cloud] shortcode as a contributor-level user

Within the shortcode, we add our param attribute containing a title attribute and XSS payload as the value. Note that we’ve HTML entity encoded the less-than (<) and greater than (>) HTML tag characters. If you recall in our earlier code analysis, HTML entities are decoded in the shortcode() function. Without this, passing the < or > characters would trigger the WordPress core post content sanitization and our payload would get stripped out.


Figure 36: The st_tag_cloud shortcode inserted with an XSS payload within the param attribute

Once the post containing the payload is submitted by this contributor-level user, it can be previewed by an admin-level user where the XSS payload will be executed in the context of the admin user.


Figure 37: The payload is triggered when an admin-level user previews the post containing the shortcode with the XSS payload

XSS Payloads

There are many great resources out there for common XSS payloads to use for testing. We’ve compiled some of the best as a list below for everyone to use:

PortSwigger Cross-Site Scripting Cheat Sheet
Payload Box XSS Payload List
OWASP XSS Filter Evasion Cheat Sheet
Hacktricks XSS

WordPress XSS Mitigations

When inspecting code for vulnerabilities, it’s important to understand the mitigations developers put in place to prevent XSS vulnerabilities. Knowing what to look for allows you to efficiently skip over secure code and focus on areas that might be vulnerable.

It’s also important to be familiar with the specific use cases of WordPress Security API functions for data validation, sanitization, and escaping. This knowledge enables you to identify both correct and incorrect usage, which is essential when attempting to discover vulnerable code. As you analyze code for potential vulnerabilities, always check whether input is being validated and sanitized, and ensure that all output is appropriately escaped.

Data Validation

Validation is the act of checking input to ensure it’s in the format that is expected. Proper validation can help prevent XSS by rejecting or altering malicious input early.

Common Validation Functions

is_email($email): Checks if the given email is valid.

if (is_email($_POST['email'])) {
// Process the email
}

is_numeric($var): Validates that the input is a number.

if (is_numeric($_POST['amount'])) {
// Process the amount
}

Data Sanitization

Sanitization cleans up input data by removing or altering any unwanted or dangerous characters. This is critical in preventing XSS by ensuring that any potentially harmful code is neutralized.

Common Sanitization Functions

sanitize_text_field($text): Strips unwanted characters from a text field.

$name = sanitize_text_field($_POST['name']);

sanitize_email($email): Removes invalid characters from an email address.

$email = sanitize_email($_POST['email']);

sanitize_file_name($filename): Cleans a file name by removing special characters.

$safe_filename = sanitize_file_name($_FILES['file']['name']);

sanitize_url($url): Sanitizes a URL by stripping out unsafe characters.

$safe_url = sanitize_url($_POST['url']);

tag_escape($tag): Strips HTML tags and attributes that are not allowed.

$safe_tag = tag_escape($_POST['html_tag']);

Data Escaping

Escaping ensures that data output is treated as plain text, not as executable code, by the browser. This is especially important when user input is displayed back to users on pages.

Common Escaping Functions

esc_html($text): Escapes HTML to prevent it from being interpreted by the browser.

echo esc_html($user_input);

esc_attr($attribute): Escapes text for use in an HTML attribute.

echo '<input type="text" value="' . esc_attr($user_input) . '">';

esc_url($url): Escapes URLs to ensure they are safe for output.

echo '<a href="' . esc_url($user_url) . '">User Link</a>';

esc_js($script): Escapes text for use in JavaScript.

echo '<script>var data = "' . esc_js($user_data) . '";</script>';

Common Misuses

While WordPress provides these functions to mitigate XSS, improper use or misunderstanding of these functions can still lead to vulnerabilities.

Examples

The following examples demonstrate how WordPress validation, sanitization, or escaping functions could be used incorrectly or in a way that is deceptive to researchers such that they might be overlooked.

Example 1: CVE-2024-2030 is a stored XSS vulnerability in versions 1.3.3 and prior of the “Database for Contact Form 7, WPforms, Elementor forms” plugin that can be exploited by Contributor+ users. The Wordfence blog post on this vulnerability details how the use of an esc_attr() followed by an esc_html() on user-supplied input introduces the vulnerability.

Example 2: CVE-2024-7588 is a stored XSS vulnerability in versions 2.2.87 and prior of the ComboBlocks plugin that can be exploited by Contributor+ users. This vulnerability builds out a Gutenberg block with user supplied values, including HTML tag values for certain elements within the block. The plugin uses the tag_escape() function to prevent malicious input that would be used in an HTML tag, but fails to take into account that the script tag could be passed along with the contents of what would be contained between those tags (e.g., JavaScript).

Example 3: CVE-2023-1403 and CVE-2023-1404 are stored XSS vulnerabilities in versions 5.0.7 and prior of the Weaver Xtreme theme and versions 1.6 and prior of the Weaver Show Posts plugin, respectively. These vulnerabilities can be exploited by Contributor+ users. As explained in the Wordfence blog post, this theme and plugin both have an out-of-order operation, where an esc_attr(get_the_author()) operation is run before an sprintf() call that uses a format string (%s) placeholder for the returned value of this operation.

Input Sanitization without Output Escaping

One common issue you might come across is plugins or themes using sanitization functions like sanitize_text_field() on user supplied input, but not escaping the input on output. In some cases, this can lead to attribute-based XSS where you simply need to close out existing quotes in order to inject an HTML attribute. Because sanitize_text_field() does not strip or escape quotes, this will lead to an exploitable condition.

Take the following HTML for example:

<?php 

$image_url = sanitize_text_field($_GET['image']);

echo "<img src=\"$image_url\" alt=\"Image\">";

?>

Because the $image_url value is not escaped on output with a function like esc_url(), a user could supply a value like x” onmouseover=alert(0)// which would render as <img src="x" onmouseover=alert(0)//" alt="Image"> and trigger an alert when mousing over the image.

Getting Started

To get started, we recommend joining the Wordfence Discord channel and downloading the Docker configuration for a WordPress local test site from the #resources channel. The configuration, as well as the installation instructions, can be found in a pinned post at the top of the channel.

This Docker configuration will provide a local test environment that includes WordPress, XDebug (along with a debugging configuration for Visual Studio Code), Adminer (a web-based GUI for database operations), Mailcatcher (a web based mail interface for intercepting mail sent by the WordPress instance), and WordPress CLI (a command-line interface for WordPress). We also recommend reviewing part one of our beginner research series to learn more about WordPress architecture in general.

Finally, you’ll want an Integrated Development Environment to easily navigate plugin and theme code installed on your WordPress test site. We recommend Visual Studio Code (free) or PHPStorm (paid). The WordPress Docker configuration offered by Wordfence includes an XDebug configuration for Visual Studio Code.

If you haven’t already registered for our Bug Bounty Program, you’ll want to sign up here so you’ll be ready once you’ve found your first vulnerability to submit.

Writing an Excellent XSS Report to Submit

Now that you’ve learned how to find and exploit XSS vulnerabilities in WordPress plugins and themes, it’s time to focus on creating a well-structured bug bounty report. A clear and detailed report increases the chances of your submission being accepted and a bounty being awarded. It can also lead to a bonus being awarded to your bounty if it is exceptional. Here’s what you need to include to write an excellent bug bounty report:

Basic Plugin or Theme Information

  • Plugin or Theme Name: The name of the plugin or theme where the vulnerability was found.
  • Plugin or Theme Slug: Provide the official slug, which is the unique identifier for the plugin or theme on the WordPress repository. For premium plugins, this can be derived from the wp-content/plugins or wp-content/themes directory.
  • Link to the Software: Include a link to the plugin or theme on the WordPress repository or the official website if it’s a premium version.
  • Number of Active Installations: Mention the number of active installations at the time of testing.

Root Cause Analysis

A well-written report should provide a detailed root cause analysis that explains where and why the vulnerability exists:

  • Vulnerable Code Files and Line Numbers: Specify the exact files, their locations, and line numbers where the vulnerable code resides. Including links to specific lines of code on WordPress Trac or GitHub can be extremely helpful in expediting the triage process. Direct links to tagged Trac files are preferred since we generally provide links to these in our vulnerability database.
  • Explanation of the Vulnerability: Offer a concise but thorough explanation of what causes the vulnerability. Describe the flaw in the code logic, the missing security checks, or improper data handling that leads to the vulnerability. Provide snippets of the vulnerable code to highlight the issue. This again speeds up the triage process and can help speed up developer response times for remediation as well.

Proof-of-Concept Exploit

Providing a proof-of-concept (PoC) exploit is crucial for demonstrating the vulnerability’s impact:

  • Step-by-Step Walkthrough: Write a detailed step-by-step guide on how to reproduce the vulnerability. Start with downloading and installing the theme or plugin, setting up any dependencies, and configuring any required settings.
  • Reproduction Steps: Clearly document each step needed to trigger the vulnerability. This should include any specific inputs or actions required. The goal is to allow the reviewer to easily replicate the issue without any ambiguity. Keep in mind that this information will be passed along to the developer of the plugin in question and should be easy to follow.
  • PoC Code: If applicable, include the exact code used to exploit the vulnerability. Make sure this code is easy to understand and directly demonstrates the vulnerability. Automated exploits (e.g., written in Python) are a bonus and preferred!

Additional Tips for an Excellent Report

  • Markdown Formatting: Submitting your report in Markdown format is a bonus. Markdown is easy to read and allows for better formatting, making your report more professional and easier to follow.
  • Screenshots and Video: While not necessary, including screenshots or videos can significantly enhance your report, especially if the exploitation process is complex. It’s also important to note that only including a screenshot or video makes it harder to reproduce a report, so this should only be added in addition to a thorough walkthrough or automated exploit.
  • Conciseness and Clarity: While your report should be comprehensive, avoid unnecessary information.
  • Include Contextual Information: If the vulnerability is related to a recent update or a specific version, include this context. Mention if the issue affects only certain versions or configurations.
  • Security Impact: Describe the potential impact of the vulnerability if exploited. Explain how an attacker could leverage it and what kind of damage it could cause in terms of confidentiality, integrity, and availability.
  • Recommended Remediation: For more unique vulnerabilities, it can be helpful if you provide a recommended remediation that we can pass along to the developer.

Report Example

The following is a real-world (excellent) report submitted to us by researcher stealthcopter for CVE-2024-2830, the same Authenticated (Contributor+) Stored Cross-Site Scripting vulnerability we walked through earlier in this post.

## Affected Plugin

Title: WordPress Tag and Category Manager – AI Autotagger
Active installations: 60,000
Version: 3.12.0
Slug: simple-tags
Link: https://wordpress.org/plugins/simple-tags/

## Root Cause

In [inc/class.client.php](https://plugins.trac.wordpress.org/browser/simple-tags/trunk/inc/class.client.php#L346) on line 346 and line 348, the shortcode attribute `title` is inserted into the page body without any sanitization.

```php
if ( $copyright === true ) {
	return "\n" . '' . "\n\t" . $wrap_div_class_open . $title . $output . $wrap_div_class_close . "\n";
} else {
	return "\n\t" . $wrap_div_class_open . $title . $output . $wrap_div_class_close . "\n";
}
```

## Proof of Concept

1. Install and activate the plugin
2. Make sure at least 1 tag exists. (I.e. Add a tag an existing post with tag `test`)
3. As a contributor level user use the following shortcode on a post:
```
[st_tag_cloud id="1" param="title=<img src=x onerror=alert(1)>"]
```
4. Submit or publish and observe JavaScript execution on page load

Focusing on High Impact XSS Vulnerabilities

When searching for XSS vulnerabilities in WordPress plugins and themes, an emphasis should be placed on high-impact vulnerabilities that pose a real-world risk to site owners. High-impact XSS vulnerabilities typically allow unauthenticated threat actors or threat actors with minimal permissions (such as subscribers) to inject malicious scripts into a WordPress site.

Our data shows that these types of vulnerabilities are far more likely to be exploited by threat actors than XSS vulnerabilities that require contributor-, author-, editor-, or admin-level privileges. Unauthenticated and subscriber-level XSS also have higher payouts in the Wordfence Bug Bounty Program due to their increased likelihood of exploitation. Check out the Bounty Rewards tab of our Bug Bounty Program page to calculate payouts for these vulnerabilities.

Recommendation: Avoid Spending Time on Admin/Editor Level XSS Vulnerabilities

We find that some researchers will spend time focusing on Cross-Site Scripting vulnerabilities that can only be exploited by editors or administrators. While these can be considered valid vulnerabilities when the unfiltered_html capability has been disabled in a hardened WordPress environment, or in a multisite instance, the impacted user-base is incredibly minimal and these will almost never be exploited unless an attacker is narrowly targeting a WordPress site that has been locked down. Due to this, we believe that researchers’ valuable efforts are better focused elsewhere on issues that may actually be exploited and will impact a significant user base.

💡 What is unfiltered_html?

unfiltered_html is a capability in WordPress that is granted to site administrators and editors that allows them to add unfiltered HTML, like scripts, in various places like posts and pages. Users without this capability will have their HTML run through wp_kses() which will strip all “evil” tags and attributes. This capability is a common reason why researchers think they’ve found an XSS vulnerability exploitable by admins and editors, when in fact it is the result of the capability in WordPress.

If you decide you still want to test for Admin/Editor level XSS vulnerabilities, make sure to disable unfiltered_html to be sure that any XSS issue you find is not the result of the unfiltered HTML capability. To do this, please add the following line to your wp-config.php file right after the /* Add any custom values between this line and the "stop editing" line. */ line:

define('DISALLOW_UNFILTERED_HTML', true);

Please note that these types of XSS vulnerabilities are out of scope of our Bug Bounty Program, though we will still assign CVE IDs as necessary.

Conclusion

When we launched our Bug Bounty Program in November of 2023, we received a tremendous amount of Cross-Site Scripting (XSS) vulnerability submissions. Many of these were in plugins that had been reviewed by other researchers, yet these vulnerabilities still existed, seemingly missed by previous reviews. We also received submissions using new techniques like taking advantage of incorrectly used sanitization or escaping or leveraging encoding/decoding.

This proves that XSS vulnerabilities continue to be a significant threat to WordPress sites due to the platform’s extensibility and the vast ecosystem of plugins and themes available. As we’ve explored in this blog post, understanding how XSS vulnerabilities can be introduced and exploited in WordPress plugins and themes is a critical first step for anyone interested in WordPress security.

Finding XSS vulnerabilities involves a combination of static and dynamic code analysis, manual testing, searching for sources and sinks, mapping the data flow, and identifying mitigations. By familiarizing yourself with the WordPress Security API functions for data validation, sanitization, and escaping, you can more effectively spot potential vulnerabilities.

For beginners looking to make their mark in WordPress security research, the Wordfence Bug Bounty Program offers an excellent opportunity to apply what you’ve learned in this guide and contribute to making the WordPress ecosystem more secure. By finding and responsibly disclosing vulnerabilities, you can help prevent attacks and protect millions of WordPress sites around the world.

Remember that through October 7th, 2024, Cross-Site Scripting vulnerabilities reported in plugins and themes with over 1,000 active installations will be in-scope for all researchers, regardless of their tier, so everyone can practice finding XSS vulnerabilities in WordPress plugins and themes.

Join the Program Submit a Vulnerability

We encourage you to responsibly use the techniques and strategies covered in this guide to start exploring WordPress plugins and themes for potential XSS vulnerabilities. Happy hunting!

Did you enjoy this post? Share it!

Comments

No Comments

All comments are moderated before being published. Inappropriate or off-topic comments may not be approved.